feat(team): public Team page + HQ CMS panel

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 07:17:09 -05:00
parent 1ee8288c7e
commit 148aefc68f
19 changed files with 862 additions and 6 deletions
+2
View File
@@ -74,6 +74,7 @@ services:
- ./public/parts:/app/public/parts - ./public/parts:/app/public/parts
- ./public/operations-inbox:/app/public/operations-inbox - ./public/operations-inbox:/app/public/operations-inbox
- ./public/branding:/app/public/branding - ./public/branding:/app/public/branding
- ./public/team:/app/public/team
networks: networks:
- flux-net - flux-net
expose: expose:
@@ -106,6 +107,7 @@ services:
- ./public/footage:/srv/footage:ro - ./public/footage:/srv/footage:ro
- ./public/operations-inbox:/srv/operations-inbox:ro - ./public/operations-inbox:/srv/operations-inbox:ro
- ./public/branding:/srv/branding:ro - ./public/branding:/srv/branding:ro
- ./public/team:/srv/team:ro
depends_on: depends_on:
- app - app
networks: networks:
+9 -1
View File
@@ -11,7 +11,15 @@
"globalMap": "Weltkarte", "globalMap": "Weltkarte",
"ourStory": "Unsere Geschichte", "ourStory": "Unsere Geschichte",
"parts": "Ersatzteile", "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": { "HeroReel": {
"title1": "Innovation,", "title1": "Innovation,",
+9 -1
View File
@@ -11,7 +11,15 @@
"globalMap": "Global Map", "globalMap": "Global Map",
"ourStory": "Our Story", "ourStory": "Our Story",
"parts": "Spare Parts", "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": { "HeroReel": {
"title1": "Innovation,", "title1": "Innovation,",
+9 -1
View File
@@ -11,7 +11,15 @@
"globalMap": "Mapa Global", "globalMap": "Mapa Global",
"ourStory": "Nuestra Historia", "ourStory": "Nuestra Historia",
"parts": "Repuestos", "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": { "HeroReel": {
"title1": "Innovación,", "title1": "Innovación,",
+9 -1
View File
@@ -11,7 +11,15 @@
"globalMap": "Mappa Globale", "globalMap": "Mappa Globale",
"ourStory": "La nostra Storia", "ourStory": "La nostra Storia",
"parts": "Ricambi", "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": { "HeroReel": {
"title1": "Innovazione,", "title1": "Innovazione,",
+9 -1
View File
@@ -11,7 +11,15 @@
"globalMap": "Mapa del Mondo", "globalMap": "Mapa del Mondo",
"ourStory": "La Nostra Storia", "ourStory": "La Nostra Storia",
"parts": "Pessi de Ricambio", "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": { "HeroReel": {
"title1": "Inovaçion,", "title1": "Inovaçion,",
+6
View File
@@ -190,6 +190,12 @@ server {
access_log off; access_log off;
} }
location /team/ {
alias /srv/team/;
add_header Cache-Control "public, max-age=300, must-revalidate" always;
access_log off;
}
location / { location / {
proxy_pass http://nextjs; proxy_pass http://nextjs;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -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");
+32
View File
@@ -381,3 +381,35 @@ model ClientUser {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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])
}
View File
+114
View File
@@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{members.map((m, i) => (
<motion.article
key={m.id}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, delay: Math.min(i * 0.06, 0.4), ease: [0.16, 1, 0.3, 1] }}
className="group relative flex flex-col rounded-3xl bg-white border border-black/[0.06] shadow-[0_2px_20px_rgba(0,0,0,0.04)] hover:shadow-[0_12px_40px_rgba(0,0,0,0.10)] transition-all duration-500 overflow-hidden"
>
{/* Portrait */}
<div className="relative aspect-[4/5] w-full overflow-hidden bg-gradient-to-br from-[#EEF2F5] to-[#E3E9ED]">
{m.photoUrl ? (
<Image
src={m.photoUrl}
alt={m.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-[#B0B8BF]">
<User size={64} strokeWidth={1} />
</div>
)}
{/* Subtle gradient for text legibility if needed later */}
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Body */}
<div className="flex flex-col flex-1 p-6">
<h3 className="text-lg font-semibold text-[#1D1D1F] tracking-tight">{m.name}</h3>
<p className="text-[#0066CC] text-xs font-medium uppercase tracking-[0.12em] mt-1">
{m.role}
</p>
{m.bio && (
<p className="mt-4 text-sm leading-relaxed text-[#6E6E73] line-clamp-5">{m.bio}</p>
)}
{/* Social links — only the ones that exist */}
<div className="mt-auto pt-5 flex items-center gap-2">
{m.linkedinUrl && (
<SocialLink href={m.linkedinUrl} label={`${m.name} on LinkedIn`} name={m.name} network="linkedin">
<Linkedin size={16} />
</SocialLink>
)}
{m.xUrl && (
<SocialLink href={m.xUrl} label={`${m.name} on X`} name={m.name} network="x">
<Twitter size={16} />
</SocialLink>
)}
{m.websiteUrl && (
<SocialLink href={m.websiteUrl} label={`${m.name} website`} name={m.name} network="web">
<Globe size={16} />
</SocialLink>
)}
{m.email && (
<SocialLink href={`mailto:${m.email}`} label={`Email ${m.name}`} name={m.name} network="email" external={false}>
<Mail size={16} />
</SocialLink>
)}
</div>
</div>
</motion.article>
))}
</div>
);
}
function SocialLink({
href, label, children, name, network, external = true,
}: {
href: string;
label: string;
children: React.ReactNode;
name: string;
network: string;
external?: boolean;
}) {
return (
<a
href={href}
aria-label={label}
title={label}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
onClick={() => 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}
</a>
);
}
+112
View File
@@ -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<Metadata> {
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 && <JsonLd data={personSchemas} />}
<main className="relative w-full min-h-screen bg-[#F5F5F7] overflow-hidden">
{/* Ambient visual, consistent with the News / Heritage hubs */}
<div className="absolute inset-0 opacity-60 pointer-events-none">
<BreathingField />
</div>
<div className="relative z-10 max-w-7xl mx-auto px-6 pt-28 md:pt-36 pb-24">
<Breadcrumbs items={crumbs} />
<header className="max-w-3xl mt-6 mb-16 md:mb-24">
<p className="text-[#0066CC] text-xs md:text-sm font-semibold uppercase tracking-[0.2em] mb-4">
{t("eyebrow")}
</p>
<h1 className="text-4xl md:text-6xl font-light text-[#1D1D1F] tracking-tight leading-[1.05]">
{t("title1")}{" "}
<span className="font-medium">{t("title2")}</span>
</h1>
<p className="mt-6 text-base md:text-lg text-[#6E6E73] leading-relaxed max-w-2xl">
{t("description")}
</p>
</header>
{members.length === 0 ? (
<div className="text-center py-24 text-[#86868B]">
<p>{t("empty")}</p>
</div>
) : (
<TeamGrid members={members} />
)}
</div>
</main>
</>
);
}
+4 -1
View File
@@ -43,10 +43,12 @@ const SCOPE_ROOTS: Record<string, string> = {
footage: path.join(process.cwd(), "public", "footage", "main"), footage: path.join(process.cwd(), "public", "footage", "main"),
// 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image) // 🔥 NUEVO: Site-wide brand assets (favicon, logo, OG image)
branding: path.join(process.cwd(), "public", "branding"), 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. // 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<string, string[]> = { const MEDIA_TYPES: Record<string, string[]> = {
image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"], 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 { function buildPublicUrl(scope: string, slug: string, rel: string): string {
if (scope === "footage") return `/footage/main/${rel}`; if (scope === "footage") return `/footage/main/${rel}`;
if (scope === "branding") return `/branding/${rel}`; if (scope === "branding") return `/branding/${rel}`;
if (scope === "team") return `/team/${rel}`;
return `/${scope}/${slug}/${rel}`; return `/${scope}/${slug}/${rel}`;
} }
+9
View File
@@ -133,6 +133,15 @@ export default async function DashboardPage() {
bg: "bg-white/10", bg: "bg-white/10",
border: "hover:border-white/50" 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", title: "Component Matrix",
description: "Manage the spare parts catalog, pricing, and SKUs.", description: "Manage the spare parts catalog, pricing, and SKUs.",
@@ -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<string, string> = { role };
if (bio) englishFields.bio = bio;
const merged: Record<string, Record<string, string>> = { 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<string, string>) };
}
}
}
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<TeamMemberInput> & { isActive?: boolean }) {
try {
const data: Record<string, unknown> = {};
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" };
}
}
+365
View File
@@ -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<MemberRow[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [savingId, setSavingId] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState<string | null>(null);
const [draggedId, setDraggedId] = useState<string | null>(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<MemberRow>) => {
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 (
<div className="min-h-screen p-6 md:p-12 max-w-5xl mx-auto">
<Link
href="/hq-command/dashboard"
className="inline-flex items-center gap-2 text-sm text-[#86868B] hover:text-white mb-6 transition-colors"
>
<ArrowLeft size={14} /> Back to Dashboard
</Link>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
<Users size={16} />
<span className="text-[10px] uppercase tracking-widest font-bold">The Team</span>
</div>
<h1 className="text-3xl md:text-4xl font-light text-white">
Team <span className="font-medium">Members.</span>
</h1>
<p className="text-[#86868B] mt-2 text-sm">
Drag to reorder. Click a card to edit photo, bio and social links. Name &amp; role auto-save.
</p>
</div>
<button
onClick={handleAdd}
disabled={creating}
className="inline-flex items-center gap-2 bg-[#00F0FF] text-black px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 shrink-0"
>
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={16} />}
Add member
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-[#86868B]">
<Loader2 className="animate-spin mr-2" size={16} /> Loading team
</div>
) : members.length === 0 ? (
<div className="text-center py-20 text-[#86868B] bg-white/[0.02] rounded-3xl border border-white/5">
<Users size={32} className="mx-auto mb-3 opacity-40" />
<p>No team members yet.</p>
<p className="text-xs mt-1">Click Add member to build the public team page.</p>
</div>
) : (
<div className="space-y-3">
{members.map((m) => {
const isExpanded = expandedId === m.id;
const isSaving = savingId === m.id;
const justSaved = savedFlash === m.id;
return (
<div
key={m.id}
draggable
onDragStart={() => 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"}`}
>
<div className="flex items-center gap-3 p-3">
<button className="cursor-grab text-[#86868B] hover:text-white p-1" title="Drag to reorder">
<GripVertical size={16} />
</button>
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-black/40 flex-shrink-0 border border-white/10">
{m.photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={m.photoUrl} alt={m.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[#86868B]">
<Users size={18} />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
value={m.name}
onChange={(e) => 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"
/>
<input
value={m.role}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-1 text-xs">
{isSaving && <Loader2 size={12} className="animate-spin text-[#00F0FF]" />}
{justSaved && <span className="text-emerald-400 flex items-center gap-1"><Check size={12} /> Saved</span>}
</div>
<button
onClick={() => patch(m.id, { isActive: !m.isActive })}
className={`p-2 rounded-lg transition-colors ${m.isActive ? "text-emerald-400 hover:bg-emerald-500/10" : "text-[#86868B] hover:bg-white/5"}`}
title={m.isActive ? "Hide from team page" : "Show on team page"}
>
{m.isActive ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
onClick={() => setExpandedId(isExpanded ? null : m.id)}
className="p-2 rounded-lg text-[#86868B] hover:bg-white/5 hover:text-white"
title="Edit details"
>
<ChevronDown size={16} className={`transition-transform ${isExpanded ? "rotate-180 text-[#00F0FF]" : ""}`} />
</button>
<button
onClick={() => handleDelete(m.id, m.name)}
className="p-2 rounded-lg text-rose-400 hover:bg-rose-500/10"
title="Remove member"
>
<Trash2 size={16} />
</button>
</div>
{isExpanded && (
<MemberEditor
member={m}
onSaved={async () => { await load(); }}
/>
)}
</div>
);
})}
</div>
)}
</div>
);
}
// ─── Expanded editor: photo upload + bio + social links + AI translate ───────
function MemberEditor({ member, onSaved }: { member: MemberRow; onSaved: () => Promise<void> }) {
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<HTMLInputElement>(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 (
<div className="border-t border-white/5 bg-white/[0.02] p-5 space-y-4">
{/* Photo */}
<div className="flex items-center gap-4">
<div className="relative w-20 h-20 rounded-full overflow-hidden bg-black/40 border border-white/10 flex-shrink-0">
{photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={photoUrl} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[#86868B]"><Users size={20} /></div>
)}
</div>
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadPhoto(f); e.target.value = ""; }}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="inline-flex items-center gap-2 bg-white/5 hover:bg-white/10 text-white text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
>
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
{photoUrl ? "Replace photo" : "Upload photo"}
</button>
<p className="text-[10px] text-[#86868B] mt-1.5">Square portrait recommended, min 400×400.</p>
</div>
</div>
{/* Bio */}
<div>
<label className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold">Bio (English master)</label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
placeholder="Short biography. Markdown supported."
className="mt-1.5 w-full bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 resize-y"
/>
</div>
{/* Social links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<SocialInput icon={<Mail size={13} />} value={email} onChange={setEmail} placeholder="email@fluxsrl.com" />
<SocialInput icon={<Linkedin size={13} />} value={linkedinUrl} onChange={setLinkedinUrl} placeholder="https://linkedin.com/in/…" />
<SocialInput icon={<Twitter size={13} />} value={xUrl} onChange={setXUrl} placeholder="https://x.com/…" />
<SocialInput icon={<Globe size={13} />} value={websiteUrl} onChange={setWebsiteUrl} placeholder="https://…" />
</div>
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
<input type="checkbox" checked={autoTranslate} onChange={(e) => setAutoTranslate(e.target.checked)} className="accent-[#00F0FF]" />
<Sparkles size={12} className="text-[#00F0FF]" /> Auto-translate role &amp; bio to IT, VEC, ES, DE with AI
</label>
<button
onClick={save}
disabled={saving}
className="px-4 py-2 bg-[#00F0FF] text-black text-sm font-medium rounded-lg hover:bg-[#00F0FF]/80 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Save details
</button>
</div>
);
}
function SocialInput({ icon, value, onChange, placeholder }: {
icon: React.ReactNode; value: string; onChange: (v: string) => void; placeholder: string;
}) {
return (
<div className="flex items-center gap-2 bg-black/40 border border-white/10 rounded-lg px-3 py-2 focus-within:border-[#00F0FF]/40">
<span className="text-[#86868B]">{icon}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1 bg-transparent border-0 outline-none text-white text-xs placeholder:text-[#86868B]/60"
/>
</div>
);
}
+1
View File
@@ -29,6 +29,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
{ path: "", priority: 1.0, changeFrequency: "weekly" as const }, { path: "", priority: 1.0, changeFrequency: "weekly" as const },
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const }, { path: "/news", priority: 0.7, changeFrequency: "daily" as const },
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" 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) { for (const locale of LOCALES) {
+1
View File
@@ -13,6 +13,7 @@ const NAV_KEYS = [
{ key: "globalMap", href: "/#global" }, { key: "globalMap", href: "/#global" },
{ key: "ourStory", href: "/#our-story" }, { key: "ourStory", href: "/#our-story" },
{ key: "insideFlux", href: "/news" }, { key: "insideFlux", href: "/news" },
{ key: "team", href: "/team" },
{ key: "parts", href: "/parts" }, { key: "parts", href: "/parts" },
]; ];
+4
View File
@@ -21,6 +21,7 @@ export type RevalidateScope =
| "hero" | "hero"
| "timeline" | "timeline"
| "settings" | "settings"
| "team"
| "all"; | "all";
export interface RevalidateOptions { export interface RevalidateOptions {
@@ -54,6 +55,9 @@ export function revalidateContent({ scope, slug }: RevalidateOptions) {
case "parts": case "parts":
safeRevalidate(`/${locale}/parts`); safeRevalidate(`/${locale}/parts`);
break; break;
case "team":
safeRevalidate(`/${locale}/team`);
break;
case "heritage": case "heritage":
safeRevalidate(`/${locale}/heritage`); safeRevalidate(`/${locale}/heritage`);
break; break;