added admin dashboard

This commit is contained in:
2026-04-20 12:48:58 +02:00
parent 6145775922
commit d3f740cfa9
56 changed files with 4438 additions and 669 deletions
+7
View File
@@ -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' };
};
+11
View File
@@ -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()}
+31
View File
@@ -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 };
}
};
+398
View File
@@ -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>
+14
View File
@@ -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
};
};
+76
View File
@@ -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>
+26
View File
@@ -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
};
};
+79
View File
@@ -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>
+89
View File
@@ -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 };
}
};
+294
View File
@@ -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>75100 verde</strong> — il testo funziona bene.</li>
<li><span class="dot amber"></span><strong>5074 giallo</strong> — va migliorato ma non è grave.</li>
<li><span class="dot red"></span><strong>049 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 5060 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 120160 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>
+76
View File
@@ -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 };
}
};
+138
View File
@@ -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>
+19
View File
@@ -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');
}
};
+32
View File
@@ -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>
+8
View File
@@ -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 };
}
};
+159
View File
@@ -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 };
}
};
+63
View File
@@ -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 };
}
};
+189
View File
@@ -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>
+110
View File
@@ -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>