Compare commits
2 Commits
a8b716fc5a
...
b62c02adc1
| Author | SHA1 | Date | |
|---|---|---|---|
| b62c02adc1 | |||
| b33eee8bea |
@@ -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 })
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 658 KiB |
+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: { icon: (s.favicon_url as string) || '/icon.png' },
|
||||
}
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# Personalizzazione dell'e-commerce
|
||||
|
||||
Tutte le personalizzazioni principali si gestiscono dal **pannello admin** senza toccare il codice, oppure modificando file specifici descritti qui sotto.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tramite pannello admin (senza toccare il codice)
|
||||
|
||||
Vai su `/admin/settings` per modificare:
|
||||
|
||||
| Impostazione | Campo | Descrizione |
|
||||
|---|---|---|
|
||||
| Nome del sito | `site_name` | Appare nel titolo del browser e nel footer |
|
||||
| Descrizione | `site_description` | Meta description per SEO |
|
||||
| Copyright footer | `footer_copyright` | Testo in basso a sinistra nel footer |
|
||||
| Link nel footer | `footer_links` | Array JSON di link `[{"label":"Chi siamo","url":"/about"}]` |
|
||||
| Favicon (URL) | `favicon_url` | URL di un'immagine esterna da usare come favicon |
|
||||
|
||||
Queste impostazioni sono salvate nel database e applicate in tempo reale.
|
||||
|
||||
---
|
||||
|
||||
## 2. Favicon (icona del browser)
|
||||
|
||||
**Modo consigliato — file locale:**
|
||||
|
||||
1. Sostituisci il file [app/src/app/icon.png](../app/src/app/icon.png) con la tua icona
|
||||
2. Formato: PNG con sfondo trasparente, dimensione minima 64×64px (ideale 512×512px)
|
||||
3. Ricostruisci il container: `docker compose up --build app -d`
|
||||
|
||||
> La favicon da pannello admin (`favicon_url`) ha la precedenza sul file locale.
|
||||
> Se vuoi usare solo il file locale, lascia `favicon_url` vuoto nel database.
|
||||
|
||||
**Fallback applicato in** [app/src/app/layout.tsx](../app/src/app/layout.tsx):
|
||||
```ts
|
||||
icons: { icon: (s.favicon_url as string) || '/icon.png' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Titolo e descrizione del sito
|
||||
|
||||
**Via pannello admin** (consigliato): imposta `site_name` e `site_description` in `/admin/settings`.
|
||||
|
||||
**Valori di fallback nel codice** — [app/src/app/layout.tsx](../app/src/app/layout.tsx) righe 13-14:
|
||||
```ts
|
||||
title: (s.site_name as string) || 'ShopX', // ← cambia 'ShopX'
|
||||
description: (s.site_description as string) || 'Your online store',
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Footer
|
||||
|
||||
**Via pannello admin** (consigliato): imposta `footer_copyright` e `footer_links`.
|
||||
|
||||
**Struttura del componente** — [app/src/components/storefront/Footer.tsx](../app/src/components/storefront/Footer.tsx):
|
||||
- Testo copyright: riga con `footer_copyright` o fallback automatico `© anno NomeSito`
|
||||
- Link di navigazione: array JSON salvato in `footer_links`
|
||||
|
||||
Esempio `footer_links`:
|
||||
```json
|
||||
[
|
||||
{ "label": "Chi siamo", "url": "/about" },
|
||||
{ "label": "Contatti", "url": "/contact" },
|
||||
{ "label": "Privacy", "url": "/privacy" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Riepilogo file da modificare (solo codice)
|
||||
|
||||
| Cosa | File |
|
||||
|---|---|
|
||||
| Favicon locale | [app/src/app/icon.png](../app/src/app/icon.png) |
|
||||
| Fallback titolo/favicon | [app/src/app/layout.tsx](../app/src/app/layout.tsx) |
|
||||
| Struttura footer | [app/src/components/storefront/Footer.tsx](../app/src/components/storefront/Footer.tsx) |
|
||||
| Stili globali | [app/src/app/globals.css](../app/src/app/globals.css) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Dopo modifiche al codice
|
||||
|
||||
Se hai modificato file nel codice (non solo il pannello admin), ricostruisci il container:
|
||||
|
||||
```bash
|
||||
docker compose up --build app -d
|
||||
```
|
||||
Reference in New Issue
Block a user