From 148aefc68f3e865f6547b7924cb29c6250221cd1 Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Tue, 2 Jun 2026 07:17:09 -0500 Subject: [PATCH] feat(team): public Team page + HQ CMS panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Team" section — a LinkedIn-style minimal profile page for the FLUX team, fully editable from the HQ Command Center. Data model: - New TeamMember model (name, role, bio, photoUrl, optional social links: email/linkedin/x/website, order, isActive, translationsJson). - Additive migration 20260602120000_add_team_member (IF NOT EXISTS guards). - Name stays as written; role + bio are translatable via the AI engine. HQ panel (/hq-command/dashboard/team): - Drag-to-reorder (same HTML5 pattern as the Hero panel). - Inline auto-save for name/role/visibility; expandable editor for photo upload, bio, social links, and AI auto-translate to IT/VEC/ES/DE. - Photo upload reuses /api/assets with a new flat "team" scope -> /public/team/. - Dashboard tile added. Public page (/[locale]/team): - Responsive card grid (framer-motion stagger), portrait + name + role + bio + social icons (only the links that exist render). - Per-member Person JSON-LD + breadcrumb for SEO. - Localized via getLocalizedData; new TeamPage namespace in all 5 locales. - NavBar item "Team" inserted before "Spare Parts" (translated 5 locales). - Added to sitemap. Infra: - "team" scope registered in /api/assets (SCOPE_ROOTS + FLAT_SCOPES + buildPublicUrl) and revalidate.ts (RevalidateScope + path). - Nginx serves /team/ from disk; docker-compose mounts public/team in both app and nginx (rw + ro). Verified: production build compiles, all 5 /[locale]/team routes + the HQ panel render; TypeScript clean; 5 message files valid JSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.yml | 2 + messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/it.json | 10 +- messages/vec.json | 10 +- nginx/conf.d/flux.conf | 6 + .../migration.sql | 25 ++ prisma/schema.prisma | 32 ++ public/team/.gitkeep | 0 src/app/[locale]/team/TeamGrid.tsx | 114 ++++++ src/app/[locale]/team/page.tsx | 112 ++++++ src/app/api/assets/route.ts | 5 +- src/app/hq-command/dashboard/page.tsx | 9 + src/app/hq-command/dashboard/team/actions.ts | 142 +++++++ src/app/hq-command/dashboard/team/page.tsx | 365 ++++++++++++++++++ src/app/sitemap.ts | 1 + src/components/layout/NavBar.tsx | 1 + src/lib/revalidate.ts | 4 + 19 files changed, 862 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260602120000_add_team_member/migration.sql create mode 100644 public/team/.gitkeep create mode 100644 src/app/[locale]/team/TeamGrid.tsx create mode 100644 src/app/[locale]/team/page.tsx create mode 100644 src/app/hq-command/dashboard/team/actions.ts create mode 100644 src/app/hq-command/dashboard/team/page.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 7585c4b..5364b5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,7 @@ services: - ./public/parts:/app/public/parts - ./public/operations-inbox:/app/public/operations-inbox - ./public/branding:/app/public/branding + - ./public/team:/app/public/team networks: - flux-net expose: @@ -106,6 +107,7 @@ services: - ./public/footage:/srv/footage:ro - ./public/operations-inbox:/srv/operations-inbox:ro - ./public/branding:/srv/branding:ro + - ./public/team:/srv/team:ro depends_on: - app networks: diff --git a/messages/de.json b/messages/de.json index 773fafc..153d106 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11,7 +11,15 @@ "globalMap": "Weltkarte", "ourStory": "Unsere Geschichte", "parts": "Ersatzteile", - "insideFlux": "Inside Flux" + "insideFlux": "Inside Flux", + "team": "Team" + }, + "TeamPage": { + "eyebrow": "Unser Team", + "title1": "Die Köpfe hinter", + "title2": "der Leistung.", + "description": "Vier Jahrzehnte RF-Ingenieurskompetenz, verkörpert von den Menschen, die jedes FLUX-System entwerfen, bauen und betreuen.", + "empty": "Die Profile unseres Teams sind in Kürze verfügbar." }, "HeroReel": { "title1": "Innovation,", diff --git a/messages/en.json b/messages/en.json index 902c185..51aa833 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11,7 +11,15 @@ "globalMap": "Global Map", "ourStory": "Our Story", "parts": "Spare Parts", - "insideFlux": "Inside Flux" + "insideFlux": "Inside Flux", + "team": "Team" + }, + "TeamPage": { + "eyebrow": "Our Team", + "title1": "The minds behind", + "title2": "the power.", + "description": "Four decades of RF engineering expertise, embodied by the people who design, build and support every FLUX system.", + "empty": "Our team profiles are coming soon." }, "HeroReel": { "title1": "Innovation,", diff --git a/messages/es.json b/messages/es.json index d3819c3..dcea1c4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11,7 +11,15 @@ "globalMap": "Mapa Global", "ourStory": "Nuestra Historia", "parts": "Repuestos", - "insideFlux": "Inside Flux" + "insideFlux": "Inside Flux", + "team": "Equipo" + }, + "TeamPage": { + "eyebrow": "Nuestro Equipo", + "title1": "Las mentes detrás", + "title2": "de la potencia.", + "description": "Cuatro décadas de experiencia en ingeniería de RF, encarnadas por las personas que diseñan, construyen y dan soporte a cada sistema FLUX.", + "empty": "Los perfiles de nuestro equipo estarán disponibles pronto." }, "HeroReel": { "title1": "Innovación,", diff --git a/messages/it.json b/messages/it.json index a89adc1..223a2a2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11,7 +11,15 @@ "globalMap": "Mappa Globale", "ourStory": "La nostra Storia", "parts": "Ricambi", - "insideFlux": "Inside Flux" + "insideFlux": "Inside Flux", + "team": "Team" + }, + "TeamPage": { + "eyebrow": "Il nostro Team", + "title1": "Le menti dietro", + "title2": "la potenza.", + "description": "Quattro decenni di competenza ingegneristica RF, incarnati dalle persone che progettano, costruiscono e supportano ogni sistema FLUX.", + "empty": "I profili del nostro team saranno disponibili a breve." }, "HeroReel": { "title1": "Innovazione,", diff --git a/messages/vec.json b/messages/vec.json index 5e7a693..4577ba2 100644 --- a/messages/vec.json +++ b/messages/vec.json @@ -11,7 +11,15 @@ "globalMap": "Mapa del Mondo", "ourStory": "La Nostra Storia", "parts": "Pessi de Ricambio", - "insideFlux": "Drento FLUX" + "insideFlux": "Drento FLUX", + "team": "Squadra" + }, + "TeamPage": { + "eyebrow": "La nostra Squadra", + "title1": "Le menti drio", + "title2": "ła potensa.", + "description": "Quatro deceni de esperiensa inzegnierìstica RF, incarnài da łe persone che projeta, costruise e suporta ogni sistema FLUX.", + "empty": "I profiłi de ła nostra squadra i rivarà presto." }, "HeroReel": { "title1": "Inovaçion,", diff --git a/nginx/conf.d/flux.conf b/nginx/conf.d/flux.conf index 3d2e041..473fe99 100644 --- a/nginx/conf.d/flux.conf +++ b/nginx/conf.d/flux.conf @@ -190,6 +190,12 @@ server { access_log off; } + location /team/ { + alias /srv/team/; + add_header Cache-Control "public, max-age=300, must-revalidate" always; + access_log off; + } + location / { proxy_pass http://nextjs; proxy_set_header Host $host; diff --git a/prisma/migrations/20260602120000_add_team_member/migration.sql b/prisma/migrations/20260602120000_add_team_member/migration.sql new file mode 100644 index 0000000..4ac81cd --- /dev/null +++ b/prisma/migrations/20260602120000_add_team_member/migration.sql @@ -0,0 +1,25 @@ +-- ───────────────────────────────────────────────────────────────────────── +-- ADDITIVE MIGRATION — adds the TeamMember table for the public team page. +-- Nothing here modifies or drops existing data. Idempotent via IF NOT EXISTS. +-- ───────────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS "TeamMember" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT NOT NULL, + "bio" TEXT, + "photoUrl" TEXT, + "email" TEXT, + "linkedinUrl" TEXT, + "xUrl" TEXT, + "websiteUrl" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "translationsJson" TEXT DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "TeamMember_isActive_order_idx" ON "TeamMember" ("isActive", "order"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8aa2ced..5784d08 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -380,4 +380,36 @@ model ClientUser { lastLoginAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +// ------------------------------------------------------ +// 14. THE TEAM (Equipo de FLUX — página pública + CMS) +// ------------------------------------------------------ +// Minimal LinkedIn-style profiles. Editable in the HQ Command Center with +// drag-to-reorder (same pattern as HeroSlide). Name stays as written; role +// and bio are translatable through the AI translation engine. Social links +// are all optional — only the ones filled in render on the public card. +model TeamMember { + id String @id @default(cuid()) + name String // Proper name — never translated + role String // Job title, e.g. "Founder & CEO" — translatable + bio String? // Short biography (Markdown allowed) — translatable + photoUrl String? // Portrait, served from /team/ bucket + + // Optional social links — render only when present + email String? + linkedinUrl String? + xUrl String? // X / Twitter + websiteUrl String? + + order Int @default(0) // Drag-to-reorder + isActive Boolean @default(true) + + // 🌍 Translation engine — holds localized role + bio per locale + translationsJson String? @default("{}") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive, order]) } \ No newline at end of file diff --git a/public/team/.gitkeep b/public/team/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/[locale]/team/TeamGrid.tsx b/src/app/[locale]/team/TeamGrid.tsx new file mode 100644 index 0000000..96e03c0 --- /dev/null +++ b/src/app/[locale]/team/TeamGrid.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Image from "next/image"; +import { motion } from "framer-motion"; +import { Linkedin, Mail, Globe, Twitter, User } from "lucide-react"; +import { trackEvent } from "@/lib/analytics/gtag"; + +export interface TeamCard { + id: string; + name: string; + role: string; + bio: string | null; + photoUrl: string | null; + email: string | null; + linkedinUrl: string | null; + xUrl: string | null; + websiteUrl: string | null; +} + +export default function TeamGrid({ members }: { members: TeamCard[] }) { + return ( +
+ {members.map((m, i) => ( + + {/* Portrait */} +
+ {m.photoUrl ? ( + {m.name} + ) : ( +
+ +
+ )} + {/* Subtle gradient for text legibility if needed later */} +
+
+ + {/* Body */} +
+

{m.name}

+

+ {m.role} +

+ + {m.bio && ( +

{m.bio}

+ )} + + {/* Social links — only the ones that exist */} +
+ {m.linkedinUrl && ( + + + + )} + {m.xUrl && ( + + + + )} + {m.websiteUrl && ( + + + + )} + {m.email && ( + + + + )} +
+
+ + ))} +
+ ); +} + +function SocialLink({ + href, label, children, name, network, external = true, +}: { + href: string; + label: string; + children: React.ReactNode; + name: string; + network: string; + external?: boolean; +}) { + return ( + trackEvent({ name: "contact_cta_clicked", params: { location: `team:${network}` } })} + className="inline-flex items-center justify-center w-9 h-9 rounded-full border border-black/[0.08] text-[#6E6E73] hover:text-white hover:bg-[#1D1D1F] hover:border-[#1D1D1F] transition-colors" + > + {children} + + ); +} diff --git a/src/app/[locale]/team/page.tsx b/src/app/[locale]/team/page.tsx new file mode 100644 index 0000000..89728ea --- /dev/null +++ b/src/app/[locale]/team/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import { prisma } from "@/lib/prisma"; +import { getLocalizedData } from "@/lib/i18nHelper"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { buildPageMetadata, baseUrl } from "@/lib/seo"; +import JsonLd from "@/components/seo/JsonLd"; +import Breadcrumbs from "@/components/seo/Breadcrumbs"; +import BreathingField from "@/components/visuals/BreathingField"; +import TeamGrid, { type TeamCard } from "./TeamGrid"; + +// ISR: revalidate every 60s, like the other public pages. +export const revalidate = 60; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "TeamPage" }); + return buildPageMetadata({ + locale, + pathWithoutLocale: "team", + title: `${t("eyebrow")} | FLUX`, + description: t("description"), + }); +} + +export default async function TeamPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations({ locale, namespace: "TeamPage" }); + + let members: TeamCard[] = []; + try { + const rows = await prisma.teamMember.findMany({ + where: { isActive: true }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + }); + members = rows.map((row) => { + const localized = getLocalizedData(row, locale); + return { + id: localized.id, + name: localized.name, + role: localized.role, + bio: localized.bio, + photoUrl: localized.photoUrl, + email: localized.email, + linkedinUrl: localized.linkedinUrl, + xUrl: localized.xUrl, + websiteUrl: localized.websiteUrl, + }; + }); + } catch (error) { + console.error("[team] DB error fetching members:", error); + } + + // JSON-LD: a Person entity per member, plus a breadcrumb trail. + const orgUrl = baseUrl(); + const personSchemas = members.map((m) => ({ + "@context": "https://schema.org", + "@type": "Person", + name: m.name, + jobTitle: m.role, + worksFor: { "@type": "Organization", name: "FLUX Srl", url: orgUrl }, + ...(m.photoUrl ? { image: `${orgUrl}${m.photoUrl}` } : {}), + ...(m.linkedinUrl ? { sameAs: [m.linkedinUrl] } : {}), + })); + + const crumbs = [ + { name: "Home", url: `/${locale}` }, + { name: t("eyebrow"), url: `/${locale}/team` }, + ]; + + return ( + <> + {personSchemas.length > 0 && } + +
+ {/* Ambient visual, consistent with the News / Heritage hubs */} +
+ +
+ +
+ + +
+

+ {t("eyebrow")} +

+

+ {t("title1")}{" "} + {t("title2")} +

+

+ {t("description")} +

+
+ + {members.length === 0 ? ( +
+

{t("empty")}

+
+ ) : ( + + )} +
+
+ + ); +} diff --git a/src/app/api/assets/route.ts b/src/app/api/assets/route.ts index a676de1..836dfdf 100644 --- a/src/app/api/assets/route.ts +++ b/src/app/api/assets/route.ts @@ -43,10 +43,12 @@ const SCOPE_ROOTS: Record = { footage: path.join(process.cwd(), "public", "footage", "main"), // 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image) branding: path.join(process.cwd(), "public", "branding"), + // 🔥 NUEVO: Team member portraits (flat folder, slug ignored) + team: path.join(process.cwd(), "public", "team"), }; // Scopes that ignore the `slug` parameter and write directly under their root. -const FLAT_SCOPES = new Set(["footage", "branding"]); +const FLAT_SCOPES = new Set(["footage", "branding", "team"]); const MEDIA_TYPES: Record = { image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], @@ -104,6 +106,7 @@ function buildSafePath(scope: string, slug: string, subPath?: string): string | function buildPublicUrl(scope: string, slug: string, rel: string): string { if (scope === "footage") return `/footage/main/${rel}`; if (scope === "branding") return `/branding/${rel}`; + if (scope === "team") return `/team/${rel}`; return `/${scope}/${slug}/${rel}`; } diff --git a/src/app/hq-command/dashboard/page.tsx b/src/app/hq-command/dashboard/page.tsx index a478a2a..9eac76d 100644 --- a/src/app/hq-command/dashboard/page.tsx +++ b/src/app/hq-command/dashboard/page.tsx @@ -133,6 +133,15 @@ export default async function DashboardPage() { bg: "bg-white/10", border: "hover:border-white/50" }, + { + title: "The Team", + description: "Add team members with photo, bio and social links. Drag to reorder.", + icon: Users, + href: "/hq-command/dashboard/team", + color: "text-sky-400", + bg: "bg-sky-500/10", + border: "hover:border-sky-500/50" + }, { title: "Component Matrix", description: "Manage the spare parts catalog, pricing, and SKUs.", diff --git a/src/app/hq-command/dashboard/team/actions.ts b/src/app/hq-command/dashboard/team/actions.ts new file mode 100644 index 0000000..7c5ac6e --- /dev/null +++ b/src/app/hq-command/dashboard/team/actions.ts @@ -0,0 +1,142 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; +import { translateContentForCMS } from "@/lib/aiTranslator"; +import { log } from "@/lib/logger"; + +export interface TeamMemberInput { + name: string; + role: string; + bio?: string | null; + photoUrl?: string | null; + email?: string | null; + linkedinUrl?: string | null; + xUrl?: string | null; + websiteUrl?: string | null; + autoTranslate?: boolean; +} + +function revalidateTeam() { + revalidatePath("/team"); + revalidatePath("/[locale]/team", "layout"); +} + +export async function getTeamMembers() { + try { + const members = await prisma.teamMember.findMany({ + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + }); + return { success: true, members }; + } catch (error: unknown) { + log.error("team.list_failed", error); + return { error: error instanceof Error ? error.message : "Failed to load team" }; + } +} + +// Translatable fields only — name is a proper noun and never translated. +async function buildTranslations(role: string, bio: string | null | undefined, autoTranslate: boolean) { + const englishFields: Record = { role }; + if (bio) englishFields.bio = bio; + + const merged: Record> = { en: englishFields }; + + if (autoTranslate) { + const aiResult = await translateContentForCMS(englishFields); + if (aiResult) { + for (const [locale, fields] of Object.entries(aiResult)) { + merged[locale] = { ...merged[locale], ...(fields as Record) }; + } + } + } + return JSON.stringify(merged); +} + +export async function createTeamMember(input: TeamMemberInput) { + try { + const last = await prisma.teamMember.findFirst({ + orderBy: { order: "desc" }, + select: { order: true }, + }); + const nextOrder = last ? last.order + 1 : 0; + + const translationsJson = await buildTranslations(input.role, input.bio, !!input.autoTranslate); + + const member = await prisma.teamMember.create({ + data: { + name: input.name, + role: input.role, + bio: input.bio || null, + photoUrl: input.photoUrl || null, + email: input.email || null, + linkedinUrl: input.linkedinUrl || null, + xUrl: input.xUrl || null, + websiteUrl: input.websiteUrl || null, + order: nextOrder, + translationsJson, + }, + }); + + revalidateTeam(); + return { success: true, member }; + } catch (error: unknown) { + log.error("team.create_failed", error); + return { error: error instanceof Error ? error.message : "Failed to create member" }; + } +} + +export async function updateTeamMember(id: string, input: Partial & { isActive?: boolean }) { + try { + const data: Record = {}; + if (input.name !== undefined) data.name = input.name; + if (input.role !== undefined) data.role = input.role; + if (input.bio !== undefined) data.bio = input.bio || null; + if (input.photoUrl !== undefined) data.photoUrl = input.photoUrl || null; + if (input.email !== undefined) data.email = input.email || null; + if (input.linkedinUrl !== undefined) data.linkedinUrl = input.linkedinUrl || null; + if (input.xUrl !== undefined) data.xUrl = input.xUrl || null; + if (input.websiteUrl !== undefined) data.websiteUrl = input.websiteUrl || null; + if (input.isActive !== undefined) data.isActive = input.isActive; + + // Rebuild translations when role or bio changed (or a translate was requested). + if (input.role !== undefined || input.bio !== undefined || input.autoTranslate) { + const existing = await prisma.teamMember.findUnique({ where: { id } }); + const role = input.role ?? existing?.role ?? ""; + const bio = input.bio ?? existing?.bio ?? null; + data.translationsJson = await buildTranslations(role, bio, !!input.autoTranslate); + } + + const member = await prisma.teamMember.update({ where: { id }, data }); + revalidateTeam(); + return { success: true, member }; + } catch (error: unknown) { + log.error("team.update_failed", error, { id }); + return { error: error instanceof Error ? error.message : "Failed to update member" }; + } +} + +export async function deleteTeamMember(id: string) { + try { + await prisma.teamMember.delete({ where: { id } }); + revalidateTeam(); + return { success: true }; + } catch (error: unknown) { + log.error("team.delete_failed", error, { id }); + return { error: error instanceof Error ? error.message : "Failed to delete member" }; + } +} + +export async function reorderTeamMembers(orderedIds: string[]) { + try { + await prisma.$transaction( + orderedIds.map((id, idx) => + prisma.teamMember.update({ where: { id }, data: { order: idx } }), + ), + ); + revalidateTeam(); + return { success: true }; + } catch (error: unknown) { + log.error("team.reorder_failed", error); + return { error: error instanceof Error ? error.message : "Failed to reorder" }; + } +} diff --git a/src/app/hq-command/dashboard/team/page.tsx b/src/app/hq-command/dashboard/team/page.tsx new file mode 100644 index 0000000..0a18bbf --- /dev/null +++ b/src/app/hq-command/dashboard/team/page.tsx @@ -0,0 +1,365 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import Link from "next/link"; +import { + ArrowLeft, Users, Plus, Trash2, Loader2, GripVertical, Eye, EyeOff, + Sparkles, Upload, Check, Linkedin, Mail, Globe, Twitter, ChevronDown, +} from "lucide-react"; +import { + getTeamMembers, createTeamMember, updateTeamMember, deleteTeamMember, + reorderTeamMembers, +} from "./actions"; +import { useHqUi } from "@/components/hq/Toast"; + +interface MemberRow { + id: string; + name: string; + role: string; + bio: string | null; + photoUrl: string | null; + email: string | null; + linkedinUrl: string | null; + xUrl: string | null; + websiteUrl: string | null; + isActive: boolean; + order: number; + translationsJson: string | null; +} + +export default function TeamDashboard() { + const ui = useHqUi(); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [savingId, setSavingId] = useState(null); + const [savedFlash, setSavedFlash] = useState(null); + const [draggedId, setDraggedId] = useState(null); + const [creating, setCreating] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + const res = await getTeamMembers(); + if (res.success && res.members) setMembers(res.members as MemberRow[]); + setLoading(false); + }, []); + + useEffect(() => { load(); }, [load]); + + const flashSaved = (id: string) => { + setSavedFlash(id); + setTimeout(() => setSavedFlash(null), 1500); + }; + + const handleAdd = async () => { + setCreating(true); + const res = await createTeamMember({ name: "New member", role: "Role / Title" }); + setCreating(false); + if (res.success && res.member) { + await load(); + setExpandedId(res.member.id); + } else { + ui.toast(res.error || "Could not create member", "error"); + } + }; + + // Inline patch with optimistic update + auto-save (name/role/isActive). + const patch = async (id: string, p: Partial) => { + setMembers((prev) => prev.map((m) => (m.id === id ? { ...m, ...p } : m))); + setSavingId(id); + const res = await updateTeamMember(id, p as never); + setSavingId(null); + if (res.success) flashSaved(id); + else ui.toast(res.error || "Save failed", "error"); + }; + + const handleDelete = async (id: string, name: string) => { + const ok = await ui.confirm({ + title: "Remove team member", + message: `Remove ${name} from the public team page. This cannot be undone.`, + confirmLabel: "Remove", + destructive: true, + }); + if (!ok) return; + await deleteTeamMember(id); + ui.toast("Member removed.", "success"); + await load(); + }; + + // Drag reorder — same pattern as the Hero panel. + const onDrop = async (targetId: string) => { + if (!draggedId || draggedId === targetId) return; + const ids = members.map((m) => m.id); + const from = ids.indexOf(draggedId); + const to = ids.indexOf(targetId); + if (from < 0 || to < 0) return; + const reordered = [...ids]; + reordered.splice(from, 1); + reordered.splice(to, 0, draggedId); + setMembers((prev) => reordered.map((id, i) => ({ ...prev.find((m) => m.id === id)!, order: i }))); + setDraggedId(null); + await reorderTeamMembers(reordered); + }; + + return ( +
+ + Back to Dashboard + + +
+
+
+ + The Team +
+

+ Team Members. +

+

+ Drag to reorder. Click a card to edit photo, bio and social links. Name & role auto-save. +

+
+ +
+ + {loading ? ( +
+ Loading team… +
+ ) : members.length === 0 ? ( +
+ +

No team members yet.

+

Click “Add member” to build the public team page.

+
+ ) : ( +
+ {members.map((m) => { + const isExpanded = expandedId === m.id; + const isSaving = savingId === m.id; + const justSaved = savedFlash === m.id; + return ( +
setDraggedId(m.id)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => onDrop(m.id)} + className={`bg-[#111] border rounded-2xl overflow-hidden transition-all ${ + draggedId === m.id ? "opacity-50" : "" + } ${m.isActive ? "border-white/10" : "border-white/5 opacity-60"}`} + > +
+ + +
+ {m.photoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {m.name} + ) : ( +
+ +
+ )} +
+ +
+ setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, name: e.target.value } : x)))} + onBlur={(e) => patch(m.id, { name: e.target.value })} + placeholder="Full name" + className="w-full bg-transparent border-0 outline-none text-white text-sm font-medium placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-1 -mx-2" + /> + setMembers((prev) => prev.map((x) => (x.id === m.id ? { ...x, role: e.target.value } : x)))} + onBlur={(e) => patch(m.id, { role: e.target.value })} + placeholder="Role / title" + className="w-full bg-transparent border-0 outline-none text-[#00F0FF] text-xs placeholder:text-[#86868B] focus:bg-white/[0.04] rounded px-2 py-0.5 -mx-2 mt-0.5" + /> +
+ +
+ {isSaving && } + {justSaved && Saved} +
+ + + + +
+ + {isExpanded && ( + { await load(); }} + /> + )} +
+ ); + })} +
+ )} +
+ ); +} + +// ─── Expanded editor: photo upload + bio + social links + AI translate ─────── +function MemberEditor({ member, onSaved }: { member: MemberRow; onSaved: () => Promise }) { + const ui = useHqUi(); + const [photoUrl, setPhotoUrl] = useState(member.photoUrl || ""); + const [bio, setBio] = useState(member.bio || ""); + const [email, setEmail] = useState(member.email || ""); + const [linkedinUrl, setLinkedinUrl] = useState(member.linkedinUrl || ""); + const [xUrl, setXUrl] = useState(member.xUrl || ""); + const [websiteUrl, setWebsiteUrl] = useState(member.websiteUrl || ""); + const [autoTranslate, setAutoTranslate] = useState(false); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const uploadPhoto = async (file: File) => { + setUploading(true); + try { + const fd = new FormData(); + fd.append("scope", "team"); + fd.append("optimize", "1"); + fd.append("file", file); + const res = await fetch("/api/assets", { method: "POST", body: fd }); + const data = await res.json(); + if (data.success) setPhotoUrl(data.file.publicUrl); + else ui.toast(data.error || "Upload failed", "error"); + } catch (err: unknown) { + ui.toast(err instanceof Error ? err.message : "Upload failed", "error"); + } + setUploading(false); + }; + + const save = async () => { + setSaving(true); + const res = await updateTeamMember(member.id, { + bio, photoUrl, email, linkedinUrl, xUrl, websiteUrl, autoTranslate, + }); + setSaving(false); + if (res.success) { ui.toast("Saved.", "success"); await onSaved(); } + else ui.toast(res.error || "Save failed", "error"); + }; + + return ( +
+ {/* Photo */} +
+
+ {photoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} +
+
+ { const f = e.target.files?.[0]; if (f) uploadPhoto(f); e.target.value = ""; }} + /> + +

Square portrait recommended, min 400×400.

+
+
+ + {/* Bio */} +
+ +