b9a744bdbc
Adds a full settings dashboard at /hq-command/dashboard/settings so the
client can update favicon, logos, footer text, social links and OG image
without code changes — wired into the SiteSetting model from the previous
commit.
NEW
- src/lib/siteSettingsTypes.ts: pure types + defaults (client-safe import)
- src/lib/siteSettings.ts: server-only loader using the SiteSetting model
- /api/assets gains a "branding" flat scope that writes to /public/branding
- /hq-command/dashboard/settings/{actions.ts, page.tsx} with three tabs:
1. Branding — favicon, Apple touch icon, main logo, email logo, OG
image, theme color. Each field has helper text with recommended
size/format and live preview.
2. Footer — CTA banner, HQ address, legal links. Optional one-click
AI translation to IT, VEC, ES, DE.
3. Social — LinkedIn, Instagram, YouTube, contact email.
WIRED INTO LAYOUT
- src/app/[locale]/layout.tsx now uses generateMetadata + generateViewport
to pull favicon, OG image and theme color dynamically. Adds Twitter
Card metadata. Falls back to the default flux-logo when SiteSetting
table is empty.
- src/components/layout/Footer.tsx reads CTA/HQ/legal copy from DB,
supports per-locale overrides via translationsJson, and renders social
icons (LinkedIn / Instagram / YouTube / Mail) only for filled fields.
UX FOR THE EDITOR (David's "12-year-old test")
- Drop-zone uploaders next to URL inputs — paste-or-upload either way
- Live image previews next to every branding field
- "Saved — live in 60 seconds" inline confirmation, no extra modals
- Recommended sizes spelled out next to each field (e.g. "PNG, square,
minimum 512×512" for favicon)
- Tooltip explaining why each image is needed
NO SCHEMA CHANGES — uses the SiteSetting table created in the previous
commit. Existing rows untouched.
482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
"use client";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
import { useState, useEffect, useRef, useCallback } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
ArrowLeft, Settings as SettingsIcon, Loader2, Check, Upload,
|
||
Image as ImageIcon, Type, Share2, Sparkles, Info,
|
||
} from "lucide-react";
|
||
import {
|
||
getAllSettingsForEditor,
|
||
saveBranding,
|
||
saveFooter,
|
||
saveSocial,
|
||
} from "./actions";
|
||
import {
|
||
DEFAULT_BRANDING,
|
||
DEFAULT_FOOTER,
|
||
DEFAULT_SOCIAL,
|
||
type BrandingSettings,
|
||
type FooterSettings,
|
||
type SocialSettings,
|
||
} from "@/lib/siteSettingsTypes";
|
||
|
||
type Tab = "branding" | "footer" | "social";
|
||
|
||
export default function SiteSettingsPage() {
|
||
const [tab, setTab] = useState<Tab>("branding");
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [savedFlash, setSavedFlash] = useState<string | null>(null);
|
||
|
||
const [branding, setBranding] = useState<BrandingSettings>(DEFAULT_BRANDING);
|
||
const [footer, setFooter] = useState<FooterSettings>(DEFAULT_FOOTER);
|
||
const [social, setSocial] = useState<SocialSettings>(DEFAULT_SOCIAL);
|
||
const [autoTranslateFooter, setAutoTranslateFooter] = useState(false);
|
||
|
||
const flash = (id: string) => {
|
||
setSavedFlash(id);
|
||
setTimeout(() => setSavedFlash(null), 1500);
|
||
};
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
const res = await getAllSettingsForEditor();
|
||
if (res.success) {
|
||
if (res.branding) setBranding(res.branding);
|
||
if (res.footer) setFooter(res.footer);
|
||
if (res.social) setSocial(res.social);
|
||
}
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const onSaveBranding = async () => {
|
||
setSaving(true);
|
||
await saveBranding(branding);
|
||
setSaving(false);
|
||
flash("branding");
|
||
};
|
||
|
||
const onSaveFooter = async () => {
|
||
setSaving(true);
|
||
await saveFooter(footer, autoTranslateFooter);
|
||
setSaving(false);
|
||
flash("footer");
|
||
};
|
||
|
||
const onSaveSocial = async () => {
|
||
setSaving(true);
|
||
await saveSocial(social);
|
||
setSaving(false);
|
||
flash("social");
|
||
};
|
||
|
||
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="mb-8">
|
||
<div className="flex items-center gap-2 text-[#00F0FF] mb-2">
|
||
<SettingsIcon size={16} />
|
||
<span className="text-[10px] uppercase tracking-widest font-bold">Site Settings</span>
|
||
</div>
|
||
<h1 className="text-3xl md:text-4xl font-light text-white">
|
||
Branding & <span className="font-medium">Global Content.</span>
|
||
</h1>
|
||
<p className="text-[#86868B] mt-2 text-sm">
|
||
Favicon, logo, footer, social links — applies site-wide across all locales.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-1 mb-8 border-b border-white/10">
|
||
{[
|
||
{ id: "branding", label: "Branding", icon: ImageIcon },
|
||
{ id: "footer", label: "Footer", icon: Type },
|
||
{ id: "social", label: "Social", icon: Share2 },
|
||
].map((t) => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => setTab(t.id as Tab)}
|
||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-all ${
|
||
tab === t.id
|
||
? "border-[#00F0FF] text-[#00F0FF]"
|
||
: "border-transparent text-[#86868B] hover:text-white"
|
||
}`}
|
||
>
|
||
<t.icon size={14} /> {t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-20 text-[#86868B]">
|
||
<Loader2 className="animate-spin mr-2" size={16} /> Loading settings…
|
||
</div>
|
||
) : tab === "branding" ? (
|
||
<BrandingTab
|
||
value={branding}
|
||
onChange={setBranding}
|
||
onSave={onSaveBranding}
|
||
saving={saving}
|
||
justSaved={savedFlash === "branding"}
|
||
/>
|
||
) : tab === "footer" ? (
|
||
<FooterTab
|
||
value={footer}
|
||
onChange={setFooter}
|
||
autoTranslate={autoTranslateFooter}
|
||
onAutoTranslateChange={setAutoTranslateFooter}
|
||
onSave={onSaveFooter}
|
||
saving={saving}
|
||
justSaved={savedFlash === "footer"}
|
||
/>
|
||
) : (
|
||
<SocialTab
|
||
value={social}
|
||
onChange={setSocial}
|
||
onSave={onSaveSocial}
|
||
saving={saving}
|
||
justSaved={savedFlash === "social"}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Branding tab ────────────────────────────────────────────────
|
||
function BrandingTab({
|
||
value, onChange, onSave, saving, justSaved,
|
||
}: {
|
||
value: BrandingSettings;
|
||
onChange: (v: BrandingSettings) => void;
|
||
onSave: () => void;
|
||
saving: boolean;
|
||
justSaved: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-8">
|
||
<Tip>
|
||
Upload images to set the site favicon and logo. Recommended sizes are shown next to each
|
||
field. Changes appear on the live site within 60 seconds, no rebuild needed.
|
||
</Tip>
|
||
|
||
<ImageField
|
||
label="Favicon"
|
||
helper="PNG, square, transparent background. Minimum 512×512. Auto-resized for tabs and bookmarks."
|
||
value={value.faviconUrl}
|
||
onChange={(url) => onChange({ ...value, faviconUrl: url })}
|
||
/>
|
||
|
||
<ImageField
|
||
label="Apple Touch Icon"
|
||
helper="PNG, 180×180. Shown when users add the site to their iPhone home screen."
|
||
value={value.appleTouchIconUrl}
|
||
onChange={(url) => onChange({ ...value, appleTouchIconUrl: url })}
|
||
/>
|
||
|
||
<ImageField
|
||
label="Main Logo"
|
||
helper="SVG preferred (scales to any size). Or PNG at 800×200 with transparent background."
|
||
value={value.logoUrl}
|
||
onChange={(url) => onChange({ ...value, logoUrl: url })}
|
||
/>
|
||
|
||
<ImageField
|
||
label="Email Logo"
|
||
helper="PNG, 600×200. Used in transactional emails sent to clients."
|
||
value={value.logoEmailUrl}
|
||
onChange={(url) => onChange({ ...value, logoEmailUrl: url })}
|
||
/>
|
||
|
||
<ImageField
|
||
label="OpenGraph / Social Share Image"
|
||
helper="PNG or JPG, 1200×630. Shown when the site is shared on LinkedIn / WhatsApp / Twitter."
|
||
value={value.ogImageUrl}
|
||
onChange={(url) => onChange({ ...value, ogImageUrl: url })}
|
||
/>
|
||
|
||
<div>
|
||
<label className="block text-xs uppercase tracking-widest text-[#86868B] font-bold mb-2">
|
||
Theme color
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
value={value.themeColor}
|
||
onChange={(e) => onChange({ ...value, themeColor: e.target.value })}
|
||
className="w-14 h-10 rounded-lg cursor-pointer bg-transparent"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={value.themeColor}
|
||
onChange={(e) => onChange({ ...value, themeColor: e.target.value })}
|
||
className="bg-black/40 border border-white/10 text-white text-sm rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
|
||
/>
|
||
<span className="text-xs text-[#86868B]">
|
||
Used for browser address bar tint on iOS / Android
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Footer tab ──────────────────────────────────────────────────
|
||
function FooterTab({
|
||
value, onChange, autoTranslate, onAutoTranslateChange, onSave, saving, justSaved,
|
||
}: {
|
||
value: FooterSettings;
|
||
onChange: (v: FooterSettings) => void;
|
||
autoTranslate: boolean;
|
||
onAutoTranslateChange: (v: boolean) => void;
|
||
onSave: () => void;
|
||
saving: boolean;
|
||
justSaved: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<Tip>Footer text appears at the bottom of every page. Auto-translate sends the text to AI for IT, VEC, ES, DE versions.</Tip>
|
||
|
||
<FieldGroup title="Call-to-action banner">
|
||
<TextField
|
||
label="First line"
|
||
value={value.ctaTitle1}
|
||
onChange={(v) => onChange({ ...value, ctaTitle1: v })}
|
||
/>
|
||
<TextField
|
||
label="Second line (highlighted)"
|
||
value={value.ctaTitle2}
|
||
onChange={(v) => onChange({ ...value, ctaTitle2: v })}
|
||
/>
|
||
<TextField
|
||
label="Subtitle paragraph"
|
||
value={value.ctaSubtitle}
|
||
onChange={(v) => onChange({ ...value, ctaSubtitle: v })}
|
||
multiline
|
||
/>
|
||
</FieldGroup>
|
||
|
||
<FieldGroup title="Headquarters address">
|
||
<TextField label="Street" value={value.hqAddress} onChange={(v) => onChange({ ...value, hqAddress: v })} />
|
||
<TextField label="City + ZIP" value={value.hqCity} onChange={(v) => onChange({ ...value, hqCity: v })} />
|
||
<TextField label="Region" value={value.hqRegion} onChange={(v) => onChange({ ...value, hqRegion: v })} />
|
||
<TextField label="Country" value={value.hqCountry} onChange={(v) => onChange({ ...value, hqCountry: v })} />
|
||
</FieldGroup>
|
||
|
||
<FieldGroup title="Legal">
|
||
<TextField label="Copyright holder" value={value.copyrightHolder} onChange={(v) => onChange({ ...value, copyrightHolder: v })} />
|
||
<TextField label="Privacy policy URL" value={value.privacyUrl} onChange={(v) => onChange({ ...value, privacyUrl: v })} />
|
||
<TextField label="Terms of service URL" value={value.termsUrl} onChange={(v) => onChange({ ...value, termsUrl: v })} />
|
||
</FieldGroup>
|
||
|
||
<label className="flex items-center gap-2 text-xs text-[#86868B] cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={autoTranslate}
|
||
onChange={(e) => onAutoTranslateChange(e.target.checked)}
|
||
className="accent-[#00F0FF]"
|
||
/>
|
||
<Sparkles size={12} className="text-[#00F0FF]" />
|
||
Auto-translate to IT, VEC, ES, DE on save
|
||
</label>
|
||
|
||
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Social tab ──────────────────────────────────────────────────
|
||
function SocialTab({
|
||
value, onChange, onSave, saving, justSaved,
|
||
}: {
|
||
value: SocialSettings;
|
||
onChange: (v: SocialSettings) => void;
|
||
onSave: () => void;
|
||
saving: boolean;
|
||
justSaved: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<Tip>Add the URL of each profile. Leave blank to hide. Email is shown as a mailto link.</Tip>
|
||
<TextField label="LinkedIn URL" value={value.linkedin} onChange={(v) => onChange({ ...value, linkedin: v })} placeholder="https://linkedin.com/company/flux-srl" />
|
||
<TextField label="Instagram URL" value={value.instagram} onChange={(v) => onChange({ ...value, instagram: v })} placeholder="https://instagram.com/..." />
|
||
<TextField label="YouTube URL" value={value.youtube} onChange={(v) => onChange({ ...value, youtube: v })} placeholder="https://youtube.com/@..." />
|
||
<TextField label="Contact email" value={value.email} onChange={(v) => onChange({ ...value, email: v })} placeholder="info@rf-flux.com" />
|
||
<SaveButton onClick={onSave} saving={saving} justSaved={justSaved} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Reusable bits ──────────────────────────────────────────────
|
||
function Tip({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-start gap-3 bg-[#00F0FF]/5 border border-[#00F0FF]/15 rounded-2xl p-4 text-xs text-[#86868B]">
|
||
<Info size={14} className="text-[#00F0FF] mt-0.5 flex-shrink-0" />
|
||
<div className="leading-relaxed">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FieldGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="text-[10px] uppercase tracking-widest text-[#86868B] font-bold">{title}</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextField({
|
||
label, value, onChange, placeholder, multiline,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
multiline?: boolean;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<label className="block text-xs text-[#86868B] mb-1.5">{label}</label>
|
||
{multiline ? (
|
||
<textarea
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
rows={3}
|
||
className="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"
|
||
/>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
className="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"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImageField({
|
||
label, helper, value, onChange,
|
||
}: {
|
||
label: string;
|
||
helper: string;
|
||
value: string;
|
||
onChange: (url: string) => void;
|
||
}) {
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const upload = async (file: File) => {
|
||
setIsUploading(true);
|
||
setError(null);
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("scope", "branding");
|
||
fd.append("file", file);
|
||
const res = await fetch("/api/assets", { method: "POST", body: fd });
|
||
const data = await res.json();
|
||
if (data.success) onChange(data.file.publicUrl);
|
||
else setError(data.error || "Upload failed");
|
||
} catch (err: any) {
|
||
setError(err.message || "Upload failed");
|
||
}
|
||
setIsUploading(false);
|
||
};
|
||
|
||
return (
|
||
<div className="bg-white/[0.02] border border-white/10 rounded-2xl p-5">
|
||
<div className="flex flex-col md:flex-row md:items-start gap-4">
|
||
{/* Preview */}
|
||
<div className="w-32 h-32 bg-black rounded-xl overflow-hidden flex items-center justify-center flex-shrink-0 border border-white/5">
|
||
{value ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={value} alt={label} className="max-w-full max-h-full object-contain" />
|
||
) : (
|
||
<ImageIcon size={32} className="text-[#86868B]/40" />
|
||
)}
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium text-white mb-1">{label}</div>
|
||
<div className="text-xs text-[#86868B] mb-3 leading-relaxed">{helper}</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) upload(file);
|
||
e.target.value = "";
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={isUploading}
|
||
className="inline-flex items-center gap-2 bg-[#00F0FF]/10 text-[#00F0FF] hover:bg-[#00F0FF]/20 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
{isUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||
{isUploading ? "Uploading…" : "Upload new"}
|
||
</button>
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder="…or paste URL"
|
||
className="flex-1 min-w-[180px] bg-black/40 border border-white/10 text-white text-xs rounded-lg px-3 py-2 outline-none focus:border-[#00F0FF]/40 font-mono"
|
||
/>
|
||
</div>
|
||
{error && <div className="text-rose-400 text-xs mt-2">{error}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SaveButton({
|
||
onClick, saving, justSaved,
|
||
}: {
|
||
onClick: () => void;
|
||
saving: boolean;
|
||
justSaved: boolean;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-3 pt-4">
|
||
<button
|
||
onClick={onClick}
|
||
disabled={saving}
|
||
className="px-5 py-3 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 changes
|
||
</button>
|
||
{justSaved && (
|
||
<span className="text-emerald-400 text-sm flex items-center gap-1">
|
||
<Check size={14} /> Saved — live in 60 seconds
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|