From b9a744bdbcaf7f21c05c67c4e375fed4d253964f Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Mon, 4 May 2026 12:47:10 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20site=20settings=20CMS=20=E2=80=94=20fav?= =?UTF-8?q?icon,=20logo,=20footer,=20social,=20OG=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/[locale]/layout.tsx | 52 +- src/app/hq-command/dashboard/page.tsx | 12 +- .../hq-command/dashboard/settings/actions.ts | 128 +++++ .../hq-command/dashboard/settings/page.tsx | 481 ++++++++++++++++++ src/components/layout/Footer.tsx | 102 ++-- src/lib/siteSettings.ts | 70 +++ src/lib/siteSettingsTypes.ts | 64 +++ 7 files changed, 857 insertions(+), 52 deletions(-) create mode 100644 src/app/hq-command/dashboard/settings/actions.ts create mode 100644 src/app/hq-command/dashboard/settings/page.tsx create mode 100644 src/lib/siteSettings.ts create mode 100644 src/lib/siteSettingsTypes.ts diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 0819046..36caaf3 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -5,29 +5,55 @@ import "../globals.css"; 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 Footer from "@/components/layout/Footer"; 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 = { - title: "FLUX | Energy, Directed.", - description: "Advanced Radio Frequency Solutions by Patrizio Grando.", -}; +// 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 { + 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 = { - width: "device-width", - initialScale: 1, - maximumScale: 1, - userScalable: false, - viewportFit: "cover", -}; +export async function generateViewport(): Promise { + 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, diff --git a/src/app/hq-command/dashboard/page.tsx b/src/app/hq-command/dashboard/page.tsx index 64c25f7..af755fd 100644 --- a/src/app/hq-command/dashboard/page.tsx +++ b/src/app/hq-command/dashboard/page.tsx @@ -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"; @@ -113,10 +114,19 @@ export default async function DashboardPage() { title: "System Health", description: "Monitor server metrics, database connection, and manage secure data backups.", icon: Server, - href: "/hq-command/dashboard/health", + href: "/hq-command/dashboard/health", 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" } ]; diff --git a/src/app/hq-command/dashboard/settings/actions.ts b/src/app/hq-command/dashboard/settings/actions.ts new file mode 100644 index 0000000..f5a566a --- /dev/null +++ b/src/app/hq-command/dashboard/settings/actions.ts @@ -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(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>( + map.branding?.valueJson, + {} as Partial + ); + const footerValue = safeParse>( + map.footer?.valueJson, + {} as Partial + ); + const socialValue = safeParse>( + map.social?.valueJson, + {} as Partial + ); + + return { + success: true, + branding: { ...DEFAULT_BRANDING, ...brandingValue }, + footer: { ...DEFAULT_FOOTER, ...footerValue }, + social: { ...DEFAULT_SOCIAL, ...socialValue }, + footerTranslations: safeParse>>( + 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 = { + 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 = {}; + 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 }; + } +} diff --git a/src/app/hq-command/dashboard/settings/page.tsx b/src/app/hq-command/dashboard/settings/page.tsx new file mode 100644 index 0000000..5c4a6b8 --- /dev/null +++ b/src/app/hq-command/dashboard/settings/page.tsx @@ -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("branding"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [savedFlash, setSavedFlash] = useState(null); + + const [branding, setBranding] = useState(DEFAULT_BRANDING); + const [footer, setFooter] = useState(DEFAULT_FOOTER); + const [social, setSocial] = useState(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 ( +
+ + Back to Dashboard + + +
+
+ + Site Settings +
+

+ Branding & Global Content. +

+

+ Favicon, logo, footer, social links — applies site-wide across all locales. +

+
+ + {/* Tabs */} +
+ {[ + { id: "branding", label: "Branding", icon: ImageIcon }, + { id: "footer", label: "Footer", icon: Type }, + { id: "social", label: "Social", icon: Share2 }, + ].map((t) => ( + + ))} +
+ + {loading ? ( +
+ Loading settings… +
+ ) : tab === "branding" ? ( + + ) : tab === "footer" ? ( + + ) : ( + + )} +
+ ); +} + +// ─── Branding tab ──────────────────────────────────────────────── +function BrandingTab({ + value, onChange, onSave, saving, justSaved, +}: { + value: BrandingSettings; + onChange: (v: BrandingSettings) => void; + onSave: () => void; + saving: boolean; + justSaved: boolean; +}) { + return ( +
+ + 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. + + + onChange({ ...value, faviconUrl: url })} + /> + + onChange({ ...value, appleTouchIconUrl: url })} + /> + + onChange({ ...value, logoUrl: url })} + /> + + onChange({ ...value, logoEmailUrl: url })} + /> + + onChange({ ...value, ogImageUrl: url })} + /> + +
+ +
+ onChange({ ...value, themeColor: e.target.value })} + className="w-14 h-10 rounded-lg cursor-pointer bg-transparent" + /> + 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" + /> + + Used for browser address bar tint on iOS / Android + +
+
+ + +
+ ); +} + +// ─── 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 ( +
+ Footer text appears at the bottom of every page. Auto-translate sends the text to AI for IT, VEC, ES, DE versions. + + + onChange({ ...value, ctaTitle1: v })} + /> + onChange({ ...value, ctaTitle2: v })} + /> + onChange({ ...value, ctaSubtitle: v })} + multiline + /> + + + + onChange({ ...value, hqAddress: v })} /> + onChange({ ...value, hqCity: v })} /> + onChange({ ...value, hqRegion: v })} /> + onChange({ ...value, hqCountry: v })} /> + + + + onChange({ ...value, copyrightHolder: v })} /> + onChange({ ...value, privacyUrl: v })} /> + onChange({ ...value, termsUrl: v })} /> + + + + + +
+ ); +} + +// ─── Social tab ────────────────────────────────────────────────── +function SocialTab({ + value, onChange, onSave, saving, justSaved, +}: { + value: SocialSettings; + onChange: (v: SocialSettings) => void; + onSave: () => void; + saving: boolean; + justSaved: boolean; +}) { + return ( +
+ Add the URL of each profile. Leave blank to hide. Email is shown as a mailto link. + onChange({ ...value, linkedin: v })} placeholder="https://linkedin.com/company/flux-srl" /> + onChange({ ...value, instagram: v })} placeholder="https://instagram.com/..." /> + onChange({ ...value, youtube: v })} placeholder="https://youtube.com/@..." /> + onChange({ ...value, email: v })} placeholder="info@rf-flux.com" /> + +
+ ); +} + +// ─── Reusable bits ────────────────────────────────────────────── +function Tip({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} + +function FieldGroup({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function TextField({ + label, value, onChange, placeholder, multiline, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + multiline?: boolean; +}) { + return ( +
+ + {multiline ? ( +