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 ? (
+
+ ) : (
+
+
+
+ )}
+ {/* 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 ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
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
+

+ ) : (
+
+
+
+ )}
+
+
+
+ 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 */}
+
+
+
+
+ {/* Social links */}
+
+ } value={email} onChange={setEmail} placeholder="email@fluxsrl.com" />
+ } value={linkedinUrl} onChange={setLinkedinUrl} placeholder="https://linkedin.com/in/…" />
+ } value={xUrl} onChange={setXUrl} placeholder="https://x.com/…" />
+ } value={websiteUrl} onChange={setWebsiteUrl} placeholder="https://…" />
+
+
+
+
+
+
+ );
+}
+
+function SocialInput({ icon, value, onChange, placeholder }: {
+ icon: React.ReactNode; value: string; onChange: (v: string) => void; placeholder: string;
+}) {
+ return (
+
+ {icon}
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ className="flex-1 bg-transparent border-0 outline-none text-white text-xs placeholder:text-[#86868B]/60"
+ />
+
+ );
+}
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index 9f77ef3..bfdb4c9 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -29,6 +29,7 @@ export default async function sitemap(): Promise {
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
+ { path: "/team", priority: 0.6, changeFrequency: "monthly" as const },
];
for (const locale of LOCALES) {
diff --git a/src/components/layout/NavBar.tsx b/src/components/layout/NavBar.tsx
index 4c50980..c387453 100644
--- a/src/components/layout/NavBar.tsx
+++ b/src/components/layout/NavBar.tsx
@@ -13,6 +13,7 @@ const NAV_KEYS = [
{ key: "globalMap", href: "/#global" },
{ key: "ourStory", href: "/#our-story" },
{ key: "insideFlux", href: "/news" },
+ { key: "team", href: "/team" },
{ key: "parts", href: "/parts" },
];
diff --git a/src/lib/revalidate.ts b/src/lib/revalidate.ts
index 5d793af..0402fcb 100644
--- a/src/lib/revalidate.ts
+++ b/src/lib/revalidate.ts
@@ -21,6 +21,7 @@ export type RevalidateScope =
| "hero"
| "timeline"
| "settings"
+ | "team"
| "all";
export interface RevalidateOptions {
@@ -54,6 +55,9 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
case "parts":
safeRevalidate(`/${locale}/parts`);
break;
+ case "team":
+ safeRevalidate(`/${locale}/team`);
+ break;
case "heritage":
safeRevalidate(`/${locale}/heritage`);
break;