feat: site settings CMS — favicon, logo, footer, social, OG image

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.
This commit is contained in:
2026-05-04 12:47:10 -05:00
parent b9201a437c
commit b9a744bdbc
7 changed files with 857 additions and 52 deletions
+29 -3
View File
@@ -6,28 +6,54 @@ import NavBar from "@/components/layout/NavBar";
import NavigationManager from "@/components/layout/NavigationManager";
import SilentObserver from "@/components/ai/SilentObserver";
import Footer from "@/components/layout/Footer";
// 🔥 NUEVO: Importamos el Drawer del Carrito / Helpdesk
import CartDrawer from "@/components/layout/CartDrawer";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import { getBranding } from '@/lib/siteSettings';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
// Dynamic metadata pulls favicon, logos, OG image and theme color from the
// SiteSetting CMS. Falls back to defaults when the table is empty.
export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding();
return {
title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
icons: {
icon: branding.faviconUrl,
shortcut: branding.faviconUrl,
apple: branding.appleTouchIconUrl,
},
openGraph: {
title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
images: branding.ogImageUrl ? [{ url: branding.ogImageUrl }] : undefined,
type: "website",
},
twitter: {
card: "summary_large_image",
title: "FLUX | Energy, Directed.",
description: "Advanced Radio Frequency Solutions by Patrizio Grando.",
images: branding.ogImageUrl ? [branding.ogImageUrl] : undefined,
},
};
}
export const viewport: Viewport = {
export async function generateViewport(): Promise<Viewport> {
const branding = await getBranding();
return {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: branding.themeColor,
};
}
export default async function RootLayout({
children,
+10
View File
@@ -17,6 +17,7 @@ import {
Wrench,
Server,
Image as ImageIcon,
Settings as SettingsIcon,
} from "lucide-react";
import { prisma } from "@/lib/prisma";
import { logoutAdmin } from "@/app/hq-command/login/actions";
@@ -117,6 +118,15 @@ export default async function DashboardPage() {
color: "text-blue-400",
bg: "bg-blue-400/10",
border: "hover:border-blue-400/50"
},
{
title: "Site Settings",
description: "Branding, favicon, footer, social links — global config across the site.",
icon: SettingsIcon,
href: "/hq-command/dashboard/settings",
color: "text-fuchsia-400",
bg: "bg-fuchsia-500/10",
border: "hover:border-fuchsia-500/50"
}
];
@@ -0,0 +1,128 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidateContent } from "@/lib/revalidate";
import {
DEFAULT_BRANDING,
DEFAULT_FOOTER,
DEFAULT_SOCIAL,
type BrandingSettings,
type FooterSettings,
type SocialSettings,
} from "@/lib/siteSettingsTypes";
import { translateContentForCMS } from "@/lib/aiTranslator";
function safeParse<T>(json: string | null | undefined, fallback: T): T {
if (!json) return fallback;
try {
return JSON.parse(json) as T;
} catch {
return fallback;
}
}
export async function getAllSettingsForEditor() {
try {
const rows = await prisma.siteSetting.findMany();
const map = Object.fromEntries(rows.map((r: any) => [r.key, r]));
const brandingValue = safeParse<Partial<BrandingSettings>>(
map.branding?.valueJson,
{} as Partial<BrandingSettings>
);
const footerValue = safeParse<Partial<FooterSettings>>(
map.footer?.valueJson,
{} as Partial<FooterSettings>
);
const socialValue = safeParse<Partial<SocialSettings>>(
map.social?.valueJson,
{} as Partial<SocialSettings>
);
return {
success: true,
branding: { ...DEFAULT_BRANDING, ...brandingValue },
footer: { ...DEFAULT_FOOTER, ...footerValue },
social: { ...DEFAULT_SOCIAL, ...socialValue },
footerTranslations: safeParse<Record<string, Partial<FooterSettings>>>(
map.footer?.translationsJson,
{}
),
};
} catch (error: any) {
return { error: error.message };
}
}
async function upsertSetting(
key: string,
valueJson: string,
translationsJson?: string
) {
await prisma.siteSetting.upsert({
where: { key },
update: {
valueJson,
...(translationsJson !== undefined ? { translationsJson } : {}),
},
create: {
key,
valueJson,
translationsJson: translationsJson ?? "{}",
},
});
}
export async function saveBranding(branding: BrandingSettings) {
try {
await upsertSetting("branding", JSON.stringify(branding));
revalidateContent({ scope: "branding" });
revalidateContent({ scope: "settings" });
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function saveFooter(footer: FooterSettings, autoTranslate: boolean) {
try {
let translationsJson: string | undefined;
if (autoTranslate) {
const translatable: Record<string, string> = {
ctaTitle1: footer.ctaTitle1,
ctaTitle2: footer.ctaTitle2,
ctaSubtitle: footer.ctaSubtitle,
hqRegion: footer.hqRegion,
hqCountry: footer.hqCountry,
};
const aiResult = await translateContentForCMS(translatable);
if (aiResult) {
const merged: Record<string, any> = {};
for (const [locale, fields] of Object.entries(aiResult)) {
merged[locale] = { valueJson: JSON.stringify({ ...footer, ...(fields as any) }) };
// We store the per-locale partial under translationsJson, with each
// locale containing only the fields that should be overridden.
merged[locale] = fields;
}
translationsJson = JSON.stringify(merged);
}
}
await upsertSetting("footer", JSON.stringify(footer), translationsJson);
revalidateContent({ scope: "settings" });
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
export async function saveSocial(social: SocialSettings) {
try {
await upsertSetting("social", JSON.stringify(social));
revalidateContent({ scope: "settings" });
return { success: true };
} catch (error: any) {
return { error: error.message };
}
}
@@ -0,0 +1,481 @@
"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 &amp; <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>
);
}
+45 -19
View File
@@ -1,31 +1,40 @@
import { Link } from "@/i18n/routing";
import { Linkedin, Instagram, Youtube, Mail } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import { getTranslations, getLocale } from "next-intl/server";
// 🔥 IMPORTAMOS ESTO PARA ROMPER LA CACHÉ
import { unstable_noStore as noStore } from "next/cache";
import { getFooterSettings, getSocialLinks } from "@/lib/siteSettings";
// Importamos nuestros componentes interactivos
import { AiContactButton, AiFooterLink } from "@/components/ui/AiTriggerButton";
export default async function Footer() {
noStore(); // 🔥 Esta línea asegura que el Footer SIEMPRE consulte Prisma al recargar
const locale = await getLocale();
const t = await getTranslations("Footer");
const [footer, social] = await Promise.all([
getFooterSettings(locale),
getSocialLinks(locale),
]);
let activeApps: any[] = [];
try {
const rawApps = await prisma.application.findMany({
where: { isActive: true },
orderBy: { createdAt: "asc" },
take: 4
take: 4,
});
activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
} catch (error) {
console.error("Error loading apps in footer", error);
}
const socialLinks = [
{ url: social.linkedin, icon: Linkedin, label: "LinkedIn" },
{ url: social.instagram, icon: Instagram, label: "Instagram" },
{ url: social.youtube, icon: Youtube, label: "YouTube" },
{ url: social.email ? `mailto:${social.email}` : "", icon: Mail, label: "Email" },
].filter((s) => s.url);
return (
<footer className="bg-[#1D1D1F] text-[#F5F5F7] pt-24 pb-12 rounded-t-[40px] mt-20 relative z-20 shadow-2xl">
<div className="max-w-7xl mx-auto px-6">
@@ -33,11 +42,13 @@ export default async function Footer() {
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-24 gap-12">
<div>
<h2 className="text-4xl md:text-6xl font-light tracking-tight mb-6">
Ready to optimize <br />
<span className="font-medium text-[#0066CC] dark:text-[#00F0FF] transition-colors">your production?</span>
{footer.ctaTitle1} <br />
<span className="font-medium text-[#0066CC] dark:text-[#00F0FF] transition-colors">
{footer.ctaTitle2}
</span>
</h2>
<p className="text-[#86868B] text-lg max-w-md font-light">
Connect with our engineering team to calculate your ROI and explore custom RF solutions.
{footer.ctaSubtitle}
</p>
</div>
@@ -46,7 +57,6 @@ export default async function Footer() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-12 mb-24 border-t border-white/10 pt-16">
{/* 🔥 COLUMNA TECNOLOGÍA: AHORA DISPARA A LA IA 🔥 */}
<div className="flex flex-col gap-4">
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("techTitle")}</span>
<AiFooterLink
@@ -63,10 +73,9 @@ export default async function Footer() {
/>
</div>
{/* 🔥 COLUMNA APLICACIONES: 100% DINÁMICA Y ANTI-CACHÉ 🔥 */}
<div className="flex flex-col gap-4">
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("appsTitle")}</span>
{activeApps.map(app => (
{activeApps.map((app) => (
<Link key={app.slug} href={`/applications/${app.slug}` as any} className="hover:text-[#0066CC] dark:hover:text-[#00F0FF] transition-colors font-light truncate">
{app.title}
</Link>
@@ -83,20 +92,37 @@ export default async function Footer() {
<div className="flex flex-col gap-4">
<span className="text-sm font-semibold tracking-widest text-[#86868B] uppercase mb-2">{t("hqTitle")}</span>
<p className="text-[#86868B] font-light leading-relaxed">
Via Benedetto Marcello 32 <br />
36060 Romano d'Ezzelino <br />
Vicenza, Italy
{footer.hqAddress} <br />
{footer.hqCity} <br />
{footer.hqRegion}, {footer.hqCountry}
</p>
{socialLinks.length > 0 && (
<div className="flex items-center gap-3 mt-2">
{socialLinks.map(({ url, icon: Icon, label }) => (
<a
key={label}
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="w-9 h-9 rounded-full bg-white/5 hover:bg-[#0066CC]/20 dark:hover:bg-[#00F0FF]/20 flex items-center justify-center transition-colors"
>
<Icon size={15} className="text-[#86868B]" />
</a>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col md:flex-row justify-between items-center border-t border-white/10 pt-8 text-sm text-[#86868B] font-light">
<p>© {new Date().getFullYear()} FLUX Srl. {t("rights")}.</p>
<p>© {new Date().getFullYear()} {footer.copyrightHolder}. {t("rights")}.</p>
<div className="flex gap-6 mt-4 md:mt-0">
<Link href="#" className="hover:text-white transition-colors">Privacy Policy</Link>
<Link href="#" className="hover:text-white transition-colors">Terms of Service</Link>
<Link href={footer.privacyUrl as any} className="hover:text-white transition-colors">Privacy Policy</Link>
<Link href={footer.termsUrl as any} className="hover:text-white transition-colors">Terms of Service</Link>
<span className="flex items-center gap-2 text-white">
<span className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#00F0FF] animate-pulse"></span>
<span className="w-1.5 h-1.5 rounded-full bg-[#0066CC] dark:bg-[#00F0FF] animate-pulse" />
{t("madeInItaly")}
</span>
</div>
+70
View File
@@ -0,0 +1,70 @@
// src/lib/siteSettings.ts
// ─────────────────────────────────────────────────────────────────────────────
// Server-only loader for site-wide settings (branding, footer, social).
// Reads the SiteSetting key-value table and merges with defaults.
//
// For pure types and defaults safe to import from client components,
// use src/lib/siteSettingsTypes.ts instead.
// ─────────────────────────────────────────────────────────────────────────────
import "server-only";
import { prisma } from "@/lib/prisma";
import { getLocalizedData } from "@/lib/i18nHelper";
import {
DEFAULT_BRANDING,
DEFAULT_FOOTER,
DEFAULT_SOCIAL,
type BrandingSettings,
type FooterSettings,
type SocialSettings,
} from "@/lib/siteSettingsTypes";
export {
DEFAULT_BRANDING,
DEFAULT_FOOTER,
DEFAULT_SOCIAL,
type BrandingSettings,
type FooterSettings,
type SocialSettings,
};
function safeParse<T>(json: string | null | undefined, fallback: T): T {
if (!json) return fallback;
try {
return JSON.parse(json) as T;
} catch {
return fallback;
}
}
async function readSetting<T>(key: string, defaults: T, locale?: string): Promise<T> {
try {
const row = await prisma.siteSetting.findUnique({ where: { key } });
if (!row) return defaults;
const localized = locale ? getLocalizedData(row, locale) : row;
const value = safeParse<Partial<T>>(localized.valueJson, {} as Partial<T>);
return { ...defaults, ...value };
} catch (error) {
console.error(`[siteSettings] Failed to read "${key}":`, error);
return defaults;
}
}
export const getBranding = (locale?: string) =>
readSetting<BrandingSettings>("branding", DEFAULT_BRANDING, locale);
export const getFooterSettings = (locale?: string) =>
readSetting<FooterSettings>("footer", DEFAULT_FOOTER, locale);
export const getSocialLinks = (locale?: string) =>
readSetting<SocialSettings>("social", DEFAULT_SOCIAL, locale);
export async function getAllSettings() {
const [branding, footer, social] = await Promise.all([
getBranding(),
getFooterSettings(),
getSocialLinks(),
]);
return { branding, footer, social };
}
+64
View File
@@ -0,0 +1,64 @@
// src/lib/siteSettingsTypes.ts
// ─────────────────────────────────────────────────────────────────────────────
// Pure types + defaults — no Prisma, no server-only deps.
// Safe to import from client components (the HQ Command settings page uses this).
// ─────────────────────────────────────────────────────────────────────────────
export interface BrandingSettings {
logoUrl: string;
logoEmailUrl: string;
faviconUrl: string;
appleTouchIconUrl: string;
ogImageUrl: string;
themeColor: string;
}
export interface FooterSettings {
ctaTitle1: string;
ctaTitle2: string;
ctaSubtitle: string;
hqAddress: string;
hqCity: string;
hqRegion: string;
hqCountry: string;
copyrightHolder: string;
privacyUrl: string;
termsUrl: string;
}
export interface SocialSettings {
linkedin: string;
instagram: string;
youtube: string;
email: string;
}
export const DEFAULT_BRANDING: BrandingSettings = {
logoUrl: "/flux-logo.svg",
logoEmailUrl: "/logoEmail.png",
faviconUrl: "/flux-logo.png",
appleTouchIconUrl: "/flux-logo.png",
ogImageUrl: "/flux-logo.png",
themeColor: "#0066CC",
};
export const DEFAULT_FOOTER: FooterSettings = {
ctaTitle1: "Ready to optimize",
ctaTitle2: "your production?",
ctaSubtitle:
"Connect with our engineering team to calculate your ROI and explore custom RF solutions.",
hqAddress: "Via Benedetto Marcello 32",
hqCity: "36060 Romano d'Ezzelino",
hqRegion: "Vicenza",
hqCountry: "Italy",
copyrightHolder: "FLUX Srl",
privacyUrl: "/privacy",
termsUrl: "/terms",
};
export const DEFAULT_SOCIAL: SocialSettings = {
linkedin: "",
instagram: "",
youtube: "",
email: "info@rf-flux.com",
};