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", "learn": "Learn",
"signIn": "Sign In", "signIn": "Sign In",
"tryForFree": "Try for Free", "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", "openMenuAriaLabel": "Open navigation menu",
"mobileMenuTitle": "Menu", "mobileMenuTitle": "Menu",
"videoMakerBrowse": "Browse Templates", "videoMakerBrowse": "Browse Templates",
@@ -521,7 +528,7 @@
}, },
"componentsDashboardSettingsSettingsProfile": { "componentsDashboardSettingsSettingsProfile": {
"title": "Profile", "title": "Profile",
"subtitle": "Your public name and account email.", "subtitle": "Your public profile, photo and account details.",
"displayNameLabel": "Display name", "displayNameLabel": "Display name",
"displayNamePlaceholder": "Your name", "displayNamePlaceholder": "Your name",
"emailLabel": "Email", "emailLabel": "Email",
@@ -530,7 +537,28 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"updateFailed": "Could not update profile.", "updateFailed": "Could not update profile.",
"updateSuccess": "Profile updated successfully.", "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": { "componentsDashboardSettingsSettingsSecurity": {
"title": "Security", "title": "Security",
+30 -2
View File
@@ -12,6 +12,13 @@
"learn": "یادگیری", "learn": "یادگیری",
"signIn": "ورود", "signIn": "ورود",
"tryForFree": "رایگان شروع کنید", "tryForFree": "رایگان شروع کنید",
"accountMenu": "منوی حساب",
"roleAdmin": "مدیر",
"roleUser": "کاربر",
"menuDashboard": "داشبورد",
"menuAdminPanel": "پنل مدیریت",
"menuProfile": "پروفایل و تنظیمات",
"menuSignOut": "خروج",
"openMenuAriaLabel": "باز کردن منو", "openMenuAriaLabel": "باز کردن منو",
"mobileMenuTitle": "منو", "mobileMenuTitle": "منو",
"videoMakerBrowse": "مرور قالب‌ها", "videoMakerBrowse": "مرور قالب‌ها",
@@ -521,7 +528,7 @@
}, },
"componentsDashboardSettingsSettingsProfile": { "componentsDashboardSettingsSettingsProfile": {
"title": "پروفایل", "title": "پروفایل",
"subtitle": "نام عمومی و ایمیل حساب شما.", "subtitle": "پروفایل عمومی، تصویر و اطلاعات حساب شما.",
"displayNameLabel": "نام نمایشی", "displayNameLabel": "نام نمایشی",
"displayNamePlaceholder": "نام شما", "displayNamePlaceholder": "نام شما",
"emailLabel": "ایمیل", "emailLabel": "ایمیل",
@@ -530,7 +537,28 @@
"saveChanges": "ذخیره تغییرات", "saveChanges": "ذخیره تغییرات",
"updateFailed": "به‌روزرسانی پروفایل ممکن نشد.", "updateFailed": "به‌روزرسانی پروفایل ممکن نشد.",
"updateSuccess": "پروفایل با موفقیت به‌روزرسانی شد.", "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": { "componentsDashboardSettingsSettingsSecurity": {
"title": "امنیت", "title": "امنیت",
@@ -488,7 +488,9 @@ public class AuthService(
u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(), u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(),
u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor, u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor,
u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount, 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( 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.SmsTellMe.HasValue) user.SmsTellMe = request.SmsTellMe.Value;
if (request.PushTellMe.HasValue) user.PushTellMe = request.PushTellMe.Value; if (request.PushTellMe.HasValue) user.PushTellMe = request.PushTellMe.Value;
if (request.TelegramTellMe.HasValue) user.TelegramTellMe = request.TelegramTellMe.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; user.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -11,7 +11,10 @@ public record UpdateUserRequest(
bool? EmailTellMe, bool? EmailTellMe,
bool? SmsTellMe, bool? SmsTellMe,
bool? PushTellMe, bool? PushTellMe,
bool? TelegramTellMe bool? TelegramTellMe,
string? CountryCode = null,
string? NationalCode = null,
string? MethodOfIntroduction = null
); );
public record SetAvatarRequest(Guid? AvatarId, string? AvatarUrl); public record SetAvatarRequest(Guid? AvatarId, string? AvatarUrl);
@@ -48,7 +48,16 @@ public record UserResponse(
int MaxDailyRenderCount, int MaxDailyRenderCount,
int ParallelRenderingCeiling, int ParallelRenderingCeiling,
long UsedStorageBytes, 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( 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 ( 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} {children}
</AdminShell> </AdminShell>
); );
+1
View File
@@ -21,6 +21,7 @@ export default async function DashboardLayout({
userEmail={user.email ?? ""} userEmail={user.email ?? ""}
userName={user.full_name ?? null} userName={user.full_name ?? null}
userId={user.id} userId={user.id}
avatarUrl={(user.avatar_url as string | null) ?? null}
> >
{children} {children}
</DashboardShell> </DashboardShell>
+15 -3
View File
@@ -23,8 +23,20 @@ export default async function DashboardSettingsPage() {
const user = await getCurrentUser(); const user = await getCurrentUser();
const email = user?.email ?? ""; const email = user?.email ?? "";
const displayName = const u = (user ?? {}) as Record<string, unknown>;
typeof user?.full_name === "string" ? user.full_name : null; 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 profile = user ? await getUserProfile(user.id) : null;
const plan = profile?.plan ?? "free"; const plan = profile?.plan ?? "free";
@@ -42,7 +54,7 @@ export default async function DashboardSettingsPage() {
{/* Content */} {/* Content */}
<div className="flex-1 p-6"> <div className="flex-1 p-6">
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<SettingsProfile email={email} displayName={displayName} /> <SettingsProfile email={email} initial={initialProfile} />
<SettingsSecurity /> <SettingsSecurity />
<SettingsBilling plan={plan} /> <SettingsBilling plan={plan} />
<SettingsNotifications /> <SettingsNotifications />
+3 -1
View File
@@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl";
import { DirectionProvider } from "@/components/layout/DirectionProvider"; import { DirectionProvider } from "@/components/layout/DirectionProvider";
import { SiteChrome } from "@/components/layout/SiteChrome"; import { SiteChrome } from "@/components/layout/SiteChrome";
import { getNavUser } from "@/lib/auth/session";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import type { Locale } from "@/i18n/routing"; import type { Locale } from "@/i18n/routing";
@@ -85,6 +86,7 @@ export default async function LocaleLayout({
const messages = await getMessages(); const messages = await getMessages();
const isRtl = locale === "fa"; const isRtl = locale === "fa";
const navUser = await getNavUser();
/** /**
* Font class strategy: * Font class strategy:
@@ -112,7 +114,7 @@ export default async function LocaleLayout({
> >
<NextIntlClientProvider messages={messages} locale={locale}> <NextIntlClientProvider messages={messages} locale={locale}>
<DirectionProvider dir={isRtl ? "rtl" : "ltr"}> <DirectionProvider dir={isRtl ? "rtl" : "ltr"}>
<SiteChrome>{children}</SiteChrome> <SiteChrome user={navUser}>{children}</SiteChrome>
</DirectionProvider> </DirectionProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
+24 -5
View File
@@ -6,10 +6,24 @@ import { ACCESS_TOKEN_COOKIE } from "@/lib/auth/constants";
export const dynamic = "force-dynamic"; 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`). * 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 * Forwards any of the editable profile fields that are present in the request body.
* fields that can be added here as the settings UI grows.
*/ */
export async function PATCH(req: Request) { export async function PATCH(req: Request) {
const token = (await cookies()).get(ACCESS_TOKEN_COOKIE)?.value; 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 body = await req.json().catch(() => null);
const fullName = typeof body?.full_name === "string" ? body.full_name.trim() : undefined; const payload: Record<string, unknown> = {};
if (fullName === undefined) { 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 }); return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
} }
const res = await gatewayFetch("/v1/users/me", { const res = await gatewayFetch("/v1/users/me", {
method: "PATCH", method: "PATCH",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ full_name: fullName }), body: JSON.stringify(payload),
}); });
if (!res.ok) { 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"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation"; import { Link, usePathname } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/Avatar";
export interface NavItem { href: string; label: string } export interface NavItem { href: string; label: string }
export interface NavGroup { title: string; items: NavItem[] } export interface NavGroup { title: string; items: NavItem[] }
export interface AdminUser { name: string; email: string; avatarUrl: string | null }
export function AdminShell({ export function AdminShell({
groups, groups,
brand, brand,
back, back,
user,
children, children,
}: { }: {
groups: NavGroup[]; groups: NavGroup[];
brand: string; brand: string;
back: string; back: string;
user?: AdminUser;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const pathname = usePathname() ?? ""; // next-intl: already without the locale prefix const pathname = usePathname() ?? ""; // next-intl: already without the locale prefix
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const tNav = useTranslations("nav");
const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/"); const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/");
const current = groups.flatMap((g) => g.items).find((i) => isActive(i.href)); const current = groups.flatMap((g) => g.items).find((i) => isActive(i.href));
@@ -66,9 +72,33 @@ export function AdminShell({
))} ))}
</nav> </nav>
<div className="border-t border-[#1e2235] p-3"> <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"> <Link href="/dashboard" className="block rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-[#161a2e] hover:text-white">
{back} {back}
</Link> </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> </div>
</aside> </aside>
@@ -4,6 +4,7 @@ interface DashboardShellProps {
userEmail: string; userEmail: string;
userName?: string | null; userName?: string | null;
userId: string; userId: string;
avatarUrl?: string | null;
children: React.ReactNode; children: React.ReactNode;
} }
@@ -11,6 +12,7 @@ export function DashboardShell({
userEmail, userEmail,
userName, userName,
userId, userId,
avatarUrl,
children, children,
}: DashboardShellProps) { }: DashboardShellProps) {
return ( return (
@@ -19,6 +21,7 @@ export function DashboardShell({
userEmail={userEmail} userEmail={userEmail}
userName={userName} userName={userName}
userId={userId} userId={userId}
avatarUrl={avatarUrl}
/> />
<div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div> <div className="flex min-h-screen min-w-0 flex-1 flex-col">{children}</div>
</div> </div>
+9 -21
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { LogoMark } from "@/components/ui/LogoMark"; import { LogoMark } from "@/components/ui/LogoMark";
import { Avatar } from "@/components/ui/Avatar";
import { import {
DashboardPlanBadge, DashboardPlanBadge,
@@ -13,27 +14,16 @@ interface DashboardSidebarProps {
userEmail: string; userEmail: string;
userName?: string | null; userName?: string | null;
userId: string; userId: string;
} avatarUrl?: string | null;
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();
} }
export async function DashboardSidebar({ export async function DashboardSidebar({
userEmail, userEmail,
userName, userName,
userId, userId,
avatarUrl,
}: DashboardSidebarProps) { }: DashboardSidebarProps) {
const t = await getTranslations("auto.componentsDashboardDashboardSidebar"); const t = await getTranslations("auto.componentsDashboardDashboardSidebar");
const initials = getInitials(userEmail, userName);
return ( return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-100 bg-white"> <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> </Suspense>
</div> </div>
<div className="flex items-center gap-3 rounded-lg px-2 py-2"> <Link
<div href="/dashboard/settings"
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" className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
aria-hidden >
> <Avatar src={avatarUrl} name={userName} email={userEmail} size={40} />
{initials}
</div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900"> <p className="truncate text-sm font-medium text-neutral-900">
{userName ?? userEmail.split("@")[0]} {userName ?? userEmail.split("@")[0]}
</p> </p>
<p className="truncate text-xs text-neutral-500">{userEmail}</p> <p className="truncate text-xs text-neutral-500">{userEmail}</p>
</div> </div>
</div> </Link>
<form action="/auth/sign-out" method="post" className="mt-3"> <form action="/auth/sign-out" method="post" className="mt-3">
<button <button
type="submit" type="submit"
@@ -1,21 +1,60 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { User } from "lucide-react"; import { Camera, User } from "lucide-react";
interface SettingsProfileProps { import { Avatar } from "@/components/ui/Avatar";
email: string;
displayName: string | null; 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 GENDERS = ["Male", "Female", "Other", "PreferNotToSay"] as const;
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 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) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -25,14 +64,24 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
const res = await fetch("/api/profile", { const res = await fetch("/api/profile", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, 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; const data = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) { setMessage(
setMessage({ type: "error", text: data?.error ?? t("updateFailed") }); res.ok
} else { ? { type: "success", text: t("updateSuccess") }
setMessage({ type: "success", text: t("updateSuccess") }); : { type: "error", text: data?.error ?? t("updateFailed") }
} );
} catch { } catch {
setMessage({ type: "error", text: t("networkError") }); setMessage({ type: "error", text: t("networkError") });
} finally { } 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 ( return (
<div className="rounded-xl border border-gray-100 bg-white p-6"> <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> <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> <p className="mt-1 text-sm text-neutral-500">{t("subtitle")}</p>
{/* Avatar */}
<div className="mt-6 flex items-center gap-4"> <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"> <div className="relative">
{initials} <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>
<div> <div>
<p className="font-medium text-neutral-900">{displayName ?? email.split("@")[0]}</p> <p className="font-medium text-neutral-900">{form.full_name || email.split("@")[0]}</p>
<p className="text-sm text-neutral-500">{email}</p> <p className="text-sm text-neutral-500">{uploading ? t("uploading") : email}</p>
</div> </div>
</div> </div>
<form onSubmit={(e) => void handleSave(e)} className="mt-6 space-y-4"> <form onSubmit={(e) => void handleSave(e)} className="mt-6 space-y-4">
<div> <div className="grid gap-4 sm:grid-cols-2">
<label htmlFor="display-name" className="block text-sm font-medium text-neutral-700"> <div>
{t("displayNameLabel")} <label htmlFor="full_name" className={labelCls}>{t("displayNameLabel")}</label>
</label> <input id="full_name" value={form.full_name} onChange={(e) => set("full_name", e.target.value)} placeholder={t("displayNamePlaceholder")} className={field} />
<input </div>
id="display-name" <div>
type="text" <label htmlFor="slogan" className={labelCls}>{t("sloganLabel")}</label>
value={name} <input id="slogan" value={form.slogan} onChange={(e) => set("slogan", e.target.value)} placeholder={t("sloganPlaceholder")} className={field} />
onChange={(e) => setName(e.target.value)} </div>
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> </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"> <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 /> <User className="h-4 w-4 text-neutral-400" aria-hidden />
<span className="text-sm text-neutral-500">{email}</span> <span className="text-sm text-neutral-500">{email}</span>
@@ -80,18 +178,19 @@ export function SettingsProfile({ email, displayName }: SettingsProfileProps) {
</div> </div>
{message && ( {message && (
<p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}> <p className={`text-sm ${message.type === "success" ? "text-green-600" : "text-red-600"}`}>{message.text}</p>
{message.text}
</p>
)} )}
<button <div className="flex items-center justify-between gap-3">
type="submit" <p className="text-xs text-neutral-400">{t("dataCollectionHint")}</p>
disabled={saving} <button
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" type="submit"
> disabled={saving}
{saving ? t("saving") : t("saveChanges")} 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"
</button> >
{saving ? t("saving") : t("saveChanges")}
</button>
</div>
</form> </form>
</div> </div>
); );
+82 -31
View File
@@ -13,7 +13,10 @@ import {
} from "@/components/layout/NavbarMenuDropdown"; } from "@/components/layout/NavbarMenuDropdown";
import { NavbarMobileMenu } from "@/components/layout/NavbarMobileMenu"; import { NavbarMobileMenu } from "@/components/layout/NavbarMobileMenu";
import { LanguageSwitcher } from "@/components/layout/LanguageSwitcher"; 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 { Button } from "@/components/ui/button";
import type { NavUser } from "@/lib/auth/session";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -22,7 +25,7 @@ import {
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
export function Navbar() { export function Navbar({ user }: { user?: NavUser | null }) {
const t = useTranslations("nav"); const t = useTranslations("nav");
const pathname = usePathname(); const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -112,15 +115,23 @@ export function Navbar() {
{/* Language switcher — desktop */} {/* Language switcher — desktop */}
<LanguageSwitcher className="hidden sm:flex" /> <LanguageSwitcher className="hidden sm:flex" />
<Button variant="ghost" asChild className="hidden sm:inline-flex"> {user ? (
<Link href="/auth">{t("signIn")}</Link> <div className="hidden sm:flex">
</Button> <UserMenu user={user} />
<Button </div>
asChild ) : (
className="hidden bg-blue-600 text-white hover:bg-blue-700 sm:inline-flex" <>
> <Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link> <Link href="/auth">{t("signIn")}</Link>
</Button> </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 */} {/* Mobile menu trigger */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
@@ -141,32 +152,72 @@ export function Navbar() {
<NavbarMobileMenu onNavigate={closeMobile} /> <NavbarMobileMenu onNavigate={closeMobile} />
<div className="mt-auto flex flex-col gap-3 border-t border-gray-100 pb-8 pt-6"> <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" /> <LanguageSwitcher className="w-full justify-center border border-gray-200" />
<Button variant="outline" size="lg" className="w-full" asChild> {user ? (
<Link href="/auth" onClick={closeMobile}> <>
{t("signIn")} <div className="flex items-center gap-3 px-1 py-2">
</Link> <Avatar src={user.avatarUrl} name={user.name} email={user.email} size={40} />
</Button> <div className="min-w-0">
<Button <p className="truncate text-sm font-medium text-neutral-900">{user.name}</p>
size="lg" <p className="truncate text-xs text-neutral-500">{user.email}</p>
className="w-full bg-blue-600 text-white hover:bg-blue-700" </div>
asChild </div>
> <Button variant="outline" size="lg" className="w-full" asChild>
<Link href="/auth?tab=sign-up" onClick={closeMobile}> <Link href="/dashboard" onClick={closeMobile}>{t("menuDashboard")}</Link>
{t("tryForFree")} </Button>
</Link> {user.isAdmin && (
</Button> <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> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Mobile CTA (outside sheet) */} {/* Mobile CTA (outside sheet) */}
<Button {user ? (
asChild <Link
size="sm" href="/dashboard"
className="bg-blue-600 text-white hover:bg-blue-700 lg:hidden" 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")}
<Link href="/auth?tab=sign-up">{t("tryForFree")}</Link> >
</Button> <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>
</div> </div>
</header> </header>
+8 -3
View File
@@ -4,15 +4,20 @@ import { usePathname } from "next/navigation";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import type { NavUser } from "@/lib/auth/session";
interface SiteChromeProps { interface SiteChromeProps {
children: React.ReactNode; children: React.ReactNode;
user?: NavUser | null;
} }
export function SiteChrome({ children }: SiteChromeProps) { export function SiteChrome({ children, user }: SiteChromeProps) {
const pathname = usePathname(); const pathname = usePathname();
// Dashboard, studio and admin all provide their own shell — no public chrome.
const isAppShell = const isAppShell =
pathname.startsWith("/dashboard") || pathname.startsWith("/studio"); pathname.startsWith("/dashboard") ||
pathname.startsWith("/studio") ||
pathname.startsWith("/admin");
if (isAppShell) { if (isAppShell) {
return <>{children}</>; return <>{children}</>;
@@ -20,7 +25,7 @@ export function SiteChrome({ children }: SiteChromeProps) {
return ( return (
<> <>
<Navbar /> <Navbar user={user} />
{children} {children}
<Footer /> <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; if (!res.ok) return null;
return (await res.json().catch(() => null)) as IdentityUser | 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),
};
}