vorrei una favicon e un footer, poi personalizzabili in maniera semplice come potrei fare?

ci sono errori
This commit is contained in:
2026-05-18 18:56:07 +02:00
parent e82e20b0f1
commit 4e654167b3
4 changed files with 234 additions and 57 deletions
+129 -47
View File
@@ -1,11 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/Input'
import { useEffect, useRef, useState } from 'react'
import { Input, Textarea } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
const DEFAULT_SETTINGS = [
const GENERAL_SETTINGS = [
{ key: 'site_name', label: 'Site Name', type: 'text', defaultValue: 'ShopX' },
{ key: 'site_description', label: 'Site Description', type: 'text', defaultValue: 'Your online store' },
{ key: 'support_email', label: 'Support Email', type: 'email', defaultValue: 'support@example.com' },
@@ -13,12 +13,24 @@ const DEFAULT_SETTINGS = [
{ key: 'tax_rate', label: 'Tax Rate (%)', type: 'number', defaultValue: '0' },
]
const FOOTER_SETTINGS = [
{ key: 'footer_copyright', label: 'Testo copyright', type: 'text', defaultValue: '' },
{ key: 'footer_links', label: '', type: 'textarea', defaultValue: '[]' },
]
const ALL_KEYS = [...GENERAL_SETTINGS, ...FOOTER_SETTINGS].map((s) => s.key)
export default function AdminSettingsPage() {
const [values, setValues] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
const [faviconMsg, setFaviconMsg] = useState('')
const [faviconErr, setFaviconErr] = useState('')
const [uploadingFavicon, setUploadingFavicon] = useState(false)
const faviconRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetch('/api/admin/settings')
@@ -26,76 +38,146 @@ export default function AdminSettingsPage() {
.then((data) => {
const settings = data.settings || {}
const initial: Record<string, string> = {}
DEFAULT_SETTINGS.forEach((s) => {
initial[s.key] = String((settings[s.key] as string | undefined) ?? s.defaultValue)
ALL_KEYS.forEach((key) => {
const def = [...GENERAL_SETTINGS, ...FOOTER_SETTINGS].find((s) => s.key === key)
initial[key] = String((settings[key] as string | undefined) ?? def?.defaultValue ?? '')
})
setValues(initial)
if (settings.favicon_url) setFaviconUrl(settings.favicon_url as string)
setLoading(false)
})
}, [])
async function saveSetting(key: string, value: string) {
setSaving(true)
setError('')
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
})
setSaving(false)
if (res.ok) {
setMessage('Settings saved!')
setTimeout(() => setMessage(''), 3000)
} else {
const data = await res.json()
setError(data.error || 'Failed to save')
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError('')
for (const s of DEFAULT_SETTINGS) {
for (const key of ALL_KEYS) {
await fetch('/api/admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: s.key, value: values[s.key] }),
body: JSON.stringify({ key, value: values[key] }),
})
}
setSaving(false)
setMessage('All settings saved!')
setMessage('Impostazioni salvate!')
setTimeout(() => setMessage(''), 3000)
}
async function handleFaviconUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setFaviconErr('')
setFaviconMsg('')
setUploadingFavicon(true)
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/admin/upload/favicon', { method: 'POST', body: fd })
const data = await res.json()
setUploadingFavicon(false)
if (!res.ok) {
setFaviconErr(data.error || 'Upload fallito')
} else {
setFaviconUrl(data.url + '?t=' + Date.now())
setFaviconMsg('Favicon aggiornata!')
setTimeout(() => setFaviconMsg(''), 3000)
}
if (faviconRef.current) faviconRef.current.value = ''
}
if (loading) return <div className="p-8 text-gray-500">Loading...</div>
return (
<div className="p-8 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="p-8 max-w-2xl space-y-8">
<h1 className="text-2xl font-bold">Settings</h1>
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
{error && <Alert variant="error">{error}</Alert>}
{message && <Alert variant="success">{message}</Alert>}
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
{DEFAULT_SETTINGS.map((setting) => (
<Input
key={setting.key}
label={setting.label}
type={setting.type}
value={values[setting.key] || ''}
onChange={(e) => setValues((v) => ({ ...v, [setting.key]: e.target.value }))}
/>
))}
<div className="pt-2">
<Button type="submit" loading={saving}>
Save All Settings
</Button>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Impostazioni generali */}
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Generale</h2>
{GENERAL_SETTINGS.map((s) => (
<Input
key={s.key}
label={s.label}
type={s.type}
value={values[s.key] || ''}
onChange={(e) => setValues((v) => ({ ...v, [s.key]: e.target.value }))}
/>
))}
</div>
{/* Footer */}
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Footer</h2>
<Input
label="Testo copyright"
type="text"
placeholder={`© ${new Date().getFullYear()} Il Mio Negozio`}
value={values['footer_copyright'] || ''}
onChange={(e) => setValues((v) => ({ ...v, footer_copyright: e.target.value }))}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Link footer (JSON)
</label>
<Textarea
label=""
rows={4}
className="font-mono text-xs"
placeholder={'[{"label":"Privacy","url":"/privacy"},{"label":"Contatti","url":"/contatti"}]'}
value={values['footer_links'] || '[]'}
onChange={(e) => setValues((v) => ({ ...v, footer_links: e.target.value }))}
/>
<p className="text-xs text-gray-400 mt-1">
Array JSON di oggetti con <code>label</code> e <code>url</code>. Lascia <code>[]</code> per nessun link.
</p>
</div>
</div>
<Button type="submit" loading={saving}>
Salva tutte le impostazioni
</Button>
</form>
{/* Favicon — upload separato */}
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
<h2 className="font-semibold">Favicon</h2>
<p className="text-sm text-gray-500">
Icona mostrata nella tab del browser. Usa un file quadrato, idealmente 32×32 o 64×64 px.
</p>
{faviconUrl && (
<div className="flex items-center gap-3">
<img
src={faviconUrl}
alt="favicon corrente"
className="w-8 h-8 rounded border border-gray-200 object-contain bg-gray-50"
/>
<span className="text-sm text-gray-500">Favicon corrente</span>
</div>
)}
{faviconErr && <p className="text-sm text-red-600">{faviconErr}</p>}
{faviconMsg && <p className="text-sm text-green-600">{faviconMsg}</p>}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{uploadingFavicon ? 'Caricamento...' : 'Carica nuova favicon'}
</label>
<input
ref={faviconRef}
type="file"
accept="image/png,image/x-icon,image/svg+xml,image/webp"
disabled={uploadingFavicon}
onChange={handleFaviconUpload}
className="block text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200 disabled:opacity-50"
/>
<p className="text-xs text-gray-400 mt-1">PNG, ICO, SVG, WebP max 1MB</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']
const MAX_SIZE = 1 * 1024 * 1024 // 1MB
const FAVICON_URL = '/uploads/branding/favicon.png'
async function requireAdmin() {
const user = await getCurrentUser()
if (!user || (user.role !== 'ADMIN' && user.role !== 'OWNER')) return null
return user
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin()
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, SVG o WebP)' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
}
const dir = path.join(process.cwd(), 'public', 'uploads', 'branding')
await mkdir(dir, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(path.join(dir, 'favicon.png'), buffer)
await prisma.siteSettings.upsert({
where: { key: 'favicon_url' },
update: { value: FAVICON_URL },
create: { key: 'favicon_url', value: FAVICON_URL },
})
return NextResponse.json({ url: FAVICON_URL })
}
+17 -10
View File
@@ -1,20 +1,27 @@
import type { Metadata } from 'next'
import './globals.css'
import { prisma } from '@/lib/prisma'
import { Footer } from '@/components/storefront/Footer'
export const metadata: Metadata = {
title: 'ShopX - E-Commerce Platform',
description: 'Your online store',
export async function generateMetadata(): Promise<Metadata> {
const rows = await prisma.siteSettings.findMany({
where: { key: { in: ['site_name', 'site_description', 'favicon_url'] } },
})
const s = Object.fromEntries(rows.map((r) => [r.key, r.value]))
return {
title: (s.site_name as string) || 'ShopX',
description: (s.site_description as string) || 'Your online store',
icons: s.favicon_url ? { icon: s.favicon_url as string } : undefined,
}
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-gray-50 text-gray-900 min-h-screen">
<html lang="it">
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
{children}
<Footer />
</body>
</html>
)
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from '@/lib/prisma'
interface FooterLink {
label: string
url: string
}
export async function Footer() {
const rows = await prisma.siteSettings.findMany({
where: { key: { in: ['footer_copyright', 'footer_links', 'site_name'] } },
})
const s = Object.fromEntries(rows.map((r) => [r.key, r.value]))
const siteName = (s.site_name as string) || 'Il Negozio'
const copyright = (s.footer_copyright as string) || `© ${new Date().getFullYear()} ${siteName}`
let links: FooterLink[] = []
try {
const raw = s.footer_links
if (Array.isArray(raw)) links = raw as unknown as FooterLink[]
} catch {
// invalid JSON stored, ignore
}
return (
<footer className="border-t border-gray-200 bg-white mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col sm:flex-row items-center justify-between gap-3">
<p className="text-sm text-gray-500">{copyright}</p>
{links.length > 0 && (
<nav className="flex gap-4">
{links.map((link) => (
<a
key={link.url}
href={link.url}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors"
>
{link.label}
</a>
))}
</nav>
)}
</div>
</footer>
)
}