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
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:
+30
-2
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user