added admin dashboard
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user