temp
vorrei una favicon e un footer, poi personalizzabili in maniera semplice come potrei fare? ci sono errori
This commit is contained in:
@@ -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) => (
|
||||
<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={setting.key}
|
||||
label={setting.label}
|
||||
type={setting.type}
|
||||
value={values[setting.key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [setting.key]: e.target.value }))}
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
type={s.type}
|
||||
value={values[s.key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [s.key]: e.target.value }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button type="submit" loading={saving}>
|
||||
Save All Settings
|
||||
</Button>
|
||||
</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 })
|
||||
}
|
||||
+20
-10
@@ -1,20 +1,30 @@
|
||||
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> {
|
||||
try {
|
||||
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,
|
||||
}
|
||||
} catch {
|
||||
return { title: 'ShopX', description: 'Your online store' }
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export async function Footer() {
|
||||
let rows: { key: string; value: unknown }[] = []
|
||||
try {
|
||||
rows = await prisma.siteSettings.findMany({
|
||||
where: { key: { in: ['footer_copyright', 'footer_links', 'site_name'] } },
|
||||
})
|
||||
} catch {
|
||||
// DB not available at build time
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user