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:
@@ -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:
|
||||
|
||||
+9
-1
@@ -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,",
|
||||
|
||||
+9
-1
@@ -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,",
|
||||
|
||||
+9
-1
@@ -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,",
|
||||
|
||||
+9
-1
@@ -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,",
|
||||
|
||||
+9
-1
@@ -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,",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -43,10 +43,12 @@ const SCOPE_ROOTS: Record<string, string> = {
|
||||
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<string, string[]> = {
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
{ 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) {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user