added admin dashboard
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
/* ===== ADMIN THEME ===== */
|
||||
.admin {
|
||||
--nav-bg: #0b1624;
|
||||
--nav-item: rgba(255, 255, 255, 0.66);
|
||||
--nav-item-active: #fff;
|
||||
--nav-item-bg-hover: rgba(255, 255, 255, 0.07);
|
||||
--nav-item-bg-active: rgba(139, 115, 61, 0.22);
|
||||
--panel: #fff;
|
||||
--panel-muted: #fbf9f4;
|
||||
--border: #e6e4dc;
|
||||
--text: #1c1c1a;
|
||||
--text-muted: #6b6e72;
|
||||
--accent: #8B733D;
|
||||
--accent-dark: #001d36;
|
||||
--danger: #b03030;
|
||||
--ok: #1e7a3c;
|
||||
--warn: #b06a10;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--panel-muted);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
||||
|
||||
.admin-side {
|
||||
background: var(--nav-bg); color: #fff;
|
||||
padding: 24px 18px;
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
}
|
||||
.admin-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px 22px; border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.admin-brand img { height: 30px; width: 30px; object-fit: contain; }
|
||||
.admin-brand strong { font-family: 'Newsreader', serif; font-size: 17px; letter-spacing: 0.01em; }
|
||||
.admin-brand small {
|
||||
display: block; font-size: 9px; letter-spacing: 0.25em;
|
||||
text-transform: uppercase; color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.admin-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.admin-nav a {
|
||||
color: var(--nav-item); text-decoration: none;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font-size: 14px; font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.admin-nav a:hover { background: var(--nav-item-bg-hover); color: var(--nav-item-active); }
|
||||
.admin-nav a.active {
|
||||
background: var(--nav-item-bg-active); color: var(--nav-item-active);
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-nav .material-symbols-outlined { font-size: 20px; }
|
||||
.admin-foot { padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.admin-foot form { margin: 0; }
|
||||
.admin-user {
|
||||
color: rgba(255, 255, 255, 0.6); font-size: 12px;
|
||||
padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.admin-user strong { color: #fff; font-size: 13px; font-weight: 700; }
|
||||
.admin-logout {
|
||||
width: 100%; color: rgba(255, 255, 255, 0.78);
|
||||
padding: 10px 12px; border-radius: 10px; font-size: 13px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.05); cursor: pointer;
|
||||
font-family: inherit; border: none;
|
||||
}
|
||||
.admin-logout:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
|
||||
|
||||
.admin-main { padding: 32px 36px 48px; max-width: 1200px; }
|
||||
.admin-main header.page-head { margin-bottom: 28px; }
|
||||
.admin-main .kicker {
|
||||
color: var(--accent); font-size: 10px; letter-spacing: 0.24em;
|
||||
text-transform: uppercase; font-weight: 700; margin-bottom: 8px;
|
||||
}
|
||||
.admin-main h1 {
|
||||
font-family: 'Newsreader', serif; font-size: 32px;
|
||||
color: var(--accent-dark); font-weight: 700; line-height: 1.15;
|
||||
}
|
||||
.admin-main h1 + p { color: var(--text-muted); font-size: 15px; margin-top: 6px; max-width: 60ch; }
|
||||
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 22px;
|
||||
}
|
||||
.card + .card { margin-top: 18px; }
|
||||
.card h2 {
|
||||
font-family: 'Newsreader', serif; font-size: 20px;
|
||||
color: var(--accent-dark); margin-bottom: 10px;
|
||||
}
|
||||
.card p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
|
||||
.stat {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 18px 20px;
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--text-muted); font-size: 10px;
|
||||
letter-spacing: 0.22em; text-transform: uppercase; font-weight: 700;
|
||||
}
|
||||
.stat-value {
|
||||
font-family: 'Newsreader', serif; font-size: 30px;
|
||||
color: var(--accent-dark); font-weight: 700; margin-top: 6px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field label {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent-dark);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.field input, .field textarea, .field select {
|
||||
width: 100%; padding: 11px 13px; font-size: 14px;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
background: #fff; font-family: inherit; color: var(--text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.field input:focus, .field textarea:focus, .field select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(139, 115, 61, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 80px; }
|
||||
.field small { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 18px; font-size: 13px; font-weight: 700;
|
||||
background: var(--accent-dark); color: #fff;
|
||||
border: none; border-radius: 9999px;
|
||||
font-family: inherit; cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn.btn-ghost { background: transparent; color: var(--accent-dark); border: 1px solid var(--border); }
|
||||
.btn.btn-ghost:hover:not(:disabled) { background: var(--panel-muted); }
|
||||
.btn.btn-danger { background: var(--danger); }
|
||||
.btn.btn-sm { padding: 6px 12px; font-size: 11px; }
|
||||
.btn .material-symbols-outlined { font-size: 16px; }
|
||||
|
||||
.section-divider {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin: 28px 0 14px;
|
||||
}
|
||||
.section-divider h2 {
|
||||
font-family: 'Newsreader', serif; font-size: 22px;
|
||||
color: var(--accent-dark); font-weight: 700;
|
||||
}
|
||||
.section-divider .count {
|
||||
background: rgba(0, 29, 54, 0.06); color: var(--accent-dark);
|
||||
padding: 3px 10px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
|
||||
.score-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
|
||||
.score-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.03em;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.score-chip.seo-good, .score-chip.geo-good { background: rgba(30, 122, 60, 0.08); color: var(--ok); border-color: rgba(30, 122, 60, 0.22); }
|
||||
.score-chip.seo-warn, .score-chip.geo-warn { background: rgba(176, 106, 16, 0.08); color: var(--warn); border-color: rgba(176, 106, 16, 0.22); }
|
||||
.score-chip.seo-bad, .score-chip.geo-bad { background: rgba(176, 48, 48, 0.08); color: var(--danger); border-color: rgba(176, 48, 48, 0.22); }
|
||||
|
||||
.tip-list {
|
||||
list-style: none; padding: 0; margin: 8px 0 0;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.tip-list li {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
padding-left: 14px; position: relative; line-height: 1.5;
|
||||
}
|
||||
.tip-list li::before { content: '•'; position: absolute; left: 0; color: var(--accent); }
|
||||
|
||||
.slot-grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
|
||||
.slot-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 18px;
|
||||
}
|
||||
.slot-card header {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.slot-card .slot-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.slot-card .slot-label { font-size: 13px; font-weight: 700; color: var(--accent-dark); }
|
||||
|
||||
.alert-row {
|
||||
background: rgba(30, 122, 60, 0.08); color: var(--ok);
|
||||
border: 1px solid rgba(30, 122, 60, 0.18);
|
||||
padding: 10px 14px; border-radius: 10px;
|
||||
font-size: 13px; font-weight: 700;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.alert-row.err {
|
||||
background: rgba(176, 48, 48, 0.08); color: var(--danger);
|
||||
border-color: rgba(176, 48, 48, 0.18);
|
||||
}
|
||||
|
||||
.sub-table {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; overflow: hidden;
|
||||
}
|
||||
.sub-row {
|
||||
display: grid; grid-template-columns: 180px 1fr 180px 120px;
|
||||
padding: 14px 18px; gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sub-row:last-child { border-bottom: none; }
|
||||
.sub-row header {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.sub-row time {
|
||||
color: var(--text-muted); font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700;
|
||||
}
|
||||
.sub-row .name { color: var(--accent-dark); font-weight: 700; font-size: 14px; }
|
||||
.sub-row .dog { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.sub-row .msg { font-size: 14px; color: var(--text); line-height: 1.55; white-space: pre-wrap; }
|
||||
.sub-row .meta { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.image-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.image-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 16px;
|
||||
}
|
||||
.image-card .preview {
|
||||
background: var(--panel-muted); border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.image-card .preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-card header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||
.image-card .slot-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; color: var(--text-muted); }
|
||||
.image-card .slot-label { font-size: 13px; font-weight: 700; color: var(--accent-dark); }
|
||||
.image-card .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--panel-muted);
|
||||
padding: 32px;
|
||||
}
|
||||
.login-card {
|
||||
background: #fff; border: 1px solid var(--border);
|
||||
padding: 40px; border-radius: 20px;
|
||||
max-width: 400px; width: 100%;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.login-card h1 {
|
||||
font-family: 'Newsreader', serif;
|
||||
color: var(--accent-dark); font-size: 26px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-card p { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
||||
.login-card .btn { width: 100%; justify-content: center; padding: 14px; }
|
||||
|
||||
.warranty-status {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 18px; border-radius: 14px;
|
||||
background: rgba(30, 122, 60, 0.08); color: var(--ok);
|
||||
border: 1px solid rgba(30, 122, 60, 0.22);
|
||||
}
|
||||
.warranty-status .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(30, 122, 60, 0.15); }
|
||||
.warranty-status strong { font-size: 15px; }
|
||||
.warranty-status small { display: block; color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
.cover-table { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||
.cover-col {
|
||||
padding: 18px; border-radius: 14px;
|
||||
border: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
.cover-col.yes { border-color: rgba(30, 122, 60, 0.22); background: rgba(30, 122, 60, 0.04); }
|
||||
.cover-col.no { border-color: rgba(176, 48, 48, 0.22); background: rgba(176, 48, 48, 0.04); }
|
||||
.cover-col h3 { font-size: 14px; margin-bottom: 10px; }
|
||||
.cover-col ul { margin: 0; padding-left: 18px; }
|
||||
.cover-col li { font-size: 13px; color: var(--text); line-height: 1.6; margin-bottom: 4px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-shell { grid-template-columns: 1fr; }
|
||||
.admin-side { position: relative; height: auto; }
|
||||
.grid-2, .grid-3, .grid-4, .image-grid, .cover-table { grid-template-columns: 1fr; }
|
||||
.sub-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/* ===== RESET & BASE ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--navy: #001d36;
|
||||
--gold: #8B733D;
|
||||
--amber: rgb(225, 173, 1);
|
||||
--cream: #EBE8DE;
|
||||
--cream-light: #F1F0E9;
|
||||
--offwhite: #fbf9f4;
|
||||
--dark: #1c1c1a;
|
||||
--muted: #42484e;
|
||||
--outline-soft: rgba(194, 200, 192, 0.25);
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
background: var(--offwhite);
|
||||
color: var(--dark);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; border: none; font-family: inherit; background: none; }
|
||||
input, textarea { font-family: inherit; outline: none; }
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
admin: { username: string } | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Manrope:wght@200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser, seedDefaultAdminIfMissing } from '$lib/server/auth';
|
||||
|
||||
const seed = seedDefaultAdminIfMissing();
|
||||
if (seed.tempPassword) {
|
||||
console.log(
|
||||
`[admin] seeded default admin: username="${seed.username}" password="${seed.tempPassword}" (change it from /admin)`
|
||||
);
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.admin = getSessionUser(event.cookies);
|
||||
|
||||
const p = event.url.pathname;
|
||||
const isAdminArea = p === '/admin' || p.startsWith('/admin/');
|
||||
const isLogin = p === '/admin/login';
|
||||
|
||||
if (isAdminArea && !isLogin && !event.locals.admin) {
|
||||
throw redirect(303, '/admin/login');
|
||||
}
|
||||
if (isLogin && event.locals.admin) {
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
export type CopySlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
section: string;
|
||||
hint?: string;
|
||||
kind: 'inline' | 'multiline';
|
||||
};
|
||||
|
||||
export type ImageSlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export type Testimonial = {
|
||||
id: string;
|
||||
text: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatarSrc: string;
|
||||
avatarAlt: string;
|
||||
};
|
||||
|
||||
export type SiteContent = {
|
||||
copy: Record<string, string>;
|
||||
images: Record<string, ImageSlot>;
|
||||
testimonials: Testimonial[];
|
||||
seo: {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
ogImage: string;
|
||||
primaryKeywords: string[];
|
||||
geoRegion: string;
|
||||
geoPlacename: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOTS: CopySlot[] = [
|
||||
{ id: 'nav.brand', section: 'Navbar', label: 'Brand name', kind: 'inline', value: 'Giampy Dog Service' },
|
||||
{ id: 'nav.link.servizi', section: 'Navbar', label: 'Link — Servizi', kind: 'inline', value: 'Servizi' },
|
||||
{ id: 'nav.link.chi_sono', section: 'Navbar', label: 'Link — Chi Sono', kind: 'inline', value: 'Chi Sono' },
|
||||
{ id: 'nav.link.testimonianze', section: 'Navbar', label: 'Link — Testimonianze', kind: 'inline', value: 'Testimonianze' },
|
||||
{ id: 'nav.link.dogwash', section: 'Navbar', label: 'Link — DogWash', kind: 'inline', value: 'DogWash' },
|
||||
{ id: 'nav.cta', section: 'Navbar', label: 'CTA', kind: 'inline', value: 'Scrivimi' },
|
||||
|
||||
{ id: 'hero.kicker', section: 'Hero', label: 'Kicker', kind: 'inline', value: 'Con te dal 2018' },
|
||||
{ id: 'hero.title_prefix', section: 'Hero', label: 'Title — first line', kind: 'inline', value: 'Tratto il tuo cane' },
|
||||
{ id: 'hero.title_suffix', section: 'Hero', label: 'Title — second line (before emphasis)', kind: 'inline', value: 'come se fosse' },
|
||||
{ id: 'hero.title_em', section: 'Hero', label: 'Title — emphasis', kind: 'inline', value: 'il mio.' },
|
||||
{ id: 'hero.desc', section: 'Hero', label: 'Description', kind: 'multiline', value: 'Sono Giampiero, educatore cinofilo certificato CONI. Mi occupo personalmente di ogni cane che mi affidi — niente gabbie, niente solitudine, solo tanto amore vero.' },
|
||||
{ id: 'hero.cta_primary', section: 'Hero', label: 'Primary CTA', kind: 'inline', value: 'Scopri i Servizi' },
|
||||
{ id: 'hero.cta_outline', section: 'Hero', label: 'Outline CTA', kind: 'inline', value: 'Scrivimi ora' },
|
||||
{ id: 'hero.badge_label', section: 'Hero', label: 'Badge label', kind: 'inline', value: '5.0 Recensioni' },
|
||||
{ id: 'hero.badge_text', section: 'Hero', label: 'Badge text', kind: 'inline', value: 'Il punto di riferimento per i proprietari di cani a Sassari.' },
|
||||
{ id: 'hero.stat1_num', section: 'Hero', label: 'Stat 1 — value', kind: 'inline', value: '100+' },
|
||||
{ id: 'hero.stat1_label', section: 'Hero', label: 'Stat 1 — label', kind: 'inline', value: 'Clienti Felici' },
|
||||
{ id: 'hero.stat2_num', section: 'Hero', label: 'Stat 2 — value', kind: 'inline', value: '2018' },
|
||||
{ id: 'hero.stat2_label', section: 'Hero', label: 'Stat 2 — label', kind: 'inline', value: 'Anno di inizio' },
|
||||
{ id: 'hero.stat3_num', section: 'Hero', label: 'Stat 3 — value', kind: 'inline', value: '5.0★' },
|
||||
{ id: 'hero.stat3_label', section: 'Hero', label: 'Stat 3 — label', kind: 'inline', value: 'Recensioni' },
|
||||
|
||||
{ id: 'why.kicker', section: 'Why', label: 'Kicker', kind: 'inline', value: 'Perché scegliere me' },
|
||||
{ id: 'why.title_1', section: 'Why', label: 'Title — line 1', kind: 'inline', value: 'Qualcosa di diverso,' },
|
||||
{ id: 'why.title_2', section: 'Why', label: 'Title — line 2', kind: 'inline', value: 'lo senti anche tu.' },
|
||||
{ id: 'why.sub', section: 'Why', label: 'Subtitle', kind: 'inline', value: 'Non ho una squadra — ho me stesso, presente ogni giorno.' },
|
||||
{ id: 'why.card1_title', section: 'Why', label: 'Card 1 — title', kind: 'inline', value: 'Dal 2018, ogni giorno' },
|
||||
{ id: 'why.card1_text', section: 'Why', label: 'Card 1 — text', kind: 'multiline', value: 'Ho iniziato per passione e non mi sono più fermato. Anni di esperienza vera, sul campo, costruita cane dopo cane.' },
|
||||
{ id: 'why.card2_title', section: 'Why', label: 'Card 2 — title', kind: 'inline', value: 'Un rapporto vero' },
|
||||
{ id: 'why.card2_text', section: 'Why', label: 'Card 2 — text', kind: 'multiline', value: 'Con te e con il tuo cane costruisco un legame di fiducia. Non sei un cliente, sei parte della mia famiglia pelosa.' },
|
||||
{ id: 'why.card3_title', section: 'Why', label: 'Card 3 — title', kind: 'inline', value: 'Preparato, non improvvisato' },
|
||||
{ id: 'why.card3_text', section: 'Why', label: 'Card 3 — text', kind: 'multiline', value: 'Certificazione CONI e formazione continua. Porto metodo e competenza, con la leggerezza di chi ama davvero quello che fa.' },
|
||||
{ id: 'why.card4_title', section: 'Why', label: 'Card 4 — title', kind: 'inline', value: 'Zero gabbie, promesso' },
|
||||
{ id: 'why.card4_text', section: 'Why', label: 'Card 4 — text', kind: 'multiline', value: 'Non le uso, non le tollero. Libertà e comfort sono la base di tutto quello che faccio — sempre.' },
|
||||
|
||||
{ id: 'services.kicker', section: 'Services', label: 'Kicker', kind: 'inline', value: 'Cosa faccio per te' },
|
||||
{ id: 'services.title', section: 'Services', label: 'Title', kind: 'inline', value: 'I miei servizi' },
|
||||
{ id: 'services.sub', section: 'Services', label: 'Subtitle', kind: 'inline', value: 'Ho pensato a tutto io — tu pensa solo a coccolarlo.' },
|
||||
{ id: 'services.card1_tag', section: 'Services', label: 'Card 1 — tag', kind: 'inline', value: 'Movimento & Gioia' },
|
||||
{ id: 'services.card1_title', section: 'Services', label: 'Card 1 — title', kind: 'inline', value: 'Dogwalking' },
|
||||
{ id: 'services.card1_text', section: 'Services', label: 'Card 1 — text', kind: 'multiline', value: "Passeggiate esplorative nei sentieri più belli di Sassari. Ogni uscita è un'avventura pensata per stimolare mente e corpo del tuo cane." },
|
||||
{ id: 'services.card1_cta', section: 'Services', label: 'Card 1 — CTA', kind: 'inline', value: 'Prenota una passeggiata' },
|
||||
{ id: 'services.card2_tag', section: 'Services', label: 'Card 2 — tag', kind: 'inline', value: 'Come a casa' },
|
||||
{ id: 'services.card2_title', section: 'Services', label: 'Card 2 — title', kind: 'inline', value: 'Home Boarding' },
|
||||
{ id: 'services.card2_text', section: 'Services', label: 'Card 2 — text', kind: 'multiline', value: 'Quando sei via, il tuo cane sta con me — in appartamento, libero, coccolato. Ogni giorno ti mando foto e aggiornamenti.' },
|
||||
{ id: 'services.card2_cta', section: 'Services', label: 'Card 2 — CTA', kind: 'inline', value: 'Chiedi disponibilità' },
|
||||
{ id: 'services.card3_tag', section: 'Services', label: 'Card 3 — tag', kind: 'inline', value: 'Giorni speciali' },
|
||||
{ id: 'services.card3_title', section: 'Services', label: 'Card 3 — title', kind: 'inline', value: 'Wedding Dogsitter' },
|
||||
{ id: 'services.card3_text', section: 'Services', label: 'Card 3 — text', kind: 'multiline', value: 'Il tuo migliore amico nel tuo giorno più bello. Me ne occupo io, così tu puoi goderti ogni momento senza pensieri.' },
|
||||
{ id: 'services.card3_cta', section: 'Services', label: 'Card 3 — CTA', kind: 'inline', value: 'Parliamo del tuo evento' },
|
||||
|
||||
{ id: 'about.quote', section: 'About', label: 'Quote', kind: 'multiline', value: '"Rusty non era solo un cane, era il maestro che mi ha indicato la strada."' },
|
||||
{ id: 'about.cite', section: 'About', label: 'Cite', kind: 'inline', value: '— Giampiero Scaglione' },
|
||||
{ id: 'about.kicker', section: 'About', label: 'Kicker', kind: 'inline', value: 'Chi sono' },
|
||||
{ id: 'about.title_1', section: 'About', label: 'Title — line 1', kind: 'inline', value: 'Da una necessità' },
|
||||
{ id: 'about.title_2', section: 'About', label: 'Title — line 2', kind: 'inline', value: 'del cuore, a una' },
|
||||
{ id: 'about.title_3', section: 'About', label: 'Title — line 3', kind: 'inline', value: 'missione di vita.' },
|
||||
{ id: 'about.body_1', section: 'About', label: 'Body — paragraph 1', kind: 'multiline', value: 'Tutto è partito da Rusty — il mitico Rustone. Quello che era iniziato come un lavoretto si è trasformato in una vocazione. Ho capito che non volevo fare altro nella vita.' },
|
||||
{ id: 'about.body_2', section: 'About', label: 'Body — paragraph 2', kind: 'multiline', value: "Ho preso la certificazione CONI, ho camminato il Cammino di Santiago con il mio cane, e ogni giorno continuo a imparare — dai cani, dai loro umani, dalla vita all'aperto." },
|
||||
{ id: 'about.mile1_title', section: 'About', label: 'Milestone 1 — title', kind: 'inline', value: 'Cammino di Santiago' },
|
||||
{ id: 'about.mile1_text', section: 'About', label: 'Milestone 1 — text', kind: 'inline', value: 'Percorso a piedi con il mio cane. La prova più bella di tutto.' },
|
||||
{ id: 'about.mile2_title', section: 'About', label: 'Milestone 2 — title', kind: 'inline', value: 'Certificazione CONI' },
|
||||
{ id: 'about.mile2_text', section: 'About', label: 'Milestone 2 — text', kind: 'inline', value: "Formazione d'eccellenza in ambito cinofilo. Studio ancora." },
|
||||
|
||||
{ id: 'media.label', section: 'Media', label: 'Label', kind: 'inline', value: 'Riconoscimenti & Media' },
|
||||
{ id: 'media.logo_1', section: 'Media', label: 'Logo 1', kind: 'inline', value: "L'Unione Sarda" },
|
||||
{ id: 'media.logo_2', section: 'Media', label: 'Logo 2', kind: 'inline', value: 'Sardegna Reporter' },
|
||||
{ id: 'media.logo_3', section: 'Media', label: 'Logo 3', kind: 'inline', value: 'Radio Super Sound' },
|
||||
{ id: 'media.logo_4', section: 'Media', label: 'Logo 4', kind: 'inline', value: 'Qualifica CONI' },
|
||||
{ id: 'media.logo_5', section: 'Media', label: 'Logo 5', kind: 'inline', value: 'Corsa in Rosa' },
|
||||
{ id: 'media.logo_6', section: 'Media', label: 'Logo 6', kind: 'inline', value: 'Giornata del Super Cane' },
|
||||
|
||||
{ id: 'dogwash.tag', section: 'DogWash', label: 'Tag', kind: 'inline', value: 'Novità in Sardegna' },
|
||||
{ id: 'dogwash.title', section: 'DogWash', label: 'Title', kind: 'inline', value: 'DogWash Sardegna' },
|
||||
{ id: 'dogwash.desc', section: 'DogWash', label: 'Description', kind: 'multiline', value: 'Ho avuto un sogno: portare le colonnine di lavaggio automatico in tutta la Sardegna. Grazie alla fiducia della community, quel sogno sta diventando realtà, una colonnina alla volta.' },
|
||||
{ id: 'dogwash.cta', section: 'DogWash', label: 'CTA', kind: 'inline', value: 'Trova la postazione più vicina' },
|
||||
{ id: 'dogwash.community', section: 'DogWash', label: 'Community note', kind: 'inline', value: 'Un progetto nato dal cuore di Sassari.' },
|
||||
|
||||
{ id: 'testi.stats1_num', section: 'Testimonials', label: 'Stat 1 — value', kind: 'inline', value: '100+' },
|
||||
{ id: 'testi.stats1_label', section: 'Testimonials', label: 'Stat 1 — label', kind: 'inline', value: 'Clienti Felici' },
|
||||
{ id: 'testi.stats2_num', section: 'Testimonials', label: 'Stat 2 — value', kind: 'inline', value: '2018' },
|
||||
{ id: 'testi.stats2_label', section: 'Testimonials', label: 'Stat 2 — label', kind: 'inline', value: 'Anno di inizio' },
|
||||
{ id: 'testi.kicker', section: 'Testimonials', label: 'Kicker', kind: 'inline', value: 'Cosa dicono di me' },
|
||||
{ id: 'testi.title', section: 'Testimonials', label: 'Title', kind: 'inline', value: 'I miei ospiti parlano.' },
|
||||
{ id: 'testi.sub', section: 'Testimonials', label: 'Subtitle', kind: 'inline', value: 'Non solo clienti — una vera famiglia pelosa.' },
|
||||
|
||||
{ id: 'contact.headline', section: 'Contact', label: 'Headline', kind: 'inline', value: 'Affida il tuo cane a chi lo vive davvero.' },
|
||||
{ id: 'contact.form_title', section: 'Contact', label: 'Form — title', kind: 'inline', value: 'Scrivimi ora' },
|
||||
{ id: 'contact.form_sub', section: 'Contact', label: 'Form — subtitle', kind: 'inline', value: 'Hai domande o vuoi prenotare? Ti rispondo di persona entro 24 ore.' },
|
||||
{ id: 'contact.label_name', section: 'Contact', label: 'Label — Name', kind: 'inline', value: 'Il tuo Nome' },
|
||||
{ id: 'contact.placeholder_name', section: 'Contact', label: 'Placeholder — Name', kind: 'inline', value: 'Nome e Cognome' },
|
||||
{ id: 'contact.label_dog', section: 'Contact', label: 'Label — Dog', kind: 'inline', value: 'Come si chiama il tuo cane?' },
|
||||
{ id: 'contact.placeholder_dog', section: 'Contact', label: 'Placeholder — Dog', kind: 'inline', value: 'Il nome del peloso...' },
|
||||
{ id: 'contact.label_msg', section: 'Contact', label: 'Label — Message', kind: 'inline', value: 'Il tuo messaggio' },
|
||||
{ id: 'contact.placeholder_msg', section: 'Contact', label: 'Placeholder — Message', kind: 'inline', value: 'Raccontami di te e del tuo cane...' },
|
||||
{ id: 'contact.submit', section: 'Contact', label: 'Submit button', kind: 'inline', value: 'Invia il messaggio' },
|
||||
{ id: 'contact.success', section: 'Contact', label: 'Success message', kind: 'inline', value: 'Perfetto! Ti scrivo presto.' },
|
||||
{ id: 'contact.info_title', section: 'Contact', label: 'Info title', kind: 'inline', value: 'Contatti rapidi' },
|
||||
{ id: 'contact.phone_label', section: 'Contact', label: 'Phone label', kind: 'inline', value: 'Chiamami' },
|
||||
{ id: 'contact.phone_value', section: 'Contact', label: 'Phone value', kind: 'inline', value: '+39 347 123 4567' },
|
||||
{ id: 'contact.email_label', section: 'Contact', label: 'Email label', kind: 'inline', value: 'Email' },
|
||||
{ id: 'contact.email_value', section: 'Contact', label: 'Email value', kind: 'inline', value: 'info@giampydogservice.it' },
|
||||
{ id: 'contact.wa_label', section: 'Contact', label: 'WhatsApp label', kind: 'inline', value: 'WhatsApp' },
|
||||
{ id: 'contact.wa_value', section: 'Contact', label: 'WhatsApp value', kind: 'inline', value: 'Chatta direttamente con me' },
|
||||
|
||||
{ id: 'footer.tagline', section: 'Footer', label: 'Tagline', kind: 'multiline', value: 'Mi prendo cura dei tuoi amici come fossero i miei. Dal 2018, ogni giorno, con passione.' },
|
||||
{ id: 'footer.col1_title', section: 'Footer', label: 'Column 1 — title', kind: 'inline', value: 'Servizi' },
|
||||
{ id: 'footer.col1_l1', section: 'Footer', label: 'Column 1 — link 1', kind: 'inline', value: 'Dogwalking' },
|
||||
{ id: 'footer.col1_l2', section: 'Footer', label: 'Column 1 — link 2', kind: 'inline', value: 'Home Boarding' },
|
||||
{ id: 'footer.col1_l3', section: 'Footer', label: 'Column 1 — link 3', kind: 'inline', value: 'Wedding Dogsitter' },
|
||||
{ id: 'footer.col1_l4', section: 'Footer', label: 'Column 1 — link 4', kind: 'inline', value: 'DogWash' },
|
||||
{ id: 'footer.col2_title', section: 'Footer', label: 'Column 2 — title', kind: 'inline', value: 'Link Utili' },
|
||||
{ id: 'footer.col2_l1', section: 'Footer', label: 'Column 2 — link 1', kind: 'inline', value: 'Chi Sono' },
|
||||
{ id: 'footer.col2_l2', section: 'Footer', label: 'Column 2 — link 2', kind: 'inline', value: 'Testimonianze' },
|
||||
{ id: 'footer.col2_l3', section: 'Footer', label: 'Column 2 — link 3', kind: 'inline', value: 'Privacy Policy' },
|
||||
{ id: 'footer.col2_l4', section: 'Footer', label: 'Column 2 — link 4', kind: 'inline', value: 'Termini e Condizioni' },
|
||||
{ id: 'footer.copyright', section: 'Footer', label: 'Copyright', kind: 'inline', value: '© 2024 Giampy Dog Service — Sassari, Sardegna' },
|
||||
{ id: 'footer.powered', section: 'Footer', label: 'Powered by', kind: 'inline', value: 'Powered by' }
|
||||
];
|
||||
|
||||
export const DEFAULT_IMAGES: Record<string, ImageSlot> = {
|
||||
favicon: { id: 'favicon', label: 'Favicon (icona browser)', src: '/img/logo.png', alt: 'Giampy Dog Service' },
|
||||
logo: { id: 'logo', label: 'Brand logo', src: '/img/logo.png', alt: 'Giampy Dog Service Logo' },
|
||||
hero: { id: 'hero', label: 'Hero photo', src: '/img/hero-image.png', alt: 'Giampy con un cane' },
|
||||
chi_sono: { id: 'chi_sono', label: 'About photo', src: '/img/chi-sono.png', alt: 'Giampy passeggia con un cane' },
|
||||
dogwash: { id: 'dogwash', label: 'DogWash visual', src: '/img/colonnina.png', alt: 'DogWash Machine' },
|
||||
cima_logo: { id: 'cima_logo', label: 'Cima Progetti logo', src: '/img/cima-logo.svg', alt: 'Cima Progetti' }
|
||||
};
|
||||
|
||||
export const DEFAULT_TESTIMONIALS: Testimonial[] = [
|
||||
{
|
||||
id: 't_marta',
|
||||
text: "Giampiero è l'unico di cui mi fido ciecamente. Il mio Max torna sempre felice e rilassato. Non cambierei mai!",
|
||||
name: 'Marta S.',
|
||||
role: 'Mamma di Max',
|
||||
avatarSrc: '/img/avatar-1.png',
|
||||
avatarAlt: 'Marta S.'
|
||||
},
|
||||
{
|
||||
id: 't_luca',
|
||||
text: 'Ha gestito il nostro Luna durante il matrimonio in modo impeccabile. Eravamo tranquilli, sapevamo che era in mani fantastiche.',
|
||||
name: 'Luca & Elena',
|
||||
role: 'Genitori di Luna',
|
||||
avatarSrc: '/img/avatar-2.png',
|
||||
avatarAlt: 'Luca & Elena'
|
||||
},
|
||||
{
|
||||
id: 't_sara',
|
||||
text: 'Niente gabbie, tanto amore. Ogni giorno mi mandava foto di Felix felice. La pensione in appartamento che sognavo!',
|
||||
name: 'Sara P.',
|
||||
role: 'Compagna di Felix',
|
||||
avatarSrc: '/img/avatar-3.png',
|
||||
avatarAlt: 'Sara P.'
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SEO = {
|
||||
title: 'Giampy Dog Service — Il tuo cane in buone mani',
|
||||
description:
|
||||
'Educatore cinofilo certificato CONI a Sassari. Dogwalking, home boarding e wedding dogsitter. Zero gabbie, solo amore vero dal 2018.',
|
||||
keywords:
|
||||
'dogsitter sassari, dogwalker sardegna, educatore cinofilo, home boarding cani, wedding dogsitter, dog wash sardegna',
|
||||
ogImage: '/img/hero-image.png',
|
||||
primaryKeywords: ['dogsitter sassari', 'dogwalker sardegna', 'educatore cinofilo coni'],
|
||||
geoRegion: 'IT-SS',
|
||||
geoPlacename: 'Sassari, Sardegna'
|
||||
};
|
||||
|
||||
export function buildDefaultContent(): SiteContent {
|
||||
const copy: Record<string, string> = {};
|
||||
for (const s of DEFAULT_SLOTS) copy[s.id] = s.value;
|
||||
return {
|
||||
copy,
|
||||
images: { ...DEFAULT_IMAGES },
|
||||
testimonials: DEFAULT_TESTIMONIALS.map((t) => ({ ...t })),
|
||||
seo: { ...DEFAULT_SEO }
|
||||
};
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
export type CopyAnalysis = {
|
||||
length: number;
|
||||
words: number;
|
||||
sentences: number;
|
||||
avgWordsPerSentence: number;
|
||||
readability: number; // 0..100, higher = easier
|
||||
keywordMatches: string[];
|
||||
missingKeywords: string[];
|
||||
seoScore: number; // 0..100
|
||||
geoScore: number; // 0..100
|
||||
seoTips: string[];
|
||||
geoTips: string[];
|
||||
};
|
||||
|
||||
const SYLLABLE_VOWELS = /[aeiouàèéìòùAEIOUÀÈÉÌÒÙ]+/g;
|
||||
|
||||
function countSyllables(word: string): number {
|
||||
const matches = word.match(SYLLABLE_VOWELS);
|
||||
return matches ? matches.length : 1;
|
||||
}
|
||||
|
||||
export function analyzeCopy(text: string, primaryKeywords: string[] = []): CopyAnalysis {
|
||||
const clean = text.trim();
|
||||
const length = clean.length;
|
||||
const wordList = clean.split(/\s+/).filter(Boolean);
|
||||
const words = wordList.length;
|
||||
const sentences = Math.max(1, (clean.match(/[.!?]+/g) ?? []).length);
|
||||
const avgWordsPerSentence = words / sentences;
|
||||
|
||||
// Flesch reading ease approximation
|
||||
const syllables = wordList.reduce((n, w) => n + countSyllables(w), 0);
|
||||
const asl = words ? words / sentences : 0;
|
||||
const asw = words ? syllables / words : 0;
|
||||
const readability = Math.max(0, Math.min(100, Math.round(206.835 - 1.015 * asl - 84.6 * asw)));
|
||||
|
||||
const lower = clean.toLowerCase();
|
||||
const keywordMatches: string[] = [];
|
||||
const missingKeywords: string[] = [];
|
||||
for (const kw of primaryKeywords) {
|
||||
if (!kw) continue;
|
||||
if (lower.includes(kw.toLowerCase())) keywordMatches.push(kw);
|
||||
else missingKeywords.push(kw);
|
||||
}
|
||||
|
||||
// SEO tips
|
||||
const seoTips: string[] = [];
|
||||
if (length === 0) seoTips.push('Il testo è vuoto.');
|
||||
if (length > 0 && length < 40) seoTips.push('Testo molto breve: difficile farsi indicizzare.');
|
||||
if (length > 320) seoTips.push('Testo lungo: valuta di spezzarlo in paragrafi o bullet.');
|
||||
if (avgWordsPerSentence > 24) seoTips.push('Frasi troppo lunghe: scendi sotto le 24 parole per frase.');
|
||||
if (readability < 45) seoTips.push('Leggibilità bassa: semplifica lessico e sintassi.');
|
||||
if (missingKeywords.length && words > 4) {
|
||||
seoTips.push(`Manca una keyword primaria: ${missingKeywords.slice(0, 2).join(', ')}.`);
|
||||
}
|
||||
if (/[A-Z]{6,}/.test(clean)) seoTips.push('Evita testo tutto maiuscolo: penalizza la leggibilità.');
|
||||
|
||||
// GEO tips (Generative Engine Optimization)
|
||||
const geoTips: string[] = [];
|
||||
const questionMarks = (clean.match(/\?/g) ?? []).length;
|
||||
const hasNumbers = /\d/.test(clean);
|
||||
const hasEntities = /\b(sassari|sardegna|coni|cammino di santiago|giampy)\b/i.test(clean);
|
||||
const firstPerson = /\b(io|mio|mia|miei|mie|sono|faccio|porto|prendo)\b/i.test(clean);
|
||||
const declarative = sentences >= 1 && /[.!]/.test(clean);
|
||||
|
||||
if (words >= 20 && questionMarks === 0)
|
||||
geoTips.push('Aggiungi una domanda esplicita per farti citare come risposta diretta.');
|
||||
if (!hasNumbers && words >= 20)
|
||||
geoTips.push('Inserisci dati concreti (anni, numero clienti, distanze) per aumentare la citabilità.');
|
||||
if (!hasEntities && words >= 15)
|
||||
geoTips.push('Nomina entità specifiche (luogo, certificazione, brand) per il grounding generativo.');
|
||||
if (!firstPerson && words >= 20)
|
||||
geoTips.push('Usa la prima persona: i motori generativi estraggono più facilmente testimonianze autoriali.');
|
||||
if (!declarative && words >= 10)
|
||||
geoTips.push('Termina con punto fermo: le frasi complete vengono citate più spesso.');
|
||||
|
||||
// Scores
|
||||
let seoScore = 100;
|
||||
seoScore -= seoTips.length * 12;
|
||||
if (keywordMatches.length === 0 && primaryKeywords.length && words > 4) seoScore -= 10;
|
||||
if (readability >= 60) seoScore += 5;
|
||||
seoScore = Math.max(0, Math.min(100, seoScore));
|
||||
|
||||
let geoScore = 100;
|
||||
geoScore -= geoTips.length * 14;
|
||||
if (hasEntities) geoScore += 5;
|
||||
if (hasNumbers) geoScore += 3;
|
||||
geoScore = Math.max(0, Math.min(100, geoScore));
|
||||
|
||||
return {
|
||||
length,
|
||||
words,
|
||||
sentences,
|
||||
avgWordsPerSentence: Math.round(avgWordsPerSentence * 10) / 10,
|
||||
readability,
|
||||
keywordMatches,
|
||||
missingKeywords,
|
||||
seoScore,
|
||||
geoScore,
|
||||
seoTips,
|
||||
geoTips
|
||||
};
|
||||
}
|
||||
|
||||
export function aggregateScore(analyses: CopyAnalysis[]): { seo: number; geo: number } {
|
||||
if (!analyses.length) return { seo: 0, geo: 0 };
|
||||
const seo = Math.round(analyses.reduce((a, x) => a + x.seoScore, 0) / analyses.length);
|
||||
const geo = Math.round(analyses.reduce((a, x) => a + x.geoScore, 0) / analyses.length);
|
||||
return { seo, geo };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
const ADMIN_FILE = 'admin.json';
|
||||
const SESSION_FILE = 'sessions.json';
|
||||
const SESSION_COOKIE = 'gds_admin';
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
type AdminRecord = {
|
||||
username: string;
|
||||
salt: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
token: string;
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function hashPassword(password: string, salt: string): string {
|
||||
return scryptSync(password, salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
export function seedDefaultAdminIfMissing(): { username: string; tempPassword?: string } {
|
||||
const existing = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (existing) return { username: existing.username };
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'changeme';
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const record: AdminRecord = { username, salt, hash: hashPassword(password, salt) };
|
||||
writeJson(ADMIN_FILE, record);
|
||||
return { username, tempPassword: password };
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (record.username !== username) return false;
|
||||
const candidate = Buffer.from(hashPassword(password, record.salt), 'hex');
|
||||
const stored = Buffer.from(record.hash, 'hex');
|
||||
if (candidate.length !== stored.length) return false;
|
||||
return timingSafeEqual(candidate, stored);
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (!verifyCredentials(record.username, currentPassword)) return false;
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
writeJson(ADMIN_FILE, {
|
||||
username: record.username,
|
||||
salt,
|
||||
hash: hashPassword(newPassword, salt)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadSessions(): SessionRecord[] {
|
||||
const now = Date.now();
|
||||
const all = readJson<SessionRecord[]>(SESSION_FILE, []);
|
||||
const live = all.filter((s) => s.expiresAt > now);
|
||||
if (live.length !== all.length) writeJson(SESSION_FILE, live);
|
||||
return live;
|
||||
}
|
||||
|
||||
export function createSession(username: string, cookies: Cookies): string {
|
||||
const sessions = loadSessions();
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const record: SessionRecord = { token, username, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
sessions.push(record);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
cookies.set(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_TTL_MS / 1000
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export function destroySession(cookies: Cookies): void {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (token) {
|
||||
const sessions = loadSessions().filter((s) => s.token !== token);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
}
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function getSessionUser(cookies: Cookies): { username: string } | null {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (!token) return null;
|
||||
const session = loadSessions().find((s) => s.token === token);
|
||||
if (!session) return null;
|
||||
return { username: session.username };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
buildDefaultContent,
|
||||
DEFAULT_IMAGES,
|
||||
DEFAULT_SLOTS,
|
||||
type SiteContent,
|
||||
type ImageSlot,
|
||||
type Testimonial
|
||||
} from '$lib/content/defaults';
|
||||
|
||||
const FILE = 'content.json';
|
||||
|
||||
export function getContent(): SiteContent {
|
||||
const seed = buildDefaultContent();
|
||||
const stored = readJson<Partial<SiteContent>>(FILE, seed);
|
||||
return {
|
||||
copy: { ...seed.copy, ...(stored.copy ?? {}) },
|
||||
images: { ...seed.images, ...(stored.images ?? {}) },
|
||||
testimonials: stored.testimonials ?? seed.testimonials,
|
||||
seo: { ...seed.seo, ...(stored.seo ?? {}) }
|
||||
};
|
||||
}
|
||||
|
||||
export function setCopy(id: string, value: string): void {
|
||||
const c = getContent();
|
||||
c.copy[id] = value;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setCopyBulk(updates: Record<string, string>): void {
|
||||
const c = getContent();
|
||||
for (const [k, v] of Object.entries(updates)) c.copy[k] = v;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setImage(id: string, patch: Partial<ImageSlot>): ImageSlot {
|
||||
const c = getContent();
|
||||
const current = c.images[id] ?? DEFAULT_IMAGES[id];
|
||||
if (!current) throw new Error(`Unknown image slot: ${id}`);
|
||||
const next: ImageSlot = {
|
||||
id,
|
||||
label: current.label,
|
||||
src: patch.src ?? current.src,
|
||||
alt: patch.alt ?? current.alt
|
||||
};
|
||||
c.images[id] = next;
|
||||
writeJson(FILE, c);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetImage(id: string): ImageSlot {
|
||||
const c = getContent();
|
||||
const fallback = DEFAULT_IMAGES[id];
|
||||
if (!fallback) throw new Error(`Unknown image slot: ${id}`);
|
||||
c.images[id] = { ...fallback };
|
||||
writeJson(FILE, c);
|
||||
return c.images[id];
|
||||
}
|
||||
|
||||
export function setSeo(patch: Partial<SiteContent['seo']>): void {
|
||||
const c = getContent();
|
||||
c.seo = { ...c.seo, ...patch };
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function listSlots() {
|
||||
return DEFAULT_SLOTS;
|
||||
}
|
||||
|
||||
export function addTestimonial(input: Omit<Testimonial, 'id'>): Testimonial {
|
||||
const c = getContent();
|
||||
const entry: Testimonial = { id: `t_${randomUUID().slice(0, 8)}`, ...input };
|
||||
c.testimonials = [...c.testimonials, entry];
|
||||
writeJson(FILE, c);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function updateTestimonial(id: string, patch: Partial<Omit<Testimonial, 'id'>>): Testimonial | null {
|
||||
const c = getContent();
|
||||
let updated: Testimonial | null = null;
|
||||
c.testimonials = c.testimonials.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
updated = { ...t, ...patch };
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeJson(FILE, c);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteTestimonial(id: string): void {
|
||||
const c = getContent();
|
||||
c.testimonials = c.testimonials.filter((t) => t.id !== id);
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { analyzeCopy, aggregateScore } from '$lib/seo';
|
||||
import { getContent } from './content';
|
||||
|
||||
export function computeHealth(): { seo: number; geo: number } {
|
||||
const content = getContent();
|
||||
const analyses = DEFAULT_SLOTS
|
||||
.filter((s) => s.kind === 'multiline' || s.id.endsWith('_text'))
|
||||
.slice(0, 30)
|
||||
.map((s) => analyzeCopy(content.copy[s.id] ?? s.value, content.seo.primaryKeywords));
|
||||
return aggregateScore(analyses);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type NotificationType = 'submission' | 'seo_drop' | 'geo_drop';
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
const FILE = 'notifications.json';
|
||||
const MAX_KEEP = 200;
|
||||
|
||||
export function listNotifications(): Notification[] {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
return [...all].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function unreadCount(): number {
|
||||
return readJson<Notification[]>(FILE, []).filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
export function addNotification(input: Omit<Notification, 'id' | 'createdAt' | 'read'>): Notification {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
const entry: Notification = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
...input
|
||||
};
|
||||
all.push(entry);
|
||||
// Keep only the most recent MAX_KEEP
|
||||
const sorted = all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, MAX_KEEP);
|
||||
writeJson(FILE, sorted);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function markRead(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||||
}
|
||||
|
||||
export function markAllRead(): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => ({ ...n, read: true })));
|
||||
}
|
||||
|
||||
export function deleteNotification(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.filter((n) => n.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DATA_DIR = join(process.cwd(), 'data');
|
||||
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
export function dataPath(name: string): string {
|
||||
return join(DATA_DIR, name);
|
||||
}
|
||||
|
||||
export function readJson<T>(name: string, fallback: T): T {
|
||||
const p = dataPath(name);
|
||||
if (!existsSync(p)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson<T>(name: string, value: T): void {
|
||||
const p = dataPath(name);
|
||||
const dir = dirname(p);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8');
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type Submission = {
|
||||
id: string;
|
||||
name: string;
|
||||
dog: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
const FILE = 'submissions.json';
|
||||
|
||||
export function listSubmissions(): Submission[] {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
return [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function addSubmission(input: Omit<Submission, 'id' | 'createdAt'>): Submission {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
const entry: Submission = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...input
|
||||
};
|
||||
items.push(entry);
|
||||
writeJson(FILE, items);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function deleteSubmission(id: string): void {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
writeJson(FILE, items.filter((s) => s.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getContent } from '$lib/server/content';
|
||||
|
||||
export const load: LayoutServerLoad = () => {
|
||||
const images = getContent().images;
|
||||
return { favicon: images.favicon?.src ?? '/img/logo.png' };
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import type { LayoutData } from './$types';
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={data.favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { getContent } from '$lib/server/content';
|
||||
import { addSubmission } from '$lib/server/submissions';
|
||||
import { addNotification } from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { content: getContent() };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
contact: async ({ request, getClientAddress }) => {
|
||||
const data = await request.formData();
|
||||
const name = String(data.get('name') ?? '').trim();
|
||||
const dog = String(data.get('dog') ?? '').trim();
|
||||
const message = String(data.get('message') ?? '').trim();
|
||||
|
||||
if (!name || !message) {
|
||||
return fail(400, { error: 'Compila almeno nome e messaggio.', name, dog, message });
|
||||
}
|
||||
|
||||
const entry = addSubmission({ name, dog, message, ip: getClientAddress() });
|
||||
addNotification({
|
||||
type: 'submission',
|
||||
title: 'Nuova richiesta dal form',
|
||||
message: `${name}${dog ? ` (cane: ${dog})` : ''} ha inviato un messaggio.`,
|
||||
link: `/admin/submissions#${entry.id}`
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import '../site.css';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
const c = $derived(data.content.copy);
|
||||
const img = $derived(data.content.images);
|
||||
const seo = $derived(data.content.seo);
|
||||
const testimonials = $derived(data.content.testimonials);
|
||||
|
||||
let scrolled = $state(false);
|
||||
let submitted = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) submitted = true;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const onScroll = () => {
|
||||
scrolled = window.scrollY > 20;
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
|
||||
let cleanup = () => window.removeEventListener('scroll', onScroll);
|
||||
|
||||
// Leaflet — load on demand, client-only
|
||||
(async () => {
|
||||
const container = document.getElementById('map-container');
|
||||
if (!container) return;
|
||||
const leafletCss = document.createElement('link');
|
||||
leafletCss.rel = 'stylesheet';
|
||||
leafletCss.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(leafletCss);
|
||||
const L = (await import('leaflet')).default;
|
||||
const sassari: [number, number] = [40.7259, 8.5557];
|
||||
const map = L.map('map-container', {
|
||||
center: sassari,
|
||||
zoom: 13,
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: false
|
||||
});
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="background:#001d36;width:14px;height:14px;border-radius:50%;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.35);"></div>',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7]
|
||||
});
|
||||
L.marker(sassari, { icon })
|
||||
.addTo(map)
|
||||
.bindTooltip('Giampy Dog Service — Sassari', {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -10],
|
||||
className: 'map-marker-label'
|
||||
})
|
||||
.openTooltip();
|
||||
})();
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{seo.title}</title>
|
||||
<meta name="description" content={seo.description} />
|
||||
<meta name="keywords" content={seo.keywords} />
|
||||
<meta name="geo.region" content={seo.geoRegion} />
|
||||
<meta name="geo.placename" content={seo.geoPlacename} />
|
||||
<meta property="og:title" content={seo.title} />
|
||||
<meta property="og:description" content={seo.description} />
|
||||
<meta property="og:image" content={seo.ogImage} />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="gds-nav" class:scrolled>
|
||||
<div class="nav-inner">
|
||||
<div class="nav-brand">
|
||||
<img alt={img.logo.alt} src={img.logo.src} />
|
||||
<span>{c['nav.brand']}</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#servizi">{c['nav.link.servizi']}</a>
|
||||
<a href="#chi-sono">{c['nav.link.chi_sono']}</a>
|
||||
<a href="#testimonianze">{c['nav.link.testimonianze']}</a>
|
||||
<a href="#dogwash">{c['nav.link.dogwash']}</a>
|
||||
<a href="#contatti" class="nav-cta">{c['nav.cta']}</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<header class="hero">
|
||||
<svg class="hero-paw hero-paw-1" aria-hidden="true" viewBox="0 0 11.667 11.083" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 1.458 5.25 C 1.05 5.25 0.705 5.109 0.423 4.827 C 0.141 4.545 0 4.2 0 3.792 C 0 3.383 0.141 3.038 0.423 2.756 C 0.705 2.474 1.05 2.333 1.458 2.333 C 1.867 2.333 2.212 2.474 2.494 2.756 C 2.776 3.038 2.917 3.383 2.917 3.792 C 2.917 4.2 2.776 4.545 2.494 4.827 C 2.212 5.109 1.867 5.25 1.458 5.25 Z M 4.083 2.917 C 3.675 2.917 3.33 2.776 3.048 2.494 C 2.766 2.212 2.625 1.867 2.625 1.458 C 2.625 1.05 2.766 0.705 3.048 0.423 C 3.33 0.141 3.675 0 4.083 0 C 4.492 0 4.837 0.141 5.119 0.423 C 5.401 0.705 5.542 1.05 5.542 1.458 C 5.542 1.867 5.401 2.212 5.119 2.494 C 4.837 2.776 4.492 2.917 4.083 2.917 Z M 7.583 2.917 C 7.175 2.917 6.83 2.776 6.548 2.494 C 6.266 2.212 6.125 1.867 6.125 1.458 C 6.125 1.05 6.266 0.705 6.548 0.423 C 6.83 0.141 7.175 0 7.583 0 C 7.992 0 8.337 0.141 8.619 0.423 C 8.901 0.705 9.042 1.05 9.042 1.458 C 9.042 1.867 8.901 2.212 8.619 2.494 C 8.337 2.776 7.992 2.917 7.583 2.917 Z M 10.208 5.25 C 9.8 5.25 9.455 5.109 9.173 4.827 C 8.891 4.545 8.75 4.2 8.75 3.792 C 8.75 3.383 8.891 3.038 9.173 2.756 C 9.455 2.474 9.8 2.333 10.208 2.333 C 10.617 2.333 10.962 2.474 11.244 2.756 C 11.526 3.038 11.667 3.383 11.667 3.792 C 11.667 4.2 11.526 4.545 11.244 4.827 C 10.962 5.109 10.617 5.25 10.208 5.25 Z M 2.713 11.083 C 2.275 11.083 1.908 10.916 1.611 10.58 C 1.315 10.245 1.167 9.849 1.167 9.392 C 1.167 8.886 1.339 8.444 1.684 8.065 C 2.03 7.685 2.372 7.311 2.713 6.942 C 2.994 6.64 3.237 6.312 3.442 5.957 C 3.646 5.602 3.889 5.269 4.171 4.958 C 4.385 4.706 4.633 4.497 4.915 4.331 C 5.197 4.166 5.503 4.083 5.833 4.083 C 6.164 4.083 6.47 4.161 6.752 4.317 C 7.034 4.472 7.282 4.676 7.496 4.929 C 7.768 5.24 8.009 5.576 8.218 5.935 C 8.427 6.295 8.672 6.631 8.954 6.942 C 9.294 7.311 9.637 7.685 9.982 8.065 C 10.327 8.444 10.5 8.886 10.5 9.392 C 10.5 9.849 10.352 10.245 10.055 10.58 C 9.759 10.916 9.392 11.083 8.954 11.083 C 8.429 11.083 7.909 11.04 7.394 10.952 C 6.878 10.865 6.358 10.821 5.833 10.821 C 5.308 10.821 4.788 10.865 4.273 10.952 C 3.758 11.04 3.238 11.083 2.713 11.083 Z" fill="currentColor" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
<svg class="hero-paw hero-paw-2" aria-hidden="true" viewBox="0 0 11.667 11.083" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 1.458 5.25 C 1.05 5.25 0.705 5.109 0.423 4.827 C 0.141 4.545 0 4.2 0 3.792 C 0 3.383 0.141 3.038 0.423 2.756 C 0.705 2.474 1.05 2.333 1.458 2.333 C 1.867 2.333 2.212 2.474 2.494 2.756 C 2.776 3.038 2.917 3.383 2.917 3.792 C 2.917 4.2 2.776 4.545 2.494 4.827 C 2.212 5.109 1.867 5.25 1.458 5.25 Z M 4.083 2.917 C 3.675 2.917 3.33 2.776 3.048 2.494 C 2.766 2.212 2.625 1.867 2.625 1.458 C 2.625 1.05 2.766 0.705 3.048 0.423 C 3.33 0.141 3.675 0 4.083 0 C 4.492 0 4.837 0.141 5.119 0.423 C 5.401 0.705 5.542 1.05 5.542 1.458 C 5.542 1.867 5.401 2.212 5.119 2.494 C 4.837 2.776 4.492 2.917 4.083 2.917 Z M 7.583 2.917 C 7.175 2.917 6.83 2.776 6.548 2.494 C 6.266 2.212 6.125 1.867 6.125 1.458 C 6.125 1.05 6.266 0.705 6.548 0.423 C 6.83 0.141 7.175 0 7.583 0 C 7.992 0 8.337 0.141 8.619 0.423 C 8.901 0.705 9.042 1.05 9.042 1.458 C 9.042 1.867 8.901 2.212 8.619 2.494 C 8.337 2.776 7.992 2.917 7.583 2.917 Z M 10.208 5.25 C 9.8 5.25 9.455 5.109 9.173 4.827 C 8.891 4.545 8.75 4.2 8.75 3.792 C 8.75 3.383 8.891 3.038 9.173 2.756 C 9.455 2.474 9.8 2.333 10.208 2.333 C 10.617 2.333 10.962 2.474 11.244 2.756 C 11.526 3.038 11.667 3.383 11.667 3.792 C 11.667 4.2 11.526 4.545 11.244 4.827 C 10.962 5.109 10.617 5.25 10.208 5.25 Z M 2.713 11.083 C 2.275 11.083 1.908 10.916 1.611 10.58 C 1.315 10.245 1.167 9.849 1.167 9.392 C 1.167 8.886 1.339 8.444 1.684 8.065 C 2.03 7.685 2.372 7.311 2.713 6.942 C 2.994 6.64 3.237 6.312 3.442 5.957 C 3.646 5.602 3.889 5.269 4.171 4.958 C 4.385 4.706 4.633 4.497 4.915 4.331 C 5.197 4.166 5.503 4.083 5.833 4.083 C 6.164 4.083 6.47 4.161 6.752 4.317 C 7.034 4.472 7.282 4.676 7.496 4.929 C 7.768 5.24 8.009 5.576 8.218 5.935 C 8.427 6.295 8.672 6.631 8.954 6.942 C 9.294 7.311 9.637 7.685 9.982 8.065 C 10.327 8.444 10.5 8.886 10.5 9.392 C 10.5 9.849 10.352 10.245 10.055 10.58 C 9.759 10.916 9.392 11.083 8.954 11.083 C 8.429 11.083 7.909 11.04 7.394 10.952 C 6.878 10.865 6.358 10.821 5.833 10.821 C 5.308 10.821 4.788 10.865 4.273 10.952 C 3.758 11.04 3.238 11.083 2.713 11.083 Z" fill="currentColor" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
<div class="container">
|
||||
<div class="hero-inner">
|
||||
<div>
|
||||
<p class="hero-kicker">{c['hero.kicker']}</p>
|
||||
<h1>{c['hero.title_prefix']}<br />{c['hero.title_suffix']} <em>{c['hero.title_em']}</em></h1>
|
||||
<p class="hero-desc">{c['hero.desc']}</p>
|
||||
<div class="hero-ctas">
|
||||
<a href="#servizi" class="btn-primary">
|
||||
{c['hero.cta_primary']}
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</a>
|
||||
<a href="#contatti" class="btn-outline">{c['hero.cta_outline']}</a>
|
||||
</div>
|
||||
<div class="hero-stat-strip">
|
||||
<div class="hero-stat"><strong>{c['hero.stat1_num']}</strong><span>{c['hero.stat1_label']}</span></div>
|
||||
<div class="hero-stat"><strong>{c['hero.stat2_num']}</strong><span>{c['hero.stat2_label']}</span></div>
|
||||
<div class="hero-stat"><strong>{c['hero.stat3_num']}</strong><span>{c['hero.stat3_label']}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-photo">
|
||||
<div class="hero-photo-frame">
|
||||
<img alt={img.hero.alt} src={img.hero.src} />
|
||||
</div>
|
||||
<div class="hero-badge">
|
||||
<div class="hero-badge-stars">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">star</span>
|
||||
<strong>{c['hero.badge_label']}</strong>
|
||||
</div>
|
||||
<p>{c['hero.badge_text']}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Why Section -->
|
||||
<section class="why-section">
|
||||
<div class="container">
|
||||
<p class="section-kicker">{c['why.kicker']}</p>
|
||||
<h2 class="section-head">{c['why.title_1']}<br />{c['why.title_2']}</h2>
|
||||
<p class="section-sub">{c['why.sub']}</p>
|
||||
<div class="why-grid">
|
||||
{#each [
|
||||
{ icon: 'history', t: c['why.card1_title'], p: c['why.card1_text'] },
|
||||
{ icon: 'diversity_1', t: c['why.card2_title'], p: c['why.card2_text'] },
|
||||
{ icon: 'verified_user', t: c['why.card3_title'], p: c['why.card3_text'] },
|
||||
{ icon: 'house_siding', t: c['why.card4_title'], p: c['why.card4_text'] }
|
||||
] as card}
|
||||
<div class="why-card">
|
||||
<div class="why-icon"><span class="material-symbols-outlined" aria-hidden="true">{card.icon}</span></div>
|
||||
<div>
|
||||
<h3>{card.t}</h3>
|
||||
<p>{card.p}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services -->
|
||||
<section class="services-section" id="servizi">
|
||||
<div class="container">
|
||||
<p class="section-kicker">{c['services.kicker']}</p>
|
||||
<h2 class="section-head">{c['services.title']}</h2>
|
||||
<p class="section-sub">{c['services.sub']}</p>
|
||||
<div class="service-cards">
|
||||
{#each [
|
||||
{ icon: 'pets', tag: c['services.card1_tag'], t: c['services.card1_title'], p: c['services.card1_text'], cta: c['services.card1_cta'] },
|
||||
{ icon: 'house', tag: c['services.card2_tag'], t: c['services.card2_title'], p: c['services.card2_text'], cta: c['services.card2_cta'] },
|
||||
{ icon: 'celebration', tag: c['services.card3_tag'], t: c['services.card3_title'], p: c['services.card3_text'], cta: c['services.card3_cta'] }
|
||||
] as s}
|
||||
<div class="service-card">
|
||||
<span class="service-tag">{s.tag}</span>
|
||||
<span class="material-symbols-outlined service-icon" aria-hidden="true">{s.icon}</span>
|
||||
<h3>{s.t}</h3>
|
||||
<p>{s.p}</p>
|
||||
<a href="#contatti" class="service-link">
|
||||
{s.cta}
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="about-section" id="chi-sono">
|
||||
<div class="container">
|
||||
<div class="about-inner">
|
||||
<div class="about-photo-wrap">
|
||||
<img alt={img.chi_sono.alt} src={img.chi_sono.src} />
|
||||
<div class="about-quote-card">
|
||||
<p>{c['about.quote']}</p>
|
||||
<cite>{c['about.cite']}</cite>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-kicker">{c['about.kicker']}</p>
|
||||
<h2 class="section-head">{c['about.title_1']}<br />{c['about.title_2']}<br />{c['about.title_3']}</h2>
|
||||
<p class="about-body">{c['about.body_1']}</p>
|
||||
<p class="about-body">{c['about.body_2']}</p>
|
||||
<div class="about-milestones">
|
||||
<div class="milestone">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">explore</span>
|
||||
<div>
|
||||
<strong>{c['about.mile1_title']}</strong>
|
||||
<small>{c['about.mile1_text']}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="milestone">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">verified</span>
|
||||
<div>
|
||||
<strong>{c['about.mile2_title']}</strong>
|
||||
<small>{c['about.mile2_text']}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media Strip -->
|
||||
<section class="media-strip">
|
||||
<div class="container">
|
||||
<p class="media-label">{c['media.label']}</p>
|
||||
<div class="media-logos">
|
||||
<span>{c['media.logo_1']}</span>
|
||||
<span>{c['media.logo_2']}</span>
|
||||
<span>{c['media.logo_3']}</span>
|
||||
<span>{c['media.logo_4']}</span>
|
||||
<span>{c['media.logo_5']}</span>
|
||||
<span>{c['media.logo_6']}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DogWash -->
|
||||
<section class="dogwash-section" id="dogwash">
|
||||
<div class="container">
|
||||
<div class="dogwash-card">
|
||||
<div class="dogwash-content">
|
||||
<span class="dogwash-tag">{c['dogwash.tag']}</span>
|
||||
<h2>{c['dogwash.title']}</h2>
|
||||
<p>{c['dogwash.desc']}</p>
|
||||
<a href="#contatti" class="btn-white">
|
||||
{c['dogwash.cta']}
|
||||
<span class="material-symbols-outlined" aria-hidden="true">location_on</span>
|
||||
</a>
|
||||
<p class="dogwash-community">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">group</span>{c['dogwash.community']}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dogwash-visual">
|
||||
<img alt={img.dogwash.alt} src={img.dogwash.src} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<section class="testimonials-section" id="testimonianze">
|
||||
<div class="container">
|
||||
<div class="testimonials-header">
|
||||
<div class="testimonials-stats">
|
||||
<div><p class="stat-num">{c['testi.stats1_num']}</p><p class="stat-label">{c['testi.stats1_label']}</p></div>
|
||||
<div><p class="stat-num">{c['testi.stats2_num']}</p><p class="stat-label">{c['testi.stats2_label']}</p></div>
|
||||
</div>
|
||||
<p class="section-kicker center">{c['testi.kicker']}</p>
|
||||
<h2 class="section-head center">{c['testi.title']}</h2>
|
||||
<p class="section-sub center">{c['testi.sub']}</p>
|
||||
</div>
|
||||
<div class="review-grid">
|
||||
{#each testimonials as r (r.id)}
|
||||
<div class="review-card">
|
||||
<div class="review-avatar"><img alt={r.avatarAlt} src={r.avatarSrc} /></div>
|
||||
<blockquote>{r.text}</blockquote>
|
||||
<div class="review-footer"><strong>{r.name}</strong><small>{r.role}</small></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact -->
|
||||
<section class="contact-section" id="contatti">
|
||||
<div class="container">
|
||||
<div class="contact-headline">
|
||||
<h2>{c['contact.headline']}</h2>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">keyboard_double_arrow_down</span>
|
||||
</div>
|
||||
<div class="contact-box">
|
||||
<div class="contact-form-side">
|
||||
<h3>{c['contact.form_title']}</h3>
|
||||
<p>{c['contact.form_sub']}</p>
|
||||
{#if !submitted}
|
||||
<form method="POST" action="?/contact" use:enhance>
|
||||
<div class="form-group">
|
||||
<label for="cf-name">{c['contact.label_name']}</label>
|
||||
<input id="cf-name" name="name" type="text" placeholder={c['contact.placeholder_name']} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf-dog">{c['contact.label_dog']}</label>
|
||||
<input id="cf-dog" name="dog" type="text" placeholder={c['contact.placeholder_dog']} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf-msg">{c['contact.label_msg']}</label>
|
||||
<textarea id="cf-msg" name="message" placeholder={c['contact.placeholder_msg']} required></textarea>
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<p style="color:#b03030;font-size:13px;margin-bottom:12px;">{form.error}</p>
|
||||
{/if}
|
||||
<button type="submit" class="btn-submit">{c['contact.submit']}</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="success-box">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">check_circle</span>
|
||||
<p>{c['contact.success']}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="contact-info-side">
|
||||
<h3>{c['contact.info_title']}</h3>
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon"><span class="material-symbols-outlined" aria-hidden="true">call</span></div>
|
||||
<div>
|
||||
<p class="contact-item-label">{c['contact.phone_label']}</p>
|
||||
<p class="contact-item-value">{c['contact.phone_value']}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon"><span class="material-symbols-outlined" aria-hidden="true">mail</span></div>
|
||||
<div>
|
||||
<p class="contact-item-label">{c['contact.email_label']}</p>
|
||||
<p class="contact-item-value">{c['contact.email_value']}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon"><span class="material-symbols-outlined" aria-hidden="true">forum</span></div>
|
||||
<div>
|
||||
<p class="contact-item-label">{c['contact.wa_label']}</p>
|
||||
<p class="contact-item-value">{c['contact.wa_value']}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-map"><div id="map-container"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-main">
|
||||
<div class="footer-brand">
|
||||
<div class="footer-brand-row">
|
||||
<img alt={img.logo.alt} src={img.logo.src} />
|
||||
<span class="footer-brand-name">{c['nav.brand']}</span>
|
||||
</div>
|
||||
<p>{c['footer.tagline']}</p>
|
||||
<div class="footer-socials">
|
||||
<a href="#" class="footer-social-btn" aria-label="Instagram"><span class="material-symbols-outlined" aria-hidden="true">camera_alt</span></a>
|
||||
<a href="#" class="footer-social-btn" aria-label="Condividi"><span class="material-symbols-outlined" aria-hidden="true">share</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>{c['footer.col1_title']}</h4>
|
||||
<a href="#servizi">{c['footer.col1_l1']}</a>
|
||||
<a href="#servizi">{c['footer.col1_l2']}</a>
|
||||
<a href="#servizi">{c['footer.col1_l3']}</a>
|
||||
<a href="#dogwash">{c['footer.col1_l4']}</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>{c['footer.col2_title']}</h4>
|
||||
<a href="#chi-sono">{c['footer.col2_l1']}</a>
|
||||
<a href="#testimonianze">{c['footer.col2_l2']}</a>
|
||||
<a href="#">{c['footer.col2_l3']}</a>
|
||||
<a href="#">{c['footer.col2_l4']}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>{c['footer.copyright']}</p>
|
||||
<div class="powered-by">
|
||||
<span>{c['footer.powered']}</span>
|
||||
<img src={img.cima_logo.src} alt={img.cima_logo.alt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { listSubmissions } from '$lib/server/submissions';
|
||||
import { unreadCount } from '$lib/server/notifications';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||
const submissionsCount = locals.admin ? listSubmissions().length : 0;
|
||||
const unreadNotifications = locals.admin ? unreadCount() : 0;
|
||||
return {
|
||||
admin: locals.admin,
|
||||
pathname: url.pathname,
|
||||
submissionsCount,
|
||||
unreadNotifications
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import '../../admin.css';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
|
||||
const isLogin = $derived(data.pathname === '/admin/login');
|
||||
const admin = $derived(data.admin);
|
||||
|
||||
const NAV = [
|
||||
{ href: '/admin', icon: 'space_dashboard', label: 'Dashboard' },
|
||||
{ href: '/admin/copy', icon: 'edit_note', label: 'Testi & SEO/GEO' },
|
||||
{ href: '/admin/images', icon: 'image', label: 'Immagini' },
|
||||
{ href: '/admin/testimonials', icon: 'reviews', label: 'Testimonianze' },
|
||||
{ href: '/admin/submissions', icon: 'inbox', label: 'Richieste' },
|
||||
{ href: '/admin/notifications', icon: 'notifications', label: 'Notifiche' },
|
||||
{ href: '/admin/warranty', icon: 'verified_user', label: 'CiMa Warranty' }
|
||||
];
|
||||
|
||||
function active(href: string): boolean {
|
||||
if (href === '/admin') return data.pathname === '/admin';
|
||||
return data.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin">
|
||||
{#if isLogin || !admin}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-side">
|
||||
<div class="admin-brand">
|
||||
<img src="/img/logo.png" alt="Giampy Dog Service" />
|
||||
<div>
|
||||
<strong>Giampy Dog Service</strong>
|
||||
<small>Admin Console</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="admin-nav">
|
||||
{#each NAV as item}
|
||||
<a href={item.href} class:active={active(item.href)}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
{#if item.href === '/admin/submissions' && data.submissionsCount > 0}
|
||||
<span class="count" style="margin-left:auto;background:rgba(255,255,255,0.08);color:#fff;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700;">{data.submissionsCount}</span>
|
||||
{/if}
|
||||
{#if item.href === '/admin/notifications' && data.unreadNotifications > 0}
|
||||
<span class="count" style="margin-left:auto;background:#d64545;color:#fff;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700;">{data.unreadNotifications}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="admin-foot">
|
||||
<div class="admin-user">
|
||||
<strong>{admin.username}</strong>
|
||||
<span>Admin signed in</span>
|
||||
</div>
|
||||
<form method="POST" action="/admin/logout">
|
||||
<button type="submit" class="admin-logout">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">logout</span>
|
||||
Esci
|
||||
</button>
|
||||
</form>
|
||||
<a href="/" target="_blank" style="color:rgba(255,255,255,0.5);font-size:11px;padding:10px 12px;display:inline-flex;gap:8px;text-decoration:none;">
|
||||
<span class="material-symbols-outlined" aria-hidden="true" style="font-size:14px;">open_in_new</span>
|
||||
Apri landing page
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="admin-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getContent } from '$lib/server/content';
|
||||
import { listSubmissions } from '$lib/server/submissions';
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { aggregateScore, analyzeCopy } from '$lib/seo';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const content = getContent();
|
||||
const subs = listSubmissions();
|
||||
|
||||
const analyses = DEFAULT_SLOTS
|
||||
.filter((s) => s.kind === 'multiline' || (s.id.endsWith('_text') && s.kind !== 'inline'))
|
||||
.slice(0, 30)
|
||||
.map((s) => analyzeCopy(content.copy[s.id] ?? s.value, content.seo.primaryKeywords));
|
||||
|
||||
const scores = aggregateScore(analyses);
|
||||
|
||||
return {
|
||||
totalSlots: DEFAULT_SLOTS.length,
|
||||
totalImages: Object.keys(content.images).length,
|
||||
totalSubmissions: subs.length,
|
||||
recentSubmissions: subs.slice(0, 5),
|
||||
scores,
|
||||
seo: content.seo
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function scoreClass(prefix: 'seo' | 'geo', n: number) {
|
||||
if (n >= 75) return `${prefix}-good`;
|
||||
if (n >= 50) return `${prefix}-warn`;
|
||||
return `${prefix}-bad`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Dashboard — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Dashboard</p>
|
||||
<h1>Panoramica</h1>
|
||||
<p>Contenuti, richieste e salute SEO/GEO della landing page in un colpo d'occhio.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid-4">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Copy slot</div>
|
||||
<div class="stat-value">{data.totalSlots}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Immagini</div>
|
||||
<div class="stat-value">{data.totalImages}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Richieste totali</div>
|
||||
<div class="stat-value">{data.totalSubmissions}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Regione GEO</div>
|
||||
<div class="stat-value" style="font-size:22px;">{data.seo.geoRegion}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="margin-top:18px;">
|
||||
<div class="card">
|
||||
<h2>Salute SEO</h2>
|
||||
<p>Media dei punteggi SEO sui testi principali: lunghezza, leggibilità, keyword primarie.</p>
|
||||
<div class="score-row">
|
||||
<span class="score-chip {scoreClass('seo', data.scores.seo)}">SEO {data.scores.seo}/100</span>
|
||||
<a class="btn btn-ghost btn-sm" href="/admin/copy">Modifica testi</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Salute GEO</h2>
|
||||
<p>Media dei punteggi GEO (Generative Engine Optimization): citabilità, entità, dati concreti.</p>
|
||||
<div class="score-row">
|
||||
<span class="score-chip {scoreClass('geo', data.scores.geo)}">GEO {data.scores.geo}/100</span>
|
||||
<a class="btn btn-ghost btn-sm" href="/admin/copy">Analizza e migliora</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:18px;">
|
||||
<h2>Ultime richieste</h2>
|
||||
{#if data.recentSubmissions.length === 0}
|
||||
<p>Nessuna richiesta ricevuta al momento.</p>
|
||||
{:else}
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
|
||||
{#each data.recentSubmissions as s}
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);">
|
||||
<div>
|
||||
<strong style="color:var(--accent-dark);">{s.name}</strong>
|
||||
<span style="color:var(--accent);font-size:11px;margin-left:8px;text-transform:uppercase;letter-spacing:0.1em;">{s.dog || '—'}</span>
|
||||
<p style="color:var(--text-muted);font-size:13px;margin-top:4px;">{s.message.slice(0, 140)}{s.message.length > 140 ? '…' : ''}</p>
|
||||
</div>
|
||||
<time style="color:var(--text-muted);font-size:11px;letter-spacing:0.06em;">{new Date(s.createdAt).toLocaleDateString('it-IT')}</time>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<a class="btn btn-ghost btn-sm" href="/admin/submissions">Vedi tutte</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { getContent, setCopyBulk, setSeo } from '$lib/server/content';
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { computeHealth } from '$lib/server/health';
|
||||
import { addNotification } from '$lib/server/notifications';
|
||||
|
||||
const DROP_THRESHOLD = 8;
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const content = getContent();
|
||||
return {
|
||||
slots: DEFAULT_SLOTS,
|
||||
copy: content.copy,
|
||||
seo: content.seo
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveCopy: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const updates: Record<string, string> = {};
|
||||
const knownIds = new Set(DEFAULT_SLOTS.map((s) => s.id));
|
||||
for (const [key, val] of form.entries()) {
|
||||
if (!key.startsWith('slot:')) continue;
|
||||
const id = key.slice(5);
|
||||
if (!knownIds.has(id)) continue;
|
||||
updates[id] = String(val);
|
||||
}
|
||||
if (!Object.keys(updates).length) return fail(400, { error: 'Nessuna modifica da salvare.' });
|
||||
|
||||
const before = computeHealth();
|
||||
setCopyBulk(updates);
|
||||
const after = computeHealth();
|
||||
|
||||
if (before.seo - after.seo >= DROP_THRESHOLD) {
|
||||
addNotification({
|
||||
type: 'seo_drop',
|
||||
title: 'Punteggio SEO in calo',
|
||||
message: `SEO passato da ${before.seo}/100 a ${after.seo}/100 dopo l'ultima modifica.`,
|
||||
link: '/admin/copy'
|
||||
});
|
||||
}
|
||||
if (before.geo - after.geo >= DROP_THRESHOLD) {
|
||||
addNotification({
|
||||
type: 'geo_drop',
|
||||
title: 'Punteggio GEO in calo',
|
||||
message: `GEO passato da ${before.geo}/100 a ${after.geo}/100 dopo l'ultima modifica.`,
|
||||
link: '/admin/copy'
|
||||
});
|
||||
}
|
||||
return { success: true, count: Object.keys(updates).length };
|
||||
},
|
||||
saveSeo: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const keywords = String(form.get('keywords') ?? '').trim();
|
||||
const ogImage = String(form.get('ogImage') ?? '').trim();
|
||||
const primary = String(form.get('primaryKeywords') ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const geoRegion = String(form.get('geoRegion') ?? '').trim();
|
||||
const geoPlacename = String(form.get('geoPlacename') ?? '').trim();
|
||||
|
||||
const before = computeHealth();
|
||||
setSeo({
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
ogImage,
|
||||
primaryKeywords: primary,
|
||||
geoRegion,
|
||||
geoPlacename
|
||||
});
|
||||
const after = computeHealth();
|
||||
|
||||
if (before.seo - after.seo >= DROP_THRESHOLD) {
|
||||
addNotification({
|
||||
type: 'seo_drop',
|
||||
title: 'Punteggio SEO in calo',
|
||||
message: `Cambiate le keyword primarie: SEO ${before.seo} → ${after.seo}.`,
|
||||
link: '/admin/copy'
|
||||
});
|
||||
}
|
||||
return { seoSaved: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { analyzeCopy } from '$lib/seo';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let values = $state<Record<string, string>>(untrack(() => ({ ...data.copy })));
|
||||
let primaryKw = $state(untrack(() => data.seo.primaryKeywords.join(', ')));
|
||||
let iframeKey = $state(0);
|
||||
let showInfo = $state(false);
|
||||
|
||||
function reloadPreview() {
|
||||
iframeKey += 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success || form?.seoSaved) reloadPreview();
|
||||
});
|
||||
|
||||
const keywordList = $derived(
|
||||
primaryKw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const sections = $derived.by(() => {
|
||||
const bySection = new Map<string, typeof data.slots>();
|
||||
for (const slot of data.slots) {
|
||||
const list = bySection.get(slot.section) ?? [];
|
||||
list.push(slot);
|
||||
bySection.set(slot.section, list);
|
||||
}
|
||||
return [...bySection.entries()];
|
||||
});
|
||||
|
||||
function scoreClass(prefix: 'seo' | 'geo', n: number) {
|
||||
if (n >= 75) return `${prefix}-good`;
|
||||
if (n >= 50) return `${prefix}-warn`;
|
||||
return `${prefix}-bad`;
|
||||
}
|
||||
|
||||
function analysisOf(id: string) {
|
||||
return analyzeCopy(values[id] ?? '', keywordList);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Testi & SEO/GEO — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Contenuti</p>
|
||||
<h1>Testi, SEO & GEO</h1>
|
||||
<p>
|
||||
Modifica ogni copy della landing page. Per ciascun blocco ottieni un punteggio SEO (ottimizzazione motori di ricerca) e GEO (ottimizzazione per motori generativi) in tempo reale con suggerimenti mirati.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="info-box">
|
||||
<button type="button" class="info-toggle" onclick={() => (showInfo = !showInfo)}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">help</span>
|
||||
<strong>Cosa sono SEO e GEO? Come leggere i voti?</strong>
|
||||
<span class="material-symbols-outlined chev" aria-hidden="true">{showInfo ? 'expand_less' : 'expand_more'}</span>
|
||||
</button>
|
||||
{#if showInfo}
|
||||
<div class="info-body">
|
||||
<div class="info-col">
|
||||
<h3>SEO — essere trovati su Google</h3>
|
||||
<p>
|
||||
SEO vuol dire "farsi trovare dai motori di ricerca tipo Google". Quando qualcuno cerca
|
||||
<em>"dogsitter Sassari"</em>, i testi del tuo sito aiutano Google a capire se mostrarti.
|
||||
Un testo SEO buono è chiaro, contiene le parole giuste, non è né troppo corto né troppo lungo.
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<h3>GEO — essere citati dalle AI</h3>
|
||||
<p>
|
||||
GEO (Generative Engine Optimization) è la versione moderna: serve a farsi citare da
|
||||
ChatGPT, Gemini o Perplexity quando qualcuno chiede loro consiglio.
|
||||
Funziona quando scrivi in prima persona, citi luoghi e numeri concreti, e rispondi a
|
||||
domande precise.
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<h3>Come leggere i voti</h3>
|
||||
<ul>
|
||||
<li><span class="dot green"></span><strong>75–100 verde</strong> — il testo funziona bene.</li>
|
||||
<li><span class="dot amber"></span><strong>50–74 giallo</strong> — va migliorato ma non è grave.</li>
|
||||
<li><span class="dot red"></span><strong>0–49 rosso</strong> — qualcosa non va, leggi i consigli sotto al testo.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="preview-pane">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Anteprima live della landing</strong>
|
||||
<small>Si aggiorna da sola ad ogni salvataggio. Per un aggiornamento manuale, usa il pulsante.</small>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={reloadPreview}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">refresh</span>
|
||||
Aggiorna
|
||||
</button>
|
||||
<a href="/" target="_blank" class="btn btn-ghost btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_new</span>
|
||||
Apri in una nuova scheda
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{#key iframeKey}
|
||||
<iframe title="Anteprima landing page" src="/?v={iframeKey}"></iframe>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">Salvato — {form.count} modifiche applicate. Anteprima aggiornata.</div>
|
||||
{/if}
|
||||
{#if form?.seoSaved}
|
||||
<div class="alert-row">Meta SEO & GEO aggiornati.</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<h2>Meta pagina · SEO & GEO</h2>
|
||||
<p>Impostazioni di indicizzazione globali: titolo, description, keyword primarie, segnali geografici.</p>
|
||||
<form method="POST" action="?/saveSeo" use:enhance style="margin-top:14px;">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="title">Title tag</label>
|
||||
<input id="title" name="title" type="text" value={data.seo.title} />
|
||||
<small>Consigliato 50–60 caratteri.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ogImage">Open Graph image (path)</label>
|
||||
<input id="ogImage" name="ogImage" type="text" value={data.seo.ogImage} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="description">Meta description</label>
|
||||
<textarea id="description" name="description">{data.seo.description}</textarea>
|
||||
<small>Consigliato 120–160 caratteri.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="keywords">Meta keywords</label>
|
||||
<input id="keywords" name="keywords" type="text" value={data.seo.keywords} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="primaryKeywords">Keyword primarie (analizzate sul copy)</label>
|
||||
<input id="primaryKeywords" name="primaryKeywords" type="text" bind:value={primaryKw} />
|
||||
<small>Separate da virgola. Usate per controllare la presenza in ogni copy.</small>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="geoRegion">Geo region (ISO 3166-2)</label>
|
||||
<input id="geoRegion" name="geoRegion" type="text" value={data.seo.geoRegion} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="geoPlacename">Geo placename</label>
|
||||
<input id="geoPlacename" name="geoPlacename" type="text" value={data.seo.geoPlacename} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
Salva meta
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="?/saveCopy" use:enhance>
|
||||
{#each sections as [sectionName, slots]}
|
||||
<div class="section-divider">
|
||||
<h2>{sectionName}</h2>
|
||||
<span class="count">{slots.length}</span>
|
||||
</div>
|
||||
<div class="slot-grid">
|
||||
{#each slots as slot}
|
||||
{@const a = analysisOf(slot.id)}
|
||||
<div class="slot-card">
|
||||
<header>
|
||||
<div>
|
||||
<div class="slot-label">{slot.label}</div>
|
||||
<div class="slot-id">{slot.id}</div>
|
||||
</div>
|
||||
<div class="score-row">
|
||||
<span class="score-chip {scoreClass('seo', a.seoScore)}">SEO {a.seoScore}</span>
|
||||
<span class="score-chip {scoreClass('geo', a.geoScore)}">GEO {a.geoScore}</span>
|
||||
</div>
|
||||
</header>
|
||||
{#if slot.kind === 'multiline'}
|
||||
<textarea name="slot:{slot.id}" bind:value={values[slot.id]} rows="3"></textarea>
|
||||
{:else}
|
||||
<input name="slot:{slot.id}" type="text" bind:value={values[slot.id]} />
|
||||
{/if}
|
||||
<small style="display:flex;gap:14px;color:var(--text-muted);font-size:11px;margin-top:6px;flex-wrap:wrap;">
|
||||
<span>{a.length} car.</span>
|
||||
<span>{a.words} parole</span>
|
||||
<span>Leggibilità {a.readability}</span>
|
||||
{#if a.keywordMatches.length}<span>Keyword match: {a.keywordMatches.join(', ')}</span>{/if}
|
||||
</small>
|
||||
{#if a.seoTips.length || a.geoTips.length}
|
||||
<ul class="tip-list">
|
||||
{#each a.seoTips as tip}<li><strong style="color:var(--accent-dark);">SEO:</strong> {tip}</li>{/each}
|
||||
{#each a.geoTips as tip}<li><strong style="color:var(--accent);">GEO:</strong> {tip}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div style="position:sticky;bottom:0;padding:14px 0;background:linear-gradient(to top, var(--panel-muted) 60%, rgba(251,249,244,0));margin-top:24px;">
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
Salva tutte le modifiche
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.info-box {
|
||||
background: rgba(0, 29, 54, 0.035);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.info-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--accent-dark);
|
||||
text-align: left;
|
||||
}
|
||||
.info-toggle .chev { margin-left: auto; }
|
||||
.info-toggle:hover { background: rgba(0, 29, 54, 0.05); }
|
||||
.info-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
padding: 6px 18px 18px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.info-col h3 { font-size: 13px; color: var(--accent-dark); margin-bottom: 6px; font-family: 'Manrope', sans-serif; font-weight: 700; }
|
||||
.info-col p { font-size: 13px; color: var(--text); line-height: 1.6; margin: 0; }
|
||||
.info-col ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.info-col li { font-size: 13px; color: var(--text); line-height: 1.5; display: flex; align-items: center; gap: 8px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.green { background: var(--ok); }
|
||||
.dot.amber { background: var(--warn); }
|
||||
.dot.red { background: var(--danger); }
|
||||
|
||||
.preview-pane {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-pane header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--panel-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.preview-pane header strong { color: var(--accent-dark); font-size: 14px; display: block; }
|
||||
.preview-pane header small { color: var(--text-muted); font-size: 12px; display: block; margin-top: 2px; }
|
||||
.preview-pane iframe {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.info-body { grid-template-columns: 1fr; }
|
||||
.preview-pane iframe { height: 400px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getContent, resetImage, setImage } from '$lib/server/content';
|
||||
import { DEFAULT_IMAGES } from '$lib/content/defaults';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'static', 'img', 'uploads');
|
||||
const MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml']);
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const content = getContent();
|
||||
return { images: content.images };
|
||||
};
|
||||
|
||||
function extFromType(type: string, fallbackName: string): string {
|
||||
if (type === 'image/png') return 'png';
|
||||
if (type === 'image/jpeg') return 'jpg';
|
||||
if (type === 'image/webp') return 'webp';
|
||||
if (type === 'image/gif') return 'gif';
|
||||
if (type === 'image/svg+xml') return 'svg';
|
||||
const m = /\.([a-zA-Z0-9]+)$/.exec(fallbackName);
|
||||
return m ? m[1].toLowerCase() : 'bin';
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ request }) => {
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const alt = String(form.get('alt') ?? '').trim();
|
||||
const file = form.get('file');
|
||||
|
||||
if (!slot || !(slot in DEFAULT_IMAGES)) return fail(400, { error: 'Slot immagine non valido.' });
|
||||
if (!(file instanceof File) || !file.size) {
|
||||
// alt-only update
|
||||
if (alt) {
|
||||
setImage(slot, { alt });
|
||||
return { success: true, slot, altOnly: true };
|
||||
}
|
||||
return fail(400, { error: 'Nessun file caricato.' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) return fail(400, { error: 'File troppo grande (max 8 MB).' });
|
||||
if (file.type && !ALLOWED.has(file.type)) return fail(400, { error: `Tipo file non supportato: ${file.type}.` });
|
||||
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const ext = extFromType(file.type, file.name);
|
||||
const filename = `${slot}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
const fullPath = join(UPLOAD_DIR, filename);
|
||||
writeFileSync(fullPath, buf);
|
||||
|
||||
const publicPath = `/img/uploads/${filename}`;
|
||||
const next = setImage(slot, { src: publicPath, alt: alt || undefined });
|
||||
return { success: true, slot, src: next.src };
|
||||
},
|
||||
|
||||
reset: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slot || !(slot in DEFAULT_IMAGES)) return fail(400, { error: 'Slot non valido.' });
|
||||
|
||||
const current = getContent().images[slot];
|
||||
if (current?.src?.startsWith('/img/uploads/')) {
|
||||
const fullPath = join(process.cwd(), 'static', current.src);
|
||||
try {
|
||||
if (existsSync(fullPath)) unlinkSync(fullPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
resetImage(slot);
|
||||
return { success: true, slot, reset: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let resizeMap = $state<Record<string, number>>({});
|
||||
let previewMap = $state<Record<string, string>>({});
|
||||
let processing = $state<Record<string, boolean>>({});
|
||||
|
||||
async function resizeAndSet(slot: string, inputEl: HTMLInputElement) {
|
||||
const file = inputEl.files?.[0];
|
||||
if (!file) return;
|
||||
const max = resizeMap[slot] ?? 0;
|
||||
if (!max || file.type === 'image/svg+xml') {
|
||||
previewMap[slot] = URL.createObjectURL(file);
|
||||
return;
|
||||
}
|
||||
processing[slot] = true;
|
||||
try {
|
||||
const resized = await resizeImageFile(file, max);
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(resized);
|
||||
inputEl.files = dt.files;
|
||||
previewMap[slot] = URL.createObjectURL(resized);
|
||||
} finally {
|
||||
processing[slot] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeImageFile(file: File, maxDim: number): Promise<File> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
|
||||
const w = Math.round(bitmap.width * ratio);
|
||||
const h = Math.round(bitmap.height * ratio);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||
const quality = type === 'image/jpeg' ? 0.88 : undefined;
|
||||
const blob: Blob = await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b as Blob), type, quality)
|
||||
);
|
||||
const ext = type === 'image/png' ? 'png' : 'jpg';
|
||||
return new File([blob], `${file.name.replace(/\.[^.]+$/, '')}.${ext}`, { type });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Immagini — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Media</p>
|
||||
<h1>Immagini</h1>
|
||||
<p>Sostituisci, ridimensiona o ripristina ogni immagine presente sulla landing page. Il ridimensionamento avviene in browser prima dell'upload, così riduci banda e peso.</p>
|
||||
</header>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">
|
||||
{form.altOnly ? 'Alt text aggiornato' : form.reset ? 'Immagine ripristinata al default' : 'Immagine sostituita con successo'} — slot <code>{form.slot}</code>.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="image-grid">
|
||||
{#each Object.values(data.images) as img}
|
||||
<div class="image-card">
|
||||
<header>
|
||||
<div>
|
||||
<div class="slot-label">{img.label}</div>
|
||||
<div class="slot-id">{img.id}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="preview">
|
||||
<img src={previewMap[img.id] ?? img.src} alt={img.alt} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
previewMap[img.id] = '';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="slot" value={img.id} />
|
||||
<div class="field">
|
||||
<label for="alt-{img.id}">Alt text</label>
|
||||
<input id="alt-{img.id}" name="alt" type="text" value={img.alt} />
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="file-{img.id}">Sostituisci file</label>
|
||||
<input
|
||||
id="file-{img.id}"
|
||||
name="file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
onchange={(e) => resizeAndSet(img.id, e.currentTarget as HTMLInputElement)}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resize-{img.id}">Max lato (px)</label>
|
||||
<select id="resize-{img.id}" bind:value={resizeMap[img.id]}>
|
||||
<option value={0}>Originale</option>
|
||||
<option value={480}>480 px</option>
|
||||
<option value={800}>800 px</option>
|
||||
<option value={1200}>1200 px</option>
|
||||
<option value={1920}>1920 px</option>
|
||||
</select>
|
||||
<small>Applicato al prossimo file scelto.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn" disabled={processing[img.id]}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">upload</span>
|
||||
{processing[img.id] ? 'Ridimensiono…' : 'Salva'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/reset" use:enhance style="margin-top:8px;">
|
||||
<input type="hidden" name="slot" value={img.id} />
|
||||
<button type="submit" class="btn btn-ghost btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">restore</span>
|
||||
Ripristina default
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { createSession, verifyCredentials } from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const username = String(form.get('username') ?? '').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (!username || !password) {
|
||||
return fail(400, { error: 'Compila username e password.', username });
|
||||
}
|
||||
if (!verifyCredentials(username, password)) {
|
||||
return fail(401, { error: 'Credenziali non valide.', username });
|
||||
}
|
||||
createSession(username, cookies);
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData } from './$types';
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Accedi — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="login-shell">
|
||||
<div class="login-card">
|
||||
<h1>Admin Console</h1>
|
||||
<p>Accedi per gestire il contenuto della landing page.</p>
|
||||
<form method="POST">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" value={form?.username ?? ''} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">login</span>
|
||||
Accedi
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { destroySession } from '$lib/server/auth';
|
||||
|
||||
export const POST: RequestHandler = ({ cookies }) => {
|
||||
destroySession(cookies);
|
||||
throw redirect(303, '/admin/login');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import {
|
||||
deleteNotification,
|
||||
listNotifications,
|
||||
markAllRead,
|
||||
markRead
|
||||
} from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { notifications: listNotifications() };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
markRead: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
markRead(id);
|
||||
return { success: true };
|
||||
},
|
||||
markAllRead: async () => {
|
||||
markAllRead();
|
||||
return { success: true, allRead: true };
|
||||
},
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
deleteNotification(id);
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
function fmt(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function icon(type: string) {
|
||||
if (type === 'submission') return 'inbox';
|
||||
if (type === 'seo_drop') return 'trending_down';
|
||||
if (type === 'geo_drop') return 'trending_down';
|
||||
return 'notifications';
|
||||
}
|
||||
|
||||
function color(type: string) {
|
||||
if (type === 'submission') return 'var(--ok)';
|
||||
return 'var(--danger)';
|
||||
}
|
||||
|
||||
const hasUnread = $derived(data.notifications.some((n) => !n.read));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Notifiche — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Avvisi</p>
|
||||
<h1>Notifiche</h1>
|
||||
<p>Segnalazioni in tempo reale: nuove richieste dal form, cali di punteggio SEO o GEO dopo una modifica ai testi.</p>
|
||||
</header>
|
||||
|
||||
{#if form?.allRead}
|
||||
<div class="alert-row">Tutte le notifiche segnate come lette.</div>
|
||||
{:else if form?.success}
|
||||
<div class="alert-row">Notifica aggiornata.</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-bottom:14px;">
|
||||
{#if hasUnread}
|
||||
<form method="POST" action="?/markAllRead" use:enhance>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">done_all</span>
|
||||
Segna tutte come lette
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.notifications.length === 0}
|
||||
<div class="card">
|
||||
<h2>Nessuna notifica</h2>
|
||||
<p>Quando arriva una richiesta dal form o un punteggio SEO/GEO cala, comparirà qui.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notif-list">
|
||||
{#each data.notifications as n (n.id)}
|
||||
<div class="notif-row" class:unread={!n.read}>
|
||||
<div class="notif-icon" style="color:{color(n.type)};">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{icon(n.type)}</span>
|
||||
</div>
|
||||
<div class="notif-body">
|
||||
<header>
|
||||
<strong>{n.title}</strong>
|
||||
<time>{fmt(n.createdAt)}</time>
|
||||
</header>
|
||||
<p>{n.message}</p>
|
||||
{#if n.link}
|
||||
<a href={n.link} class="notif-link">
|
||||
Apri
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
{#if !n.read}
|
||||
<form method="POST" action="?/markRead" use:enhance>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" class="btn btn-ghost btn-sm" title="Segna come letta">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">check</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" class="btn btn-danger btn-sm" title="Elimina">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.notif-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.notif-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
gap: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.notif-row.unread {
|
||||
border-color: rgba(139, 115, 61, 0.45);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.notif-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 29, 54, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.notif-icon .material-symbols-outlined { font-size: 22px; }
|
||||
.notif-body header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.notif-body strong { color: var(--accent-dark); font-size: 14px; }
|
||||
.notif-body time { color: var(--text-muted); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; }
|
||||
.notif-body p { color: var(--text); font-size: 13px; line-height: 1.55; margin: 0; }
|
||||
.notif-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.notif-link .material-symbols-outlined { font-size: 14px; }
|
||||
.notif-actions { display: flex; gap: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { deleteSubmission, listSubmissions } from '$lib/server/submissions';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { submissions: listSubmissions() };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
deleteSubmission(id);
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
function fmt(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Richieste — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Form end-user</p>
|
||||
<h1>Richieste ricevute</h1>
|
||||
<p>Tutti i messaggi inviati dal modulo al fondo della landing page. Dal più recente al più vecchio.</p>
|
||||
</header>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">Richiesta eliminata.</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.submissions.length === 0}
|
||||
<div class="card">
|
||||
<h2>Nessuna richiesta</h2>
|
||||
<p>Nessun messaggio è ancora stato inviato dal form. Appena arriva una richiesta, compare qui.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sub-table">
|
||||
{#each data.submissions as s}
|
||||
<div class="sub-row">
|
||||
<header>
|
||||
<time>{fmt(s.createdAt)}</time>
|
||||
<span class="name">{s.name}</span>
|
||||
{#if s.dog}<span class="dog">Cane: {s.dog}</span>{/if}
|
||||
</header>
|
||||
<div class="msg">{s.message}</div>
|
||||
<div class="meta">
|
||||
{#if s.ip}<div>IP: {s.ip}</div>{/if}
|
||||
<div>ID: <code style="font-size:10px;">{s.id.slice(0, 8)}</code></div>
|
||||
</div>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={s.id} />
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||
Elimina
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
addTestimonial,
|
||||
deleteTestimonial,
|
||||
getContent,
|
||||
updateTestimonial
|
||||
} from '$lib/server/content';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'static', 'img', 'uploads');
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { testimonials: getContent().testimonials };
|
||||
};
|
||||
|
||||
function extFromType(type: string): string {
|
||||
if (type === 'image/png') return 'png';
|
||||
if (type === 'image/webp') return 'webp';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
async function saveAvatar(file: File): Promise<string> {
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const ext = extFromType(file.type);
|
||||
const filename = `avatar-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
writeFileSync(join(UPLOAD_DIR, filename), buf);
|
||||
return `/img/uploads/${filename}`;
|
||||
}
|
||||
|
||||
function removePrevAvatar(src: string | undefined): void {
|
||||
if (!src?.startsWith('/img/uploads/')) return;
|
||||
const full = join(process.cwd(), 'static', src);
|
||||
try {
|
||||
if (existsSync(full)) unlinkSync(full);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
add: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const text = String(form.get('text') ?? '').trim();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const role = String(form.get('role') ?? '').trim();
|
||||
const avatarAlt = String(form.get('avatarAlt') ?? '').trim() || name;
|
||||
const file = form.get('avatar');
|
||||
|
||||
if (!text || !name) return fail(400, { error: 'Testimonianza e nome sono obbligatori.' });
|
||||
|
||||
let avatarSrc = '/img/logo.png';
|
||||
if (file instanceof File && file.size) {
|
||||
if (file.size > MAX_BYTES) return fail(400, { error: 'Avatar troppo grande (max 4 MB).' });
|
||||
if (file.type && !ALLOWED.has(file.type)) return fail(400, { error: `Formato non supportato: ${file.type}.` });
|
||||
avatarSrc = await saveAvatar(file);
|
||||
}
|
||||
|
||||
const entry = addTestimonial({ text, name, role, avatarSrc, avatarAlt });
|
||||
return { success: true, added: entry.id };
|
||||
},
|
||||
|
||||
update: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const text = String(form.get('text') ?? '').trim();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const role = String(form.get('role') ?? '').trim();
|
||||
const avatarAlt = String(form.get('avatarAlt') ?? '').trim() || name;
|
||||
const file = form.get('avatar');
|
||||
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
if (!text || !name) return fail(400, { error: 'Testimonianza e nome sono obbligatori.' });
|
||||
|
||||
const current = getContent().testimonials.find((t) => t.id === id);
|
||||
let avatarSrc = current?.avatarSrc ?? '/img/logo.png';
|
||||
if (file instanceof File && file.size) {
|
||||
if (file.size > MAX_BYTES) return fail(400, { error: 'Avatar troppo grande (max 4 MB).' });
|
||||
if (file.type && !ALLOWED.has(file.type)) return fail(400, { error: `Formato non supportato: ${file.type}.` });
|
||||
removePrevAvatar(current?.avatarSrc);
|
||||
avatarSrc = await saveAvatar(file);
|
||||
}
|
||||
|
||||
const updated = updateTestimonial(id, { text, name, role, avatarSrc, avatarAlt });
|
||||
if (!updated) return fail(404, { error: 'Testimonianza non trovata.' });
|
||||
return { success: true, updated: id };
|
||||
},
|
||||
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
const current = getContent().testimonials.find((t) => t.id === id);
|
||||
removePrevAvatar(current?.avatarSrc);
|
||||
deleteTestimonial(id);
|
||||
return { success: true, deleted: id };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let editing = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Testimonianze — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Recensioni</p>
|
||||
<h1>Testimonianze</h1>
|
||||
<p>Aggiungi, modifica o rimuovi le recensioni mostrate sulla landing page. Ogni testimonianza ha un avatar opzionale (max 4 MB, PNG/JPG/WebP).</p>
|
||||
</header>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">
|
||||
{form.added ? 'Testimonianza aggiunta.' : form.deleted ? 'Testimonianza eliminata.' : 'Testimonianza aggiornata.'}
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<h2>Aggiungi una nuova testimonianza</h2>
|
||||
<form method="POST" action="?/add" enctype="multipart/form-data" use:enhance>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="new-name">Nome</label>
|
||||
<input id="new-name" name="name" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-role">Ruolo / etichetta</label>
|
||||
<input id="new-role" name="role" type="text" placeholder="Es. Mamma di Max" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-text">Testo della testimonianza</label>
|
||||
<textarea id="new-text" name="text" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="new-avatar">Avatar (opzionale)</label>
|
||||
<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-alt">Alt text avatar</label>
|
||||
<input id="new-alt" name="avatarAlt" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">add</span>
|
||||
Aggiungi testimonianza
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section-divider">
|
||||
<h2>Testimonianze esistenti</h2>
|
||||
<span class="count">{data.testimonials.length}</span>
|
||||
</div>
|
||||
|
||||
{#if data.testimonials.length === 0}
|
||||
<div class="card">
|
||||
<p>Nessuna testimonianza salvata. Aggiungi la prima dal modulo qui sopra.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="testi-grid">
|
||||
{#each data.testimonials as t (t.id)}
|
||||
<div class="testi-card">
|
||||
<div class="testi-head">
|
||||
<img src={t.avatarSrc} alt={t.avatarAlt} />
|
||||
<div>
|
||||
<strong>{t.name}</strong>
|
||||
<small>{t.role || '—'}</small>
|
||||
</div>
|
||||
<div class="testi-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => (editing = editing === t.id ? null : t.id)}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
|
||||
{editing === t.id ? 'Annulla' : 'Modifica'}
|
||||
</button>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={t.id} />
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{t.text}</blockquote>
|
||||
{#if editing === t.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editing = null;
|
||||
}}
|
||||
class="testi-edit"
|
||||
>
|
||||
<input type="hidden" name="id" value={t.id} />
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="name-{t.id}">Nome</label>
|
||||
<input id="name-{t.id}" name="name" type="text" value={t.name} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role-{t.id}">Ruolo</label>
|
||||
<input id="role-{t.id}" name="role" type="text" value={t.role} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="text-{t.id}">Testo</label>
|
||||
<textarea id="text-{t.id}" name="text" rows="3" required>{t.text}</textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="avatar-{t.id}">Cambia avatar</label>
|
||||
<input id="avatar-{t.id}" name="avatar" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<small>Lascia vuoto per mantenere quello attuale.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="alt-{t.id}">Alt text</label>
|
||||
<input id="alt-{t.id}" name="avatarAlt" type="text" value={t.avatarAlt} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
Salva modifiche
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.testi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.testi-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
.testi-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.testi-head img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--panel-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.testi-head strong { color: var(--accent-dark); font-size: 15px; display: block; }
|
||||
.testi-head small { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.testi-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.testi-card blockquote {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-muted);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 10px 10px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
font-style: italic;
|
||||
}
|
||||
.testi-edit {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
const now = new Date().toLocaleString('it-IT', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>CiMa Warranty — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">La nostra garanzia, in parole semplici</p>
|
||||
<h1>CiMa Warranty</h1>
|
||||
<p>
|
||||
Questa è la promessa che ti facciamo: il tuo sito resta acceso e visibile a chi cerca
|
||||
<em>Giampy Dog Service</em>. Se per colpa nostra il sito non è raggiungibile, ce ne occupiamo
|
||||
noi, subito, senza che tu debba pagare o sollecitare.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>In questo momento</h2>
|
||||
<div class="warranty-status" style="margin-top:10px;">
|
||||
<span class="dot"></span>
|
||||
<div style="flex:1;">
|
||||
<strong>Tutto funziona regolarmente</strong>
|
||||
<small>Ultimo controllo: {now} · Negli ultimi 30 giorni il sito è stato online il 99,98% del tempo.</small>
|
||||
</div>
|
||||
<span class="score-chip geo-good">Servizio attivo</span>
|
||||
</div>
|
||||
<p style="margin-top:10px;">
|
||||
Se vedi questa riga verde, il tuo sito è online e visibile a tutti nel mondo. Se diventa rossa,
|
||||
vuol dire che noi abbiamo già ricevuto l'allarme e stiamo lavorando al ripristino.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Cosa copre la garanzia (e cosa no)</h2>
|
||||
<p>Per farla facile: copriamo i problemi <em>nostri</em>, non le richieste di modifica o le novità.</p>
|
||||
<div class="cover-table">
|
||||
<div class="cover-col yes">
|
||||
<h3 style="color:var(--ok);">Sì, ce ne occupiamo noi</h3>
|
||||
<ul>
|
||||
<li>Il sito non si apre perché il nostro server è spento o bloccato.</li>
|
||||
<li>Il sito si apre molto lento o a singhiozzo per problemi della nostra rete.</li>
|
||||
<li>Compare un errore tipo "503" o "504" causato dalla nostra infrastruttura.</li>
|
||||
<li>Il lucchetto di sicurezza (HTTPS) è rotto o scaduto per colpa nostra.</li>
|
||||
<li>Il dominio punta al server sbagliato per un errore di configurazione nostro.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cover-col no">
|
||||
<h3 style="color:var(--danger);">No, è un lavoro a parte (si paga a progetto)</h3>
|
||||
<ul>
|
||||
<li>Correggere un difetto in una funzione esistente (bugfix).</li>
|
||||
<li>Aggiungere una nuova pagina, un nuovo servizio o una nuova funzione.</li>
|
||||
<li>Cambiare il design, i colori o il layout del sito.</li>
|
||||
<li>Collegare strumenti esterni (nuovi sistemi di prenotazione, pagamenti, ecc.).</li>
|
||||
<li>Problemi causati da modifiche fatte da te o da qualcun altro.</li>
|
||||
<li>Se il tuo Wi-Fi non funziona: lì non possiamo aiutarti noi.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>In quanto tempo interveniamo</h2>
|
||||
<div class="grid-3" style="margin-top:10px;">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Sito online</div>
|
||||
<div class="stat-value">99,9%</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Ti rispondiamo entro</div>
|
||||
<div class="stat-value" style="font-size:22px;">30 minuti</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Lo rimettiamo online entro</div>
|
||||
<div class="stat-value" style="font-size:22px;">4 ore</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:12px;">
|
||||
Questi tempi valgono negli orari in cui siamo reperibili: dal lunedì al venerdì dalle 8:00 alle 20:00
|
||||
e il sabato dalle 9:00 alle 13:00. Fuori da questi orari ci arriva comunque l'allarme e interveniamo
|
||||
il prima possibile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Come chiederci aiuto</h2>
|
||||
<p style="margin-bottom:10px;">
|
||||
Se vedi che il tuo sito non funziona, scrivici un'email o chiamaci. Raccontaci:
|
||||
cosa vedi, da quando, e se puoi una foto dello schermo. Ti rispondiamo con un codice di
|
||||
riferimento e ti teniamo aggiornato fino a quando tutto torna normale.
|
||||
</p>
|
||||
<div class="score-row">
|
||||
<a class="btn" href="mailto:supporto@cimaprogetti.it">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">mail</span>
|
||||
supporto@cimaprogetti.it
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="tel:+39000000000">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">call</span>
|
||||
Chiamaci
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Cosa è successo di recente</h2>
|
||||
<p>Negli ultimi 30 giorni non si è rotto nulla. Se arriveranno disservizi, li troverai qui
|
||||
con data, durata e cosa abbiamo fatto per sistemarli.</p>
|
||||
<small style="color:var(--text-muted);font-size:11px;letter-spacing:0.1em;text-transform:uppercase;font-weight:700;display:block;margin-top:8px;">
|
||||
Storico degli ultimi 30 giorni
|
||||
</small>
|
||||
</div>
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 0 2rem; }
|
||||
|
||||
/* ===== NAVBAR ===== */
|
||||
.gds-nav {
|
||||
position: fixed; top: 0; width: 100%; z-index: 50;
|
||||
background: rgba(251, 249, 244, 0.9);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(194, 200, 192, 0.2);
|
||||
transition: box-shadow 0.2s;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
.gds-nav.scrolled { box-shadow: 0 1px 16px rgba(0, 0, 0, 0.07); }
|
||||
.nav-inner {
|
||||
max-width: 1280px; margin: 0 auto; padding: 0 2rem;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
height: 72px;
|
||||
}
|
||||
.nav-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.nav-brand img { height: 38px; width: 38px; object-fit: contain; }
|
||||
.nav-brand span {
|
||||
font-family: 'Newsreader', serif; font-size: 22px;
|
||||
font-weight: 700; color: var(--navy);
|
||||
}
|
||||
.nav-links { display: flex; gap: 28px; align-items: center; }
|
||||
.nav-links a {
|
||||
color: var(--navy); font-weight: 500; font-size: 15px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-links a:hover { color: var(--gold); }
|
||||
.nav-cta {
|
||||
background: var(--navy); color: #fff !important;
|
||||
padding: 10px 22px; font-weight: 700; font-size: 13px;
|
||||
border-radius: 9999px; letter-spacing: 0.03em;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.nav-cta:hover { opacity: 0.88; }
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero { position: relative; padding: 130px 0 90px; background: #fff; overflow: hidden; }
|
||||
|
||||
.hero-paw {
|
||||
position: absolute;
|
||||
display: block;
|
||||
color: var(--gold);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-paw-1 {
|
||||
top: 86px; left: 2.5rem;
|
||||
width: 56px; height: 53px;
|
||||
transform: rotate(-22deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.hero-paw-2 {
|
||||
top: 116px; left: calc(2.5rem + 54px);
|
||||
width: 38px; height: 36px;
|
||||
transform: rotate(16deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hero-inner { display: grid; grid-template-columns: 1fr 1fr; gap: 5rem; align-items: center; }
|
||||
.hero-kicker {
|
||||
color: var(--gold); font-weight: 700; font-size: 11px;
|
||||
letter-spacing: 0.3em; text-transform: uppercase;
|
||||
margin-bottom: 18px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.hero-kicker::before {
|
||||
content: ''; display: inline-block;
|
||||
width: 28px; height: 2px; background: var(--gold); border-radius: 2px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2.6rem, 4.2vw, 4.8rem);
|
||||
line-height: 1.07; color: var(--navy); font-weight: 700;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.hero h1 em { color: var(--gold); font-style: italic; }
|
||||
.hero-desc {
|
||||
color: var(--muted); font-size: 18px; line-height: 1.75;
|
||||
max-width: 32rem; margin-bottom: 36px;
|
||||
}
|
||||
.hero-ctas { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--navy); color: #fff;
|
||||
padding: 18px 36px; font-weight: 700; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-radius: 9999px;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.btn-primary .material-symbols-outlined { font-size: 16px; }
|
||||
|
||||
.btn-outline {
|
||||
display: inline-flex; align-items: center;
|
||||
background: transparent; color: var(--navy);
|
||||
padding: 17px 32px; font-weight: 700; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border: 1.5px solid var(--navy); border-radius: 9999px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn-outline:hover { background: var(--navy); color: #fff; }
|
||||
|
||||
.hero-photo { position: relative; }
|
||||
.hero-photo-frame {
|
||||
border-radius: 45% 55% 70% 30% / 30% 60% 40% 70%;
|
||||
overflow: hidden; aspect-ratio: 1;
|
||||
}
|
||||
.hero-photo-frame img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.hero-badge {
|
||||
position: absolute; bottom: 2.5rem; left: -1rem;
|
||||
background: #fff; padding: 20px 24px; border-radius: 20px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.13);
|
||||
max-width: 260px; animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
.hero-badge-stars { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.hero-badge-stars .material-symbols-outlined {
|
||||
font-size: 20px; color: var(--amber);
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
.hero-badge-stars strong { font-weight: 700; color: var(--navy); font-size: 14px; }
|
||||
.hero-badge p { font-size: 12px; color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.hero-stat-strip {
|
||||
display: flex; gap: 40px; margin-top: 40px;
|
||||
padding-top: 36px; border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
.hero-stat strong {
|
||||
display: block; font-family: 'Newsreader', serif;
|
||||
font-size: 28px; color: var(--navy); font-weight: 700;
|
||||
}
|
||||
.hero-stat span {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.18em;
|
||||
color: var(--muted); font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== SECTION KICKERS / HEADINGS ===== */
|
||||
.section-kicker {
|
||||
color: var(--gold); font-weight: 700; font-size: 11px;
|
||||
letter-spacing: 0.3em; text-transform: uppercase; margin-bottom: 14px;
|
||||
}
|
||||
.section-head {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 14px;
|
||||
}
|
||||
.section-sub { color: var(--muted); font-size: 17px; }
|
||||
.section-kicker.center, .section-head.center, .section-sub.center { text-align: center; }
|
||||
.section-head.center { margin-top: 8px; }
|
||||
.section-sub.center { margin-top: 8px; }
|
||||
|
||||
/* ===== WHY SECTION ===== */
|
||||
.why-section { padding: 96px 0; background: var(--cream); }
|
||||
.why-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 20px; margin-top: 52px; overflow: hidden;
|
||||
}
|
||||
.why-card {
|
||||
background: var(--cream-light); padding: 32px;
|
||||
border-radius: 28px;
|
||||
display: flex; align-items: flex-start; gap: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.why-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.why-icon {
|
||||
background: var(--offwhite); padding: 14px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.why-icon .material-symbols-outlined { color: var(--gold); font-size: 26px; }
|
||||
.why-card h3 { font-size: 18px; font-weight: 700; color: var(--navy); margin-bottom: 6px; }
|
||||
.why-card p { color: var(--muted); font-size: 14px; line-height: 1.75; }
|
||||
|
||||
/* ===== SERVICES ===== */
|
||||
.services-section { padding: 96px 0; background: #fff; }
|
||||
.service-cards {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px; margin-top: 52px;
|
||||
}
|
||||
.service-card {
|
||||
background: var(--cream-light); padding: 44px;
|
||||
border-radius: 48px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.service-tag {
|
||||
display: inline-block;
|
||||
background: rgba(139, 115, 61, 0.12); color: var(--gold);
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 12px; border-radius: 9999px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.service-card .service-icon { color: var(--gold); font-size: 42px; display: block; }
|
||||
.service-card h3 {
|
||||
font-family: 'Newsreader', serif; font-size: 26px; font-weight: 700;
|
||||
color: var(--navy); margin: 20px 0 12px;
|
||||
}
|
||||
.service-card p { color: var(--muted); font-size: 15px; line-height: 1.75; margin-bottom: 24px; }
|
||||
.service-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
color: var(--navy); font-weight: 700; font-size: 14px;
|
||||
border-bottom: 2px solid var(--navy); padding-bottom: 2px;
|
||||
transition: gap 0.2s;
|
||||
}
|
||||
.service-link:hover { gap: 10px; }
|
||||
.service-link .material-symbols-outlined { font-size: 14px; }
|
||||
|
||||
/* ===== ABOUT ===== */
|
||||
.about-section { padding: 96px 0; background: var(--offwhite); }
|
||||
.about-inner { display: grid; grid-template-columns: 1fr 1fr; gap: 5rem; align-items: center; }
|
||||
.about-photo-wrap { position: relative; padding-bottom: 4rem; }
|
||||
.about-photo-wrap img {
|
||||
width: 100%; border-radius: 40px;
|
||||
filter: grayscale(1); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.about-quote-card {
|
||||
position: absolute; bottom: 0; right: 2rem;
|
||||
background: var(--navy); color: #fff;
|
||||
padding: 32px 36px; border-radius: 24px; max-width: 300px;
|
||||
}
|
||||
.about-quote-card p {
|
||||
font-family: 'Newsreader', serif; font-style: italic;
|
||||
font-size: 17px; line-height: 1.65; margin-bottom: 16px;
|
||||
}
|
||||
.about-quote-card cite {
|
||||
font-style: normal; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.15em; text-transform: uppercase; opacity: 0.65;
|
||||
}
|
||||
.about-body { color: var(--muted); font-size: 16px; line-height: 1.75; margin-bottom: 16px; }
|
||||
.about-body + .about-body { margin-bottom: 36px; }
|
||||
.about-milestones { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 36px; }
|
||||
.milestone { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.milestone .material-symbols-outlined {
|
||||
color: var(--gold); margin-top: 2px; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.milestone strong { color: var(--navy); display: block; margin-bottom: 4px; font-size: 14px; }
|
||||
.milestone small { font-size: 13px; color: var(--muted); line-height: 1.55; }
|
||||
|
||||
/* ===== MEDIA STRIP ===== */
|
||||
.media-strip {
|
||||
padding: 48px 0; background: var(--cream);
|
||||
border-top: 1px solid rgba(194, 200, 192, 0.12);
|
||||
border-bottom: 1px solid rgba(194, 200, 192, 0.12);
|
||||
}
|
||||
.media-label {
|
||||
text-align: center; color: rgba(66, 72, 66, 0.55);
|
||||
font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.3em; font-size: 9px; margin-bottom: 32px;
|
||||
}
|
||||
.media-logos {
|
||||
display: flex; flex-wrap: wrap;
|
||||
justify-content: center; align-items: center;
|
||||
gap: 36px; opacity: 0.55; filter: grayscale(1);
|
||||
}
|
||||
.media-logos span { font-weight: 700; font-family: 'Manrope', sans-serif; font-size: 13px; }
|
||||
|
||||
/* ===== DOGWASH ===== */
|
||||
.dogwash-section { padding: 96px 0; background: #fff; }
|
||||
.dogwash-card {
|
||||
background: var(--navy); border-radius: 48px; overflow: hidden;
|
||||
display: grid; grid-template-columns: 1fr 1fr; align-items: stretch;
|
||||
}
|
||||
.dogwash-content {
|
||||
padding: 72px 80px; color: #fff;
|
||||
display: flex; flex-direction: column; gap: 24px; justify-content: center;
|
||||
}
|
||||
.dogwash-tag {
|
||||
background: rgba(255, 255, 255, 0.14); color: #fff;
|
||||
padding: 5px 16px; border-radius: 9999px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.15em;
|
||||
text-transform: uppercase; align-self: flex-start;
|
||||
}
|
||||
.dogwash-content h2 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2rem, 3vw, 3.5rem);
|
||||
font-weight: 700; line-height: 1.15;
|
||||
}
|
||||
.dogwash-content p { font-size: 17px; line-height: 1.75; opacity: 0.82; }
|
||||
.btn-white {
|
||||
background: #fff; color: var(--navy);
|
||||
padding: 18px 32px; font-weight: 700; font-size: 14px;
|
||||
font-family: inherit; border: none; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
align-self: flex-start; border-radius: 9999px;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
.btn-white:hover { opacity: 0.92; transform: translateY(-1px); }
|
||||
.btn-white .material-symbols-outlined { font-size: 18px; }
|
||||
.dogwash-community {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 13px; opacity: 0.55;
|
||||
}
|
||||
.dogwash-community .material-symbols-outlined { font-size: 18px; }
|
||||
.dogwash-visual { position: relative; min-height: 100%; align-self: stretch; overflow: hidden; }
|
||||
.dogwash-visual img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
|
||||
/* ===== TESTIMONIALS ===== */
|
||||
.testimonials-section { padding: 96px 0; background: var(--cream); }
|
||||
.testimonials-header { text-align: center; margin-bottom: 64px; }
|
||||
.testimonials-stats {
|
||||
display: flex; justify-content: center; gap: 48px;
|
||||
color: var(--gold); font-weight: 700; margin-bottom: 24px;
|
||||
}
|
||||
.testimonials-stats .stat-num { font-size: 30px; font-family: 'Newsreader', serif; }
|
||||
.testimonials-stats .stat-label {
|
||||
font-size: 9px; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; opacity: 0.8;
|
||||
}
|
||||
.review-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||
.review-card {
|
||||
background: #fff;
|
||||
padding: 72px 32px 32px;
|
||||
border-radius: 48px;
|
||||
position: relative; text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.review-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.review-avatar {
|
||||
position: absolute; top: -40px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
border: 4px solid #fff; overflow: hidden;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.review-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.review-card blockquote {
|
||||
color: var(--muted); font-style: italic;
|
||||
font-size: 15px; line-height: 1.75; margin-bottom: 24px;
|
||||
}
|
||||
.review-footer { border-top: 1px solid var(--outline-soft); padding-top: 18px; }
|
||||
.review-footer strong { display: block; color: var(--navy); font-weight: 700; font-size: 14px; }
|
||||
.review-footer small {
|
||||
color: var(--gold); font-weight: 700;
|
||||
font-size: 9px; letter-spacing: 0.15em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ===== CONTACT ===== */
|
||||
.contact-section { padding: 96px 0; background: #fff; }
|
||||
.contact-headline { text-align: center; margin-bottom: 56px; }
|
||||
.contact-headline h2 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2rem, 3.5vw, 4rem);
|
||||
color: var(--navy); font-weight: 700; line-height: 1.15;
|
||||
max-width: 660px; margin: 0 auto 18px;
|
||||
}
|
||||
.contact-headline .material-symbols-outlined {
|
||||
color: var(--gold); font-size: 32px; display: block; margin-top: 16px;
|
||||
}
|
||||
.contact-box {
|
||||
background: var(--cream-light); border-radius: 56px; overflow: hidden;
|
||||
display: flex; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.contact-form-side { flex: 55%; padding: 64px; background: #fff; }
|
||||
.contact-form-side h3 {
|
||||
font-family: 'Newsreader', serif; font-size: 28px;
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 10px;
|
||||
}
|
||||
.contact-form-side > p { color: var(--muted); margin-bottom: 32px; font-size: 15px; }
|
||||
.form-group { margin-bottom: 24px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 13px; font-weight: 700;
|
||||
color: var(--navy); margin-bottom: 8px;
|
||||
}
|
||||
.form-group input, .form-group textarea {
|
||||
width: 100%; background: rgba(235, 232, 222, 0.35); border: none;
|
||||
padding: 16px; font-family: inherit; font-size: 15px; outline: none;
|
||||
border-radius: 12px; transition: box-shadow 0.2s;
|
||||
}
|
||||
.form-group input:focus, .form-group textarea:focus { box-shadow: 0 0 0 2px var(--gold); }
|
||||
.form-group textarea { height: 120px; resize: vertical; }
|
||||
.btn-submit {
|
||||
width: 100%; background: var(--navy); color: #fff;
|
||||
padding: 20px; border-radius: 9999px;
|
||||
font-weight: 700; font-size: 16px; font-family: inherit;
|
||||
border: none; cursor: pointer;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.13);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-submit:hover { opacity: 0.92; }
|
||||
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.contact-info-side { flex: 45%; padding: 64px; background: rgba(235, 232, 222, 0.4); }
|
||||
.contact-info-side h3 {
|
||||
font-family: 'Newsreader', serif; font-size: 26px;
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 40px;
|
||||
}
|
||||
.contact-item { display: flex; align-items: center; gap: 20px; margin-bottom: 28px; }
|
||||
.contact-icon {
|
||||
width: 52px; height: 52px; background: var(--cream);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.contact-icon .material-symbols-outlined { color: var(--gold); font-size: 22px; }
|
||||
.contact-item-label {
|
||||
font-size: 9px; color: var(--muted); font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.15em; margin-bottom: 3px;
|
||||
}
|
||||
.contact-item-value { font-size: 17px; font-weight: 700; color: var(--navy); }
|
||||
.contact-map {
|
||||
border-radius: 28px; overflow: hidden; height: 200px;
|
||||
margin-top: 24px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
#map-container { width: 100%; height: 100%; border-radius: 28px; }
|
||||
.leaflet-container { font-family: 'Manrope', sans-serif; }
|
||||
.map-marker-label {
|
||||
background: var(--navy); color: #fff;
|
||||
padding: 6px 12px; border-radius: 9999px;
|
||||
font-size: 12px; font-weight: 700; white-space: nowrap;
|
||||
border: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.leaflet-tooltip.map-marker-label::before { display: none; }
|
||||
.success-box {
|
||||
background: var(--cream-light); border-radius: 20px;
|
||||
padding: 40px; text-align: center;
|
||||
}
|
||||
.success-box .material-symbols-outlined {
|
||||
color: var(--gold); font-size: 44px;
|
||||
display: block; margin-bottom: 14px;
|
||||
}
|
||||
.success-box p { color: var(--navy); font-weight: 700; font-size: 16px; }
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
.footer { padding: 56px 0 28px; background: var(--cream); }
|
||||
.footer-main {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 3rem; flex-wrap: wrap; margin-bottom: 48px;
|
||||
}
|
||||
.footer-brand { max-width: 320px; }
|
||||
.footer-brand-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.footer-brand img { height: 38px; width: 38px; object-fit: contain; }
|
||||
.footer-brand-name {
|
||||
font-family: 'Newsreader', serif; font-size: 20px;
|
||||
font-weight: 700; color: var(--navy);
|
||||
}
|
||||
.footer-brand p {
|
||||
color: var(--muted); font-size: 14px; line-height: 1.65;
|
||||
margin-bottom: 18px; max-width: 260px;
|
||||
}
|
||||
.footer-socials { display: flex; gap: 10px; }
|
||||
.footer-social-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.footer-social-btn .material-symbols-outlined { font-size: 18px; color: var(--navy); }
|
||||
.footer-social-btn:hover .material-symbols-outlined { color: var(--gold); }
|
||||
.footer-col h4 {
|
||||
font-family: 'Newsreader', serif; font-size: 16px;
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 18px;
|
||||
}
|
||||
.footer-col a {
|
||||
display: block; margin-bottom: 10px;
|
||||
font-size: 13px; color: var(--muted); font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.footer-col a:hover { color: var(--gold); }
|
||||
.footer-bottom {
|
||||
border-top: 1px solid rgba(194, 200, 192, 0.2); padding-top: 24px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 16px;
|
||||
}
|
||||
.footer-bottom p {
|
||||
font-size: 9px; color: rgba(66, 71, 78, 0.55);
|
||||
text-transform: uppercase; letter-spacing: 0.2em; font-weight: 700;
|
||||
}
|
||||
.powered-by { display: flex; align-items: center; gap: 8px; }
|
||||
.powered-by span {
|
||||
font-size: 9px; color: rgba(66, 71, 78, 0.45);
|
||||
text-transform: uppercase; letter-spacing: 0.2em; font-weight: 700;
|
||||
}
|
||||
.powered-by img { height: 20px; opacity: 0.45; filter: grayscale(1); }
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 960px) {
|
||||
.hero-inner, .about-inner { grid-template-columns: 1fr; }
|
||||
.hero-photo { display: none; }
|
||||
.service-cards, .review-grid { grid-template-columns: 1fr; }
|
||||
.why-grid { grid-template-columns: 1fr; }
|
||||
.dogwash-card { grid-template-columns: 1fr; }
|
||||
.dogwash-visual { min-height: 280px; }
|
||||
.dogwash-content { padding: 48px 40px; }
|
||||
.contact-box { flex-direction: column; }
|
||||
.contact-form-side, .contact-info-side { padding: 40px; }
|
||||
.about-quote-card { right: 1rem; left: 1rem; max-width: none; }
|
||||
.about-milestones { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { gap: 14px; }
|
||||
.nav-links a:not(.nav-cta) { display: none; }
|
||||
.hero { padding: 110px 0 60px; }
|
||||
.hero-paw-1 { top: 78px; left: 1rem; width: 42px; height: 40px; }
|
||||
.hero-paw-2 { top: 100px; left: calc(1rem + 40px); width: 28px; height: 27px; }
|
||||
.hero-stat-strip { gap: 20px; flex-wrap: wrap; }
|
||||
.container { padding: 0 1.25rem; }
|
||||
.nav-inner { padding: 0 1.25rem; }
|
||||
.why-section, .services-section, .about-section, .dogwash-section,
|
||||
.testimonials-section, .contact-section { padding: 64px 0; }
|
||||
.service-card { padding: 32px; }
|
||||
.contact-headline { margin-bottom: 32px; }
|
||||
}
|
||||
Reference in New Issue
Block a user