feat(profile): role-aware nav + avatar menu + full editable profile
Build backend images / build content-svc (push) Failing after 1m59s
Build backend images / build file-svc (push) Failing after 3m18s
Build backend images / build gateway (push) Failing after 3m28s
Build backend images / build identity-svc (push) Failing after 2m1s
Build backend images / build notification-svc (push) Failing after 4m45s
Build backend images / build render-svc (push) Failing after 5m18s
Build backend images / build studio-svc (push) Failing after 2m12s

Navigation:
- UserMenu (avatar + role-aware dropdown: Dashboard, Admin Panel for admins,
  Profile, Sign out) replaces Sign In/Try Free when logged in (desktop + mobile).
- Real avatars in dashboard sidebar + a new admin-shell profile section.
- Shared Avatar primitive (image with initials fallback). SiteChrome excludes /admin.

Profile (data-collection surface for future AI video generation):
- SettingsProfile rebuilt: avatar upload + slogan, about, company, website,
  country, national code, birthdate, gender. No resume builder (per scope change).
- /api/profile forwards all fields; new user-scoped /api/profile/upload (avatar →
  MinIO via file-svc, sets avatar). Identity UpdateUserRequest/UserResponse widened
  (country/national/method); no DB migration (columns already exist).
- fa+en strings; verified GET/PATCH round-trip + logged-in SSR render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 00:34:25 +03:30
parent 718564bce4
commit d4fee8d1d7
21 changed files with 659 additions and 116 deletions
+30 -2
View File
@@ -12,6 +12,13 @@
"learn": "Learn",
"signIn": "Sign In",
"tryForFree": "Try for Free",
"accountMenu": "Account menu",
"roleAdmin": "Admin",
"roleUser": "Member",
"menuDashboard": "Dashboard",
"menuAdminPanel": "Admin panel",
"menuProfile": "Profile & settings",
"menuSignOut": "Sign out",
"openMenuAriaLabel": "Open navigation menu",
"mobileMenuTitle": "Menu",
"videoMakerBrowse": "Browse Templates",
@@ -521,7 +528,7 @@
},
"componentsDashboardSettingsSettingsProfile": {
"title": "Profile",
"subtitle": "Your public name and account email.",
"subtitle": "Your public profile, photo and account details.",
"displayNameLabel": "Display name",
"displayNamePlaceholder": "Your name",
"emailLabel": "Email",
@@ -530,7 +537,28 @@
"saveChanges": "Save changes",
"updateFailed": "Could not update profile.",
"updateSuccess": "Profile updated successfully.",
"networkError": "Network error. Please try again."
"networkError": "Network error. Please try again.",
"changeAvatar": "Change profile picture",
"uploading": "Uploading image…",
"avatarUpdated": "Profile picture updated.",
"uploadFailed": "Could not upload the image.",
"sloganLabel": "Slogan / headline",
"sloganPlaceholder": "e.g. Motion designer",
"aboutLabel": "About me",
"aboutPlaceholder": "Tell us a little about yourself…",
"companyLabel": "Company / business",
"websiteLabel": "Website",
"countryLabel": "Country",
"countryPlaceholder": "United States",
"nationalCodeLabel": "National ID",
"birthDateLabel": "Date of birth",
"genderLabel": "Gender",
"genderUnset": "Not specified",
"genderMale": "Male",
"genderFemale": "Female",
"genderOther": "Other",
"genderPreferNotToSay": "Prefer not to say",
"dataCollectionHint": "This information is used to personalize your experience and power future AI video generation."
},
"componentsDashboardSettingsSettingsSecurity": {
"title": "Security",
+30 -2
View File
@@ -12,6 +12,13 @@
"learn": "یادگیری",
"signIn": "ورود",
"tryForFree": "رایگان شروع کنید",
"accountMenu": "منوی حساب",
"roleAdmin": "مدیر",
"roleUser": "کاربر",
"menuDashboard": "داشبورد",
"menuAdminPanel": "پنل مدیریت",
"menuProfile": "پروفایل و تنظیمات",
"menuSignOut": "خروج",
"openMenuAriaLabel": "باز کردن منو",
"mobileMenuTitle": "منو",
"videoMakerBrowse": "مرور قالب‌ها",
@@ -521,7 +528,7 @@
},
"componentsDashboardSettingsSettingsProfile": {
"title": "پروفایل",
"subtitle": "نام عمومی و ایمیل حساب شما.",
"subtitle": "پروفایل عمومی، تصویر و اطلاعات حساب شما.",
"displayNameLabel": "نام نمایشی",
"displayNamePlaceholder": "نام شما",
"emailLabel": "ایمیل",
@@ -530,7 +537,28 @@
"saveChanges": "ذخیره تغییرات",
"updateFailed": "به‌روزرسانی پروفایل ممکن نشد.",
"updateSuccess": "پروفایل با موفقیت به‌روزرسانی شد.",
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید."
"networkError": "خطای شبکه. لطفاً دوباره تلاش کنید.",
"changeAvatar": "تغییر تصویر پروفایل",
"uploading": "در حال بارگذاری تصویر…",
"avatarUpdated": "تصویر پروفایل به‌روزرسانی شد.",
"uploadFailed": "بارگذاری تصویر ممکن نشد.",
"sloganLabel": "شعار / عنوان",
"sloganPlaceholder": "مثلاً طراح موشن گرافیک",
"aboutLabel": "درباره من",
"aboutPlaceholder": "کمی درباره خودتان بنویسید…",
"companyLabel": "شرکت / کسب‌وکار",
"websiteLabel": "وب‌سایت",
"countryLabel": "کشور",
"countryPlaceholder": "ایران",
"nationalCodeLabel": "کد ملی",
"birthDateLabel": "تاریخ تولد",
"genderLabel": "جنسیت",
"genderUnset": "انتخاب نشده",
"genderMale": "مرد",
"genderFemale": "زن",
"genderOther": "سایر",
"genderPreferNotToSay": "ترجیح می‌دهم نگویم",
"dataCollectionHint": "این اطلاعات برای شخصی‌سازی و ساخت خودکار ویدیو با هوش مصنوعی در آینده استفاده می‌شود."
},
"componentsDashboardSettingsSettingsSecurity": {
"title": "امنیت",
@@ -488,7 +488,9 @@ public class AuthService(
u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(),
u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor,
u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount,
u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate
u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate,
u.Slogan, u.AboutMe, u.CompanyName, u.WebsiteName,
u.BirthDate, u.Gender?.ToString(), u.NationalCode, u.CountryCode
);
internal static TenantResponse MapTenantResponse(Tenant t) => new(
@@ -33,6 +33,9 @@ public class UserService(IdentityDbContext db) : IUserService
if (request.SmsTellMe.HasValue) user.SmsTellMe = request.SmsTellMe.Value;
if (request.PushTellMe.HasValue) user.PushTellMe = request.PushTellMe.Value;
if (request.TelegramTellMe.HasValue) user.TelegramTellMe = request.TelegramTellMe.Value;
if (request.CountryCode != null) user.CountryCode = request.CountryCode;
if (request.NationalCode != null) user.NationalCode = request.NationalCode;
if (request.MethodOfIntroduction != null) user.MethodOfIntroduction = request.MethodOfIntroduction;
user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -11,7 +11,10 @@ public record UpdateUserRequest(
bool? EmailTellMe,
bool? SmsTellMe,
bool? PushTellMe,
bool? TelegramTellMe
bool? TelegramTellMe,
string? CountryCode = null,
string? NationalCode = null,
string? MethodOfIntroduction = null
);
public record SetAvatarRequest(Guid? AvatarId, string? AvatarUrl);
@@ -48,7 +48,16 @@ public record UserResponse(
int MaxDailyRenderCount,
int ParallelRenderingCeiling,
long UsedStorageBytes,
DateTime RegisterDate
DateTime RegisterDate,
// Profile data (collected over time; powers future AI video generation)
string? Slogan = null,
string? AboutMe = null,
string? CompanyName = null,
string? WebsiteName = null,
DateOnly? BirthDate = null,
string? Gender = null,
string? NationalCode = null,
string? CountryCode = null
);
public record BalanceResponse(
+13 -1
View File
@@ -73,8 +73,20 @@ export default async function AdminLayout({
},
];
const email = user.email ?? "";
const fullName = typeof user.full_name === "string" ? user.full_name.trim() : "";
return (
<AdminShell groups={groups} brand={t("brand")} back={t("backToDashboard")}>
<AdminShell
groups={groups}
brand={t("brand")}
back={t("backToDashboard")}
user={{
name: fullName || (email ? email.split("@")[0] : "Admin"),
email,
avatarUrl: (user.avatar_url as string | null) ?? null,
}}
>
{children}
</AdminShell>
);
+1
View File
@@ -21,6 +21,7 @@ export default async function DashboardLayout({
userEmail={user.email ?? ""}
userName={user.full_name ?? null}
userId={user.id}
avatarUrl={(user.avatar_url as string | null) ?? null}
>
{children}
</DashboardShell>
+15 -3
View File
@@ -23,8 +23,20 @@ export default async function DashboardSettingsPage() {
const user = await getCurrentUser();
const email = user?.email ?? "";
const displayName =
typeof user?.full_name === "string" ? user.full_name : null;
const u = (user ?? {}) as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : "");
const initialProfile = {
full_name: str(u.full_name),
avatar_url: typeof u.avatar_url === "string" ? u.avatar_url : null,
slogan: str(u.slogan),
about_me: str(u.about_me),
company_name: str(u.company_name),
website_name: str(u.website_name),
country_code: str(u.country_code),
national_code: str(u.national_code),
birth_date: str(u.birth_date),
gender: str(u.gender),
};
const profile = user ? await getUserProfile(user.id) : null;
const plan = profile?.plan ?? "free";
@@ -42,7 +54,7 @@ export default async function DashboardSettingsPage() {
{/* Content */}
<div className="flex-1 p-6">
<div className="mx-auto max-w-2xl space-y-6">
<SettingsProfile email={email} displayName={displayName} />
<SettingsProfile email={email} initial={initialProfile} />
<SettingsSecurity />
<SettingsBilling plan={plan} />
<SettingsNotifications />
+3 -1
View File
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
import { DirectionProvider } from "@/components/layout/DirectionProvider";
import { SiteChrome } from "@/components/layout/SiteChrome";
import { getNavUser } from "@/lib/auth/session";
import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing";
@@ -85,6 +86,7 @@ export default async function LocaleLayout({
const messages = await getMessages();
const isRtl = locale === "fa";
const navUser = await getNavUser();
/**
* Font class strategy:
@@ -112,7 +114,7 @@ export default async function LocaleLayout({
>
<NextIntlClientProvider messages={messages} locale={locale}>
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
<SiteChrome>{children}</SiteChrome>
<SiteChrome user={navUser}>{children}</SiteChrome>
</DirectionProvider>
</NextIntlClientProvider>
</body>
+24 -5
View File
@@ -6,10 +6,24 @@ import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants";
export const dynamic = "force-dynamic";
// Profile fields the user may edit; forwarded as-is (snake_case) to the Identity
// PATCH DTO. This is the data-collection surface that later powers AI video gen.
const EDITABLE_FIELDS = [
"full_name",
"slogan",
"about_me",
"company_name",
"website_name",
"birth_date",
"gender",
"country_code",
"national_code",
"method_of_introduction",
] as const;
/**
* Update the signed-in user's profile via Identity (`PATCH /v1/users/me`).
* Currently surfaces the display name (full_name); the Identity DTO accepts more
* fields that can be added here as the settings UI grows.
* Forwards any of the editable profile fields that are present in the request body.
*/
export async function PATCH(req: Request) {
const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value;
@@ -18,15 +32,20 @@ export async function PATCH(req: Request) {
}
const body = await req.json().catch(() => null);
const fullName = typeof body?.full_name === "string" ? body.full_name.trim() : undefined;
if (fullName === undefined) {
const payload: Record<string, unknown> = {};
for (const key of EDITABLE_FIELDS) {
const v = body?.[key];
if (v === undefined || v === null) continue;
payload[key] = typeof v === "string" ? v.trim() : v;
}
if (Object.keys(payload).length === 0) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
const res = await gatewayFetch("/v1/users/me", {
method: "PATCH",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ full_name: fullName }),
body: JSON.stringify(payload),
});
if (!res.ok) {
+91
View File
@@ -0,0 +1,91 @@
import { type NextRequest, NextResponse } from "next/server";
import { gatewayUrl } from "@/lib/api/gateway";
import { getAccessToken } from "@/lib/auth/session";
import { MINIO_PUBLIC_URL } from "@/lib/files";
export const dynamic = "force-dynamic";
/**
* User-scoped upload (avatar / profile media). Same Browser → Next → MinIO proxy as
* the admin uploader, but available to ANY logged-in user (file-svc only requires
* auth, not admin). Returns the public object URL.
*/
export async function POST(req: NextRequest) {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData().catch(() => null);
const file = form?.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Avatars are images and small — guard so a profile upload can't be abused for
// arbitrary large files. (5 MB is generous for an avatar.)
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 413 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
const auth = { Authorization: `Bearer ${token}` };
// 1. presigned PUT URL
const presignRes = await fetch(gatewayUrl("/v1/files/presigned-upload"), {
method: "POST",
cache: "no-store",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
mime_type: file.type || "application/octet-stream",
size_bytes: file.size,
}),
});
const presign = await presignRes.json().catch(() => null);
if (!presignRes.ok || !presign?.upload_url || !presign?.file_id) {
return NextResponse.json(
{ error: presign?.error?.message ?? "Could not start upload" },
{ status: presignRes.status || 502 }
);
}
// 2. PUT the bytes to MinIO (server-side; reaches minio:9000)
const put = await fetch(presign.upload_url, {
method: "PUT",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: Buffer.from(await file.arrayBuffer()),
});
if (!put.ok) {
return NextResponse.json({ error: "Upload to storage failed" }, { status: 502 });
}
// 3. confirm
await fetch(gatewayUrl(`/v1/files/${presign.file_id}/confirm`), {
method: "POST",
cache: "no-store",
headers: auth,
});
// 4. fetch the record → build the public URL
const detailRes = await fetch(gatewayUrl(`/v1/files/${presign.file_id}`), {
cache: "no-store",
headers: auth,
});
const detail = await detailRes.json().catch(() => null);
const bucket = detail?.minio_bucket ?? "user-uploads";
const key = detail?.minio_key;
const url = key ? `${MINIO_PUBLIC_URL}/${bucket}/${key}` : null;
// 5. persist as the user's avatar (Identity `POST /v1/users/me/avatar`)
if (url) {
await fetch(gatewayUrl("/v1/users/me/avatar"), {
method: "POST",
cache: "no-store",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ avatar_url: url }),
}).catch(() => null);
}
return NextResponse.json({ id: presign.file_id, name: file.name, mime_type: file.type, url });
}
+30
View File
@@ -1,25 +1,31 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/Avatar";
export interface NavItem { href: string; label: string }
export interface NavGroup { title: string; items: NavItem[] }
export interface AdminUser { name: string; email: string; avatarUrl: string | null }
export function AdminShell({
groups,
brand,
back,
user,
children,
}: {
groups: NavGroup[];
brand: string;
back: string;
user?: AdminUser;
children: React.ReactNode;
}) {
const pathname = usePathname() ?? ""; // next-intl: already without the locale prefix
const [open, setOpen] = useState(false);
const tNav = useTranslations("nav");
const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/");
const current = groups.flatMap((g) => g.items).find((i) => isActive(i.href));
@@ -66,9 +72,33 @@ export function AdminShell({
))}
</nav>
<div className="border-t border-[#1e2235] p-3">
{user && (
<Link
href="/dashboard/settings"
className="mb-1 flex items-center gap-2.5 rounded-lg px-2 py-2 transition-colors hover:bg-[#161a2e]"
onClick={() => setOpen(false)}
>
<Avatar
src={user.avatarUrl}
name={user.name}
email={user.email}
size={34}
fallbackClassName="bg-indigo-600/20 text-indigo-300"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{user.name}</p>
<p className="truncate text-xs text-gray-500">{user.email}</p>
</div>
</Link>
)}
<Link href="/dashboard" className="block rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-[#161a2e] hover:text-white">
{back}
</Link>
<form action="/auth/sign-out" method="post">
<button type="submit" className="mt-0.5 block w-full rounded-lg px-3 py-1.5 text-start text-sm text-red-400 transition-colors hover:bg-red-500/10">
{tNav("menuSignOut")}
</button>
</form>
</div>
</aside>
@@ -4,6 +4,7 @@ interface DashboardShellProps {
userEmail: string;
userName?: string | null;
userId: string;
avatarUrl?: string | null;
children: React.ReactNode;
}
@@ -11,6 +12,7 @@ export function DashboardShell({
userEmail,
userName,
userId,
avatarUrl,
children,
}: DashboardShellProps) {
return (
@@ -19,6 +21,7 @@ export function DashboardShell({
userEmail={userEmail}
userName={userName}
userId={userId}
avatarUrl={avatarUrl}
/>
<div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div>
</div>
+9 -21
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { LogoMark } from "@/components/ui/LogoMark";
import { Avatar } from "@/components/ui/Avatar";
import {
DashboardPlanBadge,
@@ -13,27 +14,16 @@ interface DashboardSidebarProps {
userEmail: string;
userName?: string | null;
userId: string;
}
function getInitials(email: string, name?: string | null): string {
if (name?.trim()) {
const parts = name.trim().split(/\s+/);
return parts
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
return email.slice(0, 2).toUpperCase();
avatarUrl?: string | null;
}
export async function DashboardSidebar({
userEmail,
userName,
userId,
avatarUrl,
}: DashboardSidebarProps) {
const t = await getTranslations("auto.componentsDashboardDashboardSidebar");
const initials = getInitials(userEmail, userName);
return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-100 bg-white">
@@ -61,20 +51,18 @@ export async function DashboardSidebar({
</Suspense>
</div>
<div className="flex items-center gap-3 rounded-lg px-2 py-2">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-sm font-semibold text-primary-700"
aria-hidden
>
{initials}
</div>
<Link
href="/dashboard/settings"
className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
>
<Avatar src={avatarUrl} name={userName} email={userEmail} size={40} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900">
{userName ?? userEmail.split("@")[0]}
</p>
<p className="truncate text-xs text-neutral-500">{userEmail}</p>
</div>
</div>
</Link>
<form action="/auth/sign-out" method="post" className="mt-3">
<button
type="submit"
@@ -1,21 +1,60 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { User } from "lucide-react";
import { Camera, User } from "lucide-react";
interface SettingsProfileProps {
email: string;
displayName: string | null;
import { Avatar } from "@/components/ui/Avatar";
export interface ProfileData {
full_name: string;
avatar_url: string | null;
slogan: string;
about_me: string;
company_name: string;
website_name: string;
country_code: string;
national_code: string;
birth_date: string; // yyyy-mm-dd or ""
gender: string; // GenderKind name or ""
}
export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
const t = useTranslations("auto.componentsDashboardSettingsSettingsProfile");
const [name, setName] = useState(displayName ?? "");
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const GENDERS = ["Male", "Female", "Other", "PreferNotToSay"] as const;
const initials = (displayName ?? email).slice(0, 2).toUpperCase();
export function SettingsProfile({ email, initial }: { email: string; initial: ProfileData }) {
const t = useTranslations("auto.componentsDashboardSettingsSettingsProfile");
const [form, setForm] = useState<ProfileData>(initial);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial.avatar_url);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const set = (k: keyof ProfileData, v: string) => setForm((f) => ({ ...f, [k]: v }));
async function handleAvatar(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/profile/upload", { method: "POST", body: fd });
const data = (await res.json().catch(() => null)) as { url?: string; error?: string } | null;
if (!res.ok || !data?.url) {
setMessage({ type: "error", text: data?.error ?? t("uploadFailed") });
} else {
setAvatarUrl(data.url);
setMessage({ type: "success", text: t("avatarUpdated") });
}
} catch {
setMessage({ type: "error", text: t("networkError") });
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
@@ -25,14 +64,24 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
const res = await fetch("/api/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_name: name.trim() }),
body: JSON.stringify({
full_name: form.full_name.trim(),
slogan: form.slogan.trim(),
about_me: form.about_me.trim(),
company_name: form.company_name.trim(),
website_name: form.website_name.trim(),
country_code: form.country_code.trim(),
national_code: form.national_code.trim(),
birth_date: form.birth_date || null,
gender: form.gender || null,
}),
});
const data = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
setMessage({ type: "error", text: data?.error ?? t("updateFailed") });
} else {
setMessage({ type: "success", text: t("updateSuccess") });
}
setMessage(
res.ok
? { type: "success", text: t("updateSuccess") }
: { type: "error", text: data?.error ?? t("updateFailed") }
);
} catch {
setMessage({ type: "error", text: t("networkError") });
} finally {
@@ -40,38 +89,87 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
}
}
const field =
"mt-1.5 block w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const labelCls = "block text-sm font-medium text-neutral-700";
return (
<div className="rounded-xl border border-gray-100 bg-white p-6">
<h2 className="font-heading text-base font-semibold text-neutral-900">{t("title")}</h2>
<p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
{/* Avatar */}
<div className="mt-6 flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 font-heading text-xl font-bold text-primary-700">
{initials}
<div className="relative">
<Avatar src={avatarUrl} name={form.full_name} email={email} size={72} className="text-2xl" />
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="absolute -bottom-1 -end-1 grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-primary-600 text-white transition-colors hover:bg-primary-700 disabled:opacity-50"
aria-label={t("changeAvatar")}
>
<Camera className="h-3.5 w-3.5" aria-hidden />
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => void handleAvatar(e)} />
</div>
<div>
<p className="font-medium text-neutral-900">{displayName ?? email.split("@")[0]}</p>
<p className="text-sm text-neutral-500">{email}</p>
<p className="font-medium text-neutral-900">{form.full_name || email.split("@")[0]}</p>
<p className="text-sm text-neutral-500">{uploading ? t("uploading") : email}</p>
</div>
</div>
<form onSubmit={(e) => void handleSave(e)} className="mt-6 space-y-4">
<div>
<label htmlFor="display-name" className="block text-sm font-medium text-neutral-700">
{t("displayNameLabel")}
</label>
<input
id="display-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("displayNamePlaceholder")}
className="mt-1.5 block w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="full_name" className={labelCls}>{t("displayNameLabel")}</label>
<input id="full_name" value={form.full_name} onChange={(e) => set("full_name", e.target.value)} placeholder={t("displayNamePlaceholder")} className={field} />
</div>
<div>
<label htmlFor="slogan" className={labelCls}>{t("sloganLabel")}</label>
<input id="slogan" value={form.slogan} onChange={(e) => set("slogan", e.target.value)} placeholder={t("sloganPlaceholder")} className={field} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700">{t("emailLabel")}</label>
<label htmlFor="about_me" className={labelCls}>{t("aboutLabel")}</label>
<textarea id="about_me" rows={3} value={form.about_me} onChange={(e) => set("about_me", e.target.value)} placeholder={t("aboutPlaceholder")} className={field} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="company_name" className={labelCls}>{t("companyLabel")}</label>
<input id="company_name" value={form.company_name} onChange={(e) => set("company_name", e.target.value)} className={field} />
</div>
<div>
<label htmlFor="website_name" className={labelCls}>{t("websiteLabel")}</label>
<input id="website_name" value={form.website_name} onChange={(e) => set("website_name", e.target.value)} placeholder="example.com" className={field} />
</div>
<div>
<label htmlFor="country_code" className={labelCls}>{t("countryLabel")}</label>
<input id="country_code" value={form.country_code} onChange={(e) => set("country_code", e.target.value)} placeholder={t("countryPlaceholder")} className={field} />
</div>
<div>
<label htmlFor="national_code" className={labelCls}>{t("nationalCodeLabel")}</label>
<input id="national_code" value={form.national_code} onChange={(e) => set("national_code", e.target.value)} className={field} />
</div>
<div>
<label htmlFor="birth_date" className={labelCls}>{t("birthDateLabel")}</label>
<input id="birth_date" type="date" value={form.birth_date} onChange={(e) => set("birth_date", e.target.value)} className={field} />
</div>
<div>
<label htmlFor="gender" className={labelCls}>{t("genderLabel")}</label>
<select id="gender" value={form.gender} onChange={(e) => set("gender", e.target.value)} className={field}>
<option value="">{t("genderUnset")}</option>
{GENDERS.map((g) => (
<option key={g} value={g}>{t(`gender${g}`)}</option>
))}
</select>
</div>
</div>
<div>
<label className={labelCls}>{t("emailLabel")}</label>
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-gray-200 bg-neutral-50 px-3 py-2">
<User className="h-4 w-4 text-neutral-400" aria-hidden />
<span className="text-sm text-neutral-500">{email}</span>
@@ -80,18 +178,19 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
</div>
{message && (
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>
{message.text}
</p>
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>{message.text}</p>
)}
<button
type="submit"
disabled={saving}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
{saving ? t("saving") : t("saveChanges")}
</button>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-neutral-400">{t("dataCollectionHint")}</p>
<button
type="submit"
disabled={saving}
className="shrink-0 rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
{saving ? t("saving") : t("saveChanges")}
</button>
</div>
</form>
</div>
);
+82 -31
View File
@@ -13,7 +13,10 @@ import {
} from "@/components/layout/NavbarMenuDropdown";
import { NavbarMobileMenu } from "@/components/layout/NavbarMobileMenu";
import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher";
import { UserMenu } from "@/components/layout/UserMenu";
import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/button";
import type { NavUser } from "@/lib/auth/session";
import {
Sheet,
SheetContent,
@@ -22,7 +25,7 @@ import {
SheetTrigger,
} from "@/components/ui/sheet";
export function Navbar() {
export function Navbar({ user }: { user?: NavUser | null }) {
const t = useTranslations("nav");
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
@@ -112,15 +115,23 @@ export function Navbar() {
{/* Language switcher — desktop */}
<LanguageSwitcher className="hidden sm:flex" />
<Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="/auth">{t("signIn")}</Link>
</Button>
<Button
asChild
className="hidden bg-blue-600 text-white hover:bg-blue-700 sm:inline-flex"
>
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
</Button>
{user ? (
<div className="hidden sm:flex">
<UserMenu user={user} />
</div>
) : (
<>
<Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="/auth">{t("signIn")}</Link>
</Button>
<Button
asChild
className="hidden bg-blue-600 text-white hover:bg-blue-700 sm:inline-flex"
>
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
</Button>
</>
)}
{/* Mobile menu trigger */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
@@ -141,32 +152,72 @@ export function Navbar() {
<NavbarMobileMenu onNavigate={closeMobile} />
<div className="mt-auto flex flex-col gap-3 border-t border-gray-100 pb-8 pt-6">
<LanguageSwitcher className="w-full justify-center border border-gray-200" />
<Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/auth" onClick={closeMobile}>
{t("signIn")}
</Link>
</Button>
<Button
size="lg"
className="w-full bg-blue-600 text-white hover:bg-blue-700"
asChild
>
<Link href="/auth?tab=sign-up" onClick={closeMobile}>
{t("tryForFree")}
</Link>
</Button>
{user ? (
<>
<div className="flex items-center gap-3 px-1 py-2">
<Avatar src={user.avatarUrl} name={user.name} email={user.email} size={40} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900">{user.name}</p>
<p className="truncate text-xs text-neutral-500">{user.email}</p>
</div>
</div>
<Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/dashboard" onClick={closeMobile}>{t("menuDashboard")}</Link>
</Button>
{user.isAdmin && (
<Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/admin" onClick={closeMobile}>{t("menuAdminPanel")}</Link>
</Button>
)}
<Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/dashboard/settings" onClick={closeMobile}>{t("menuProfile")}</Link>
</Button>
<form action="/auth/sign-out" method="post" className="w-full">
<Button type="submit" variant="ghost" size="lg" className="w-full text-red-600 hover:bg-red-50 hover:text-red-700">
{t("menuSignOut")}
</Button>
</form>
</>
) : (
<>
<Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/auth" onClick={closeMobile}>
{t("signIn")}
</Link>
</Button>
<Button
size="lg"
className="w-full bg-blue-600 text-white hover:bg-blue-700"
asChild
>
<Link href="/auth?tab=sign-up" onClick={closeMobile}>
{t("tryForFree")}
</Link>
</Button>
</>
)}
</div>
</SheetContent>
</Sheet>
{/* Mobile CTA (outside sheet) */}
<Button
asChild
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700 lg:hidden"
>
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
</Button>
{user ? (
<Link
href="/dashboard"
className="rounded-full p-0.5 lg:hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
aria-label={t("accountMenu")}
>
<Avatar src={user.avatarUrl} name={user.name} email={user.email} size={32} />
</Link>
) : (
<Button
asChild
size="sm"
className="bg-blue-600 text-white hover:bg-blue-700 lg:hidden"
>
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link>
</Button>
)}
</div>
</div>
</header>
+8 -3
View File
@@ -4,15 +4,20 @@ import { usePathname } from "next/navigation";
import { Footer } from "@/components/layout/Footer";
import { Navbar } from "@/components/layout/Navbar";
import type { NavUser } from "@/lib/auth/session";
interface SiteChromeProps {
children: React.ReactNode;
user?: NavUser | null;
}
export function SiteChrome({ children }: SiteChromeProps) {
export function SiteChrome({ children, user }: SiteChromeProps) {
const pathname = usePathname();
// Dashboard, studio and admin all provide their own shell — no public chrome.
const isAppShell =
pathname.startsWith("/dashboard") || pathname.startsWith("/studio");
pathname.startsWith("/dashboard") ||
pathname.startsWith("/studio") ||
pathname.startsWith("/admin");
if (isAppShell) {
return <>{children}</>;
@@ -20,7 +25,7 @@ export function SiteChrome({ children }: SiteChromeProps) {
return (
<>
<Navbar />
<Navbar user={user} />
{children}
<Footer />
</>
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/Avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { NavUser } from "@/lib/auth/session";
/**
* Logged-in navbar control: avatar + dropdown with role-aware links (admins also
* get the admin panel) and sign-out. Shown in place of Sign In / Try Free.
*/
export function UserMenu({ user }: { user: NavUser }) {
const t = useTranslations("nav");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 rounded-full p-0.5 pe-2 transition-colors hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
aria-label={t("accountMenu")}
>
<Avatar src={user.avatarUrl} name={user.name} email={user.email} size={32} />
<ChevronDown className="hidden h-4 w-4 text-gray-500 sm:block" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<div className="flex items-center gap-3 px-2 py-2">
<Avatar src={user.avatarUrl} name={user.name} email={user.email} size={40} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900">{user.name}</p>
<p className="truncate text-xs text-neutral-500">{user.email}</p>
</div>
</div>
<div className="px-2 pb-1.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-medium ${
user.isAdmin ? "bg-indigo-100 text-indigo-700" : "bg-primary-100 text-primary-700"
}`}
>
{user.isAdmin ? t("roleAdmin") : t("roleUser")}
</span>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/dashboard" className="cursor-pointer">{t("menuDashboard")}</Link>
</DropdownMenuItem>
{user.isAdmin && (
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">{t("menuAdminPanel")}</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/dashboard/settings" className="cursor-pointer">{t("menuProfile")}</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<form action="/auth/sign-out" method="post" className="w-full">
<button type="submit" className="w-full cursor-pointer text-start text-red-600">
{t("menuSignOut")}
</button>
</form>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Avatar — renders the user's uploaded image (avatar_url) or, as a fallback, their
* initials in a coloured circle. Works in both server and client components (no
* hooks). Theme the initials fallback via `fallbackClassName`.
*/
export function getInitials(name?: string | null, email?: string | null): string {
if (name?.trim()) {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
if (email) return email.slice(0, 2).toUpperCase();
return "•";
}
interface AvatarProps {
src?: string | null;
name?: string | null;
email?: string | null;
size?: number;
className?: string;
/** Tailwind classes for the initials fallback circle (bg + text colour). */
fallbackClassName?: string;
}
export function Avatar({
src,
name,
email,
size = 40,
className = "",
fallbackClassName = "bg-primary-100 text-primary-700",
}: AvatarProps) {
if (src) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={name ?? email ?? "avatar"}
width={size}
height={size}
style={{ width: size, height: size }}
className={`shrink-0 rounded-full object-cover ${className}`}
/>
);
}
return (
<div
aria-hidden
style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}
className={`flex shrink-0 items-center justify-center rounded-full font-heading font-semibold ${fallbackClassName} ${className}`}
>
{getInitials(name, email)}
</div>
);
}
+22
View File
@@ -60,3 +60,25 @@ export async function getCurrentUser(): Promise<IdentityUser | null> {
if (!res.ok) return null;
return (await res.json().catch(() => null)) as IdentityUser | null;
}
/** Minimal, serializable user summary for the navbar/profile menu (passed from
* server layouts into client components). Null when signed out. */
export interface NavUser {
name: string;
email: string;
avatarUrl: string | null;
isAdmin: boolean;
}
export async function getNavUser(): Promise<NavUser | null> {
const user = await getCurrentUser();
if (!user) return null;
const email = user.email ?? "";
const fullName = typeof user.full_name === "string" ? user.full_name.trim() : "";
return {
name: fullName || (email ? email.split("@")[0] : "User"),
email,
avatarUrl: (user.avatar_url as string | null) ?? null,
isAdmin: Boolean(user.is_admin) || Boolean((user as Record<string, unknown>).is_tenant_admin),
};
}