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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input, Textarea } from '@/components/ui/Input'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = [
|
const GENERAL_SETTINGS = [
|
||||||
{ key: 'site_name', label: 'Site Name', type: 'text', defaultValue: 'ShopX' },
|
{ key: 'site_name', label: 'Site Name', type: 'text', defaultValue: 'ShopX' },
|
||||||
{ key: 'site_description', label: 'Site Description', type: 'text', defaultValue: 'Your online store' },
|
{ key: 'site_description', label: 'Site Description', type: 'text', defaultValue: 'Your online store' },
|
||||||
{ key: 'support_email', label: 'Support Email', type: 'email', defaultValue: 'support@example.com' },
|
{ 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' },
|
{ 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() {
|
export default function AdminSettingsPage() {
|
||||||
const [values, setValues] = useState<Record<string, string>>({})
|
const [values, setValues] = useState<Record<string, string>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [error, setError] = 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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/settings')
|
fetch('/api/admin/settings')
|
||||||
@@ -26,76 +38,146 @@ export default function AdminSettingsPage() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
const settings = data.settings || {}
|
const settings = data.settings || {}
|
||||||
const initial: Record<string, string> = {}
|
const initial: Record<string, string> = {}
|
||||||
DEFAULT_SETTINGS.forEach((s) => {
|
ALL_KEYS.forEach((key) => {
|
||||||
initial[s.key] = String((settings[s.key] as string | undefined) ?? s.defaultValue)
|
const def = [...GENERAL_SETTINGS, ...FOOTER_SETTINGS].find((s) => s.key === key)
|
||||||
|
initial[key] = String((settings[key] as string | undefined) ?? def?.defaultValue ?? '')
|
||||||
})
|
})
|
||||||
setValues(initial)
|
setValues(initial)
|
||||||
|
if (settings.favicon_url) setFaviconUrl(settings.favicon_url as string)
|
||||||
setLoading(false)
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
for (const key of ALL_KEYS) {
|
||||||
for (const s of DEFAULT_SETTINGS) {
|
|
||||||
await fetch('/api/admin/settings', {
|
await fetch('/api/admin/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key: s.key, value: values[s.key] }),
|
body: JSON.stringify({ key, value: values[key] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
setMessage('All settings saved!')
|
setMessage('Impostazioni salvate!')
|
||||||
setTimeout(() => setMessage(''), 3000)
|
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>
|
if (loading) return <div className="p-8 text-gray-500">Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-2xl">
|
<div className="p-8 max-w-2xl space-y-8">
|
||||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
|
||||||
{error && <Alert variant="error" className="mb-4">{error}</Alert>}
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
{message && <Alert variant="success" className="mb-4">{message}</Alert>}
|
{message && <Alert variant="success">{message}</Alert>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
{DEFAULT_SETTINGS.map((setting) => (
|
{/* Impostazioni generali */}
|
||||||
<Input
|
<div className="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
key={setting.key}
|
<h2 className="font-semibold">Generale</h2>
|
||||||
label={setting.label}
|
{GENERAL_SETTINGS.map((s) => (
|
||||||
type={setting.type}
|
<Input
|
||||||
value={values[setting.key] || ''}
|
key={s.key}
|
||||||
onChange={(e) => setValues((v) => ({ ...v, [setting.key]: e.target.value }))}
|
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>
|
</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>
|
</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>
|
</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
@@ -1,20 +1,27 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { Footer } from '@/components/storefront/Footer'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: 'ShopX - E-Commerce Platform',
|
const rows = await prisma.siteSettings.findMany({
|
||||||
description: 'Your online store',
|
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({
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="it">
|
||||||
<body className="bg-gray-50 text-gray-900 min-h-screen">
|
<body className="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
||||||
{children}
|
{children}
|
||||||
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user