added admin dashboard
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
/.svelte-kit
|
||||
/build
|
||||
/dist
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vite
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# runtime data (admin-managed content, uploads, sessions)
|
||||
/data
|
||||
/static/img/uploads/*
|
||||
!/static/img/uploads/.gitkeep
|
||||
@@ -1,358 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Giampy Dog Service | Cura e Passione per il tuo Cane</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Manrope:wght@200..800&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<img alt="Giampy Dog Service Logo" src="img/logo.png">
|
||||
<span>Giampy Dog Service</span>
|
||||
</div>
|
||||
<div class="navbar-links">
|
||||
<a href="#servizi">Servizi</a>
|
||||
<a href="#chi-sono">Chi Sono</a>
|
||||
<a href="#testimonianze">Testimonianze</a>
|
||||
<a href="#dogwash">DogWash</a>
|
||||
<button class="btn-primary" onclick="document.getElementById('contatti').scrollIntoView({behavior:'smooth'})">Contattami</button>
|
||||
</div>
|
||||
<button class="menu-toggle" aria-label="Menu">
|
||||
<span class="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<header class="hero">
|
||||
<div class="container">
|
||||
<div>
|
||||
<p class="hero-subtitle">Dal 2018 con amore</p>
|
||||
<h1>La cura e l'amore che il tuo cane <span class="gold">merita.</span></h1>
|
||||
<p class="hero-text">Un approccio umano e professionale per garantire il benessere del tuo compagno di vita, senza gabbie né solitudine.</p>
|
||||
<div class="hero-buttons">
|
||||
<button class="btn-big filled" onclick="document.getElementById('servizi').scrollIntoView({behavior:'smooth'})">
|
||||
SCOPRI I SERVIZI <span class="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
<button class="btn-big outlined" onclick="document.getElementById('contatti').scrollIntoView({behavior:'smooth'})">CONTATTAMI</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-image">
|
||||
<div class="organic-mask">
|
||||
<img alt="Giampy con un cane" src="img/hero-image.png">
|
||||
</div>
|
||||
<div class="hero-badge">
|
||||
<div class="stars">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1; color: var(--gold);">star</span>
|
||||
<span>5.0 Recensioni</span>
|
||||
</div>
|
||||
<p>Il punto di riferimento per i proprietari di Sassari.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Why Section -->
|
||||
<section class="why-section">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2>Perché scegliere Giampy</h2>
|
||||
<p>Ogni cane viene seguito con attenzione, rispetto e presenza vera.</p>
|
||||
</div>
|
||||
<div class="why-grid">
|
||||
<div class="why-card">
|
||||
<div class="icon-wrap"><span class="material-symbols-outlined">history</span></div>
|
||||
<div>
|
||||
<h3>Esperienza dal 2018</h3>
|
||||
<p>Anni di dedizione quotidiana che hanno costruito una competenza solida e affidabile.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="why-card">
|
||||
<div class="icon-wrap"><span class="material-symbols-outlined">diversity_1</span></div>
|
||||
<div>
|
||||
<h3>Rapporti Umani</h3>
|
||||
<p>Costruisco legami duraturi con i proprietari basati su fiducia e trasparenza totale.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="why-card">
|
||||
<div class="icon-wrap"><span class="material-symbols-outlined">verified_user</span></div>
|
||||
<div>
|
||||
<h3>Approccio Professionale</h3>
|
||||
<p>Metodologia amichevole ma rigorosa nel rispetto delle abitudini del tuo cane.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="why-card">
|
||||
<div class="icon-wrap"><span class="material-symbols-outlined">house_siding</span></div>
|
||||
<div>
|
||||
<h3>Senza Gabbie</h3>
|
||||
<p>Assoluto divieto di catene o gabbie. Libertà e comfort sono le nostre priorità.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services -->
|
||||
<section class="services" id="servizi">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2>I Nostri Servizi</h2>
|
||||
<p>Soluzioni su misura per ogni esigenza, pensate per il massimo comfort del tuo cane.</p>
|
||||
</div>
|
||||
<div class="services-grid">
|
||||
<div class="service-card">
|
||||
<span class="material-symbols-outlined">pets</span>
|
||||
<h3>Dogwalking</h3>
|
||||
<p>Passeggiate esplorative nei sentieri più belli di Sassari, per stimolare mente e corpo.</p>
|
||||
<a href="#contatti">Prenota ora <span class="material-symbols-outlined" style="font-size:0.875rem">arrow_forward</span></a>
|
||||
</div>
|
||||
<div class="service-card">
|
||||
<span class="material-symbols-outlined">house</span>
|
||||
<h3>Home Boarding</h3>
|
||||
<p>Dogsitting a domicilio o ospitalità casalinga. Il tuo cane si sentirà sempre nel suo ambiente.</p>
|
||||
<a href="#contatti">Chiedi info <span class="material-symbols-outlined" style="font-size:0.875rem">arrow_forward</span></a>
|
||||
</div>
|
||||
<div class="service-card">
|
||||
<span class="material-symbols-outlined">celebration</span>
|
||||
<h3>Wedding Dogsitter</h3>
|
||||
<p>Gestione professionale durante le cerimonie. Il tuo migliore amico presente nel tuo giorno speciale.</p>
|
||||
<a href="#contatti">Scopri di più <span class="material-symbols-outlined" style="font-size:0.875rem">arrow_forward</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="about" id="chi-sono">
|
||||
<div class="container">
|
||||
<div class="about-image">
|
||||
<img alt="Giampy passeggia con un cane" src="img/chi-sono.png">
|
||||
<div class="about-quote">
|
||||
<p>"Rusty non è stato solo un cane, è stato il maestro che mi ha indicato la strada."</p>
|
||||
<cite>— Giampiero Scaglione</cite>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-content">
|
||||
<p class="about-label">CHI SONO</p>
|
||||
<h2>Dall'amore alla carriera CONI</h2>
|
||||
<p class="desc">La storia di Giampy Dog Service nasce da una necessità del cuore. Quello che era iniziato come un lavoro temporaneo si è evoluto in una missione di vita grazie a Rusty (il mitico Rustone).</p>
|
||||
<p class="desc">Oggi, quella passione è diventata una carriera certificata CONI, unendo l'istinto alla formazione tecnica più rigorosa per offrire ai cani non solo assistenza, ma vera comprensione.</p>
|
||||
<div class="about-badges">
|
||||
<div class="about-badge">
|
||||
<span class="material-symbols-outlined">explore</span>
|
||||
<div>
|
||||
<strong>Cammino di Santiago</strong>
|
||||
<small>Percorso a piedi con il proprio cane, testando legame e resilienza.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-badge">
|
||||
<span class="material-symbols-outlined">verified</span>
|
||||
<div>
|
||||
<strong>Certificazione CONI</strong>
|
||||
<small>Formazione professionale d'eccellenza in ambito cinofilo.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media Strip -->
|
||||
<section class="media-strip">
|
||||
<div class="container">
|
||||
<p class="label">RICONOSCIMENTI E MEDIA</p>
|
||||
<div class="media-logos">
|
||||
<span style="font-size:1.25rem;letter-spacing:-0.05em">L'UNIONE SARDA</span>
|
||||
<span style="font-weight:600">Sardegna Reporter</span>
|
||||
<span style="font-size:1.25rem;font-weight:900;color:var(--gold)">RADIO SUPER SOUND</span>
|
||||
<span style="font-size:0.875rem;border:1px solid #000;padding:0.5rem 1rem">QUALIFICA CONI</span>
|
||||
<span style="font-weight:500">Corsa in Rosa</span>
|
||||
<span style="font-style:italic">Giornata del Super Cane</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DogWash -->
|
||||
<section class="dogwash" id="dogwash">
|
||||
<div class="container">
|
||||
<div class="dogwash-card">
|
||||
<div class="dogwash-text">
|
||||
<span class="tag">Innovazione</span>
|
||||
<h2>DogWash Sardegna</h2>
|
||||
<p>Il futuro della cura del cane è qui. Grazie a un incredibile successo nel crowdfunding e alla fiducia della community, stiamo portando le colonne di lavaggio automatico "DogWash" in tutta la Sardegna. Un servizio rapido, ecologico e comodo.</p>
|
||||
<button class="btn-white" onclick="document.getElementById('contatti').scrollIntoView({behavior:'smooth'})">
|
||||
Scopri le postazioni <span class="material-symbols-outlined">location_on</span>
|
||||
</button>
|
||||
<div class="note">
|
||||
<span class="material-symbols-outlined">group</span>
|
||||
<span>Un progetto nato dalla fiducia dei cittadini di Sassari.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dogwash-image">
|
||||
<img alt="DogWash Machine" src="img/colonnina.png">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<section class="testimonials" id="testimonianze">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<div class="stats">
|
||||
<div><p class="number">100+</p><p class="label">Clienti Felici</p></div>
|
||||
<div><p class="number">2018</p><p class="label">Anno Fondazione</p></div>
|
||||
</div>
|
||||
<h2>I nostri ospiti dicono...</h2>
|
||||
<p>Più di semplici clienti, una grande famiglia pelosa.</p>
|
||||
</div>
|
||||
<div class="testimonials-grid">
|
||||
<div class="testimonial-card">
|
||||
<div class="avatar">
|
||||
<img alt="Dog avatar" src="img/avatar-1.png">
|
||||
</div>
|
||||
<blockquote>"Giampiero è l'unico di cui mi fido ciecamente. Il mio Max torna sempre felice e rilassato dalle passeggiate. Servizio eccellente!"</blockquote>
|
||||
<div class="author">
|
||||
<strong>Marta S.</strong>
|
||||
<small>Mamma di Max</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="avatar">
|
||||
<img alt="Dog avatar" src="img/avatar-2.png">
|
||||
</div>
|
||||
<blockquote>"Abbiamo usato il servizio Wedding Dogsitter ed è stato perfetto. Il nostro Luna è stato coccolato tutto il tempo e noi ci siamo goduti la festa."</blockquote>
|
||||
<div class="author">
|
||||
<strong>Luca & Elena</strong>
|
||||
<small>Genitori di Luna</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="avatar">
|
||||
<img alt="Dog avatar" src="img/avatar-3.png">
|
||||
</div>
|
||||
<blockquote>"Pensione in appartamento fantastica. Nessuna gabbia, solo tanto amore. Giampiero ci ha mandato foto e video ogni giorno!"</blockquote>
|
||||
<div class="author">
|
||||
<strong>Sara P.</strong>
|
||||
<small>Compagna di Felix</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact -->
|
||||
<section class="contact" id="contatti">
|
||||
<div class="container">
|
||||
<div class="contact-heading">
|
||||
<h2>Affida il tuo cane a chi lo vive davvero.</h2>
|
||||
<span class="material-symbols-outlined arrow">keyboard_double_arrow_down</span>
|
||||
</div>
|
||||
<div class="contact-card">
|
||||
<div class="contact-form">
|
||||
<h3>Affida il tuo cane a chi lo vive davvero.</h3>
|
||||
<p>Hai domande o vuoi prenotare un servizio? Scrivici ora e verrai ricontattato in meno di 24 ore.</p>
|
||||
<form onsubmit="event.preventDefault(); alert('Grazie! Ti ricontatteremo presto.');">
|
||||
<div class="field">
|
||||
<label>Il tuo Nome</label>
|
||||
<input type="text" placeholder="Nome e Cognome">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nome del tuo Cane</label>
|
||||
<input type="text" placeholder="Come si chiama il peloso?">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Messaggio</label>
|
||||
<textarea placeholder="Raccontaci le tue necessità..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn-submit">Invia Richiesta</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
<h3>Contatti Rapidi</h3>
|
||||
<div class="contact-item">
|
||||
<div class="icon-circle"><span class="material-symbols-outlined">call</span></div>
|
||||
<div>
|
||||
<p class="label-small">Chiamaci</p>
|
||||
<p class="value">+39 347 123 4567</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<div class="icon-circle"><span class="material-symbols-outlined">mail</span></div>
|
||||
<div>
|
||||
<p class="label-small">Email</p>
|
||||
<p class="value">info@giampydogservice.it</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<div class="icon-circle"><span class="material-symbols-outlined">forum</span></div>
|
||||
<div>
|
||||
<p class="label-small">WhatsApp</p>
|
||||
<p class="value">Chatta con Giampiero</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-map">
|
||||
<img alt="Mappa della sede" src="img/mappa.png">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<div class="footer-brand">
|
||||
<img alt="Logo" src="img/logo.png">
|
||||
<span>Giampy Dog Service</span>
|
||||
</div>
|
||||
<p class="footer-desc">Professionisti della cinofilia dal 2018. Ci prendiamo cura dei vostri amici come fossero i nostri.</p>
|
||||
<div class="footer-social">
|
||||
<a href="#"><span class="material-symbols-outlined" style="font-size:1.25rem">camera_alt</span></a>
|
||||
<a href="#"><span class="material-symbols-outlined" style="font-size:1.25rem">share</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Servizi</h4>
|
||||
<ul>
|
||||
<li><a href="#servizi">Dogwalking</a></li>
|
||||
<li><a href="#servizi">Home Boarding</a></li>
|
||||
<li><a href="#servizi">Wedding Dogsitter</a></li>
|
||||
<li><a href="#dogwash">DogWash</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Link Utili</h4>
|
||||
<ul>
|
||||
<li><a href="#chi-sono">Chi Sono</a></li>
|
||||
<li><a href="#testimonianze">Testimonianze</a></li>
|
||||
<li><a href="#">Privacy Policy</a></li>
|
||||
<li><a href="#">Termini e Condizioni</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Orari</h4>
|
||||
<ul class="hours">
|
||||
<li><span>Lun - Ven:</span> <strong>08:00 - 19:00</strong></li>
|
||||
<li><span>Sabato:</span> <strong>09:00 - 14:00</strong></li>
|
||||
<li><span>Domenica:</span> <strong style="font-style:italic;font-weight:400">Su richiesta</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2024 Giampy Dog Service. Tutti i diritti riservati.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "giampy-dogservice",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"seed": "node scripts/seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.15.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/leaflet": "^1.9.15",
|
||||
"@types/node": "^22.10.2",
|
||||
"svelte": "^5.16.0",
|
||||
"svelte-check": "^4.1.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/* ===== ADMIN THEME ===== */
|
||||
.admin {
|
||||
--nav-bg: #0b1624;
|
||||
--nav-item: rgba(255, 255, 255, 0.66);
|
||||
--nav-item-active: #fff;
|
||||
--nav-item-bg-hover: rgba(255, 255, 255, 0.07);
|
||||
--nav-item-bg-active: rgba(139, 115, 61, 0.22);
|
||||
--panel: #fff;
|
||||
--panel-muted: #fbf9f4;
|
||||
--border: #e6e4dc;
|
||||
--text: #1c1c1a;
|
||||
--text-muted: #6b6e72;
|
||||
--accent: #8B733D;
|
||||
--accent-dark: #001d36;
|
||||
--danger: #b03030;
|
||||
--ok: #1e7a3c;
|
||||
--warn: #b06a10;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--panel-muted);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
||||
|
||||
.admin-side {
|
||||
background: var(--nav-bg); color: #fff;
|
||||
padding: 24px 18px;
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
}
|
||||
.admin-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px 22px; border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.admin-brand img { height: 30px; width: 30px; object-fit: contain; }
|
||||
.admin-brand strong { font-family: 'Newsreader', serif; font-size: 17px; letter-spacing: 0.01em; }
|
||||
.admin-brand small {
|
||||
display: block; font-size: 9px; letter-spacing: 0.25em;
|
||||
text-transform: uppercase; color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.admin-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.admin-nav a {
|
||||
color: var(--nav-item); text-decoration: none;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font-size: 14px; font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.admin-nav a:hover { background: var(--nav-item-bg-hover); color: var(--nav-item-active); }
|
||||
.admin-nav a.active {
|
||||
background: var(--nav-item-bg-active); color: var(--nav-item-active);
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-nav .material-symbols-outlined { font-size: 20px; }
|
||||
.admin-foot { padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.admin-foot form { margin: 0; }
|
||||
.admin-user {
|
||||
color: rgba(255, 255, 255, 0.6); font-size: 12px;
|
||||
padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.admin-user strong { color: #fff; font-size: 13px; font-weight: 700; }
|
||||
.admin-logout {
|
||||
width: 100%; color: rgba(255, 255, 255, 0.78);
|
||||
padding: 10px 12px; border-radius: 10px; font-size: 13px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.05); cursor: pointer;
|
||||
font-family: inherit; border: none;
|
||||
}
|
||||
.admin-logout:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
|
||||
|
||||
.admin-main { padding: 32px 36px 48px; max-width: 1200px; }
|
||||
.admin-main header.page-head { margin-bottom: 28px; }
|
||||
.admin-main .kicker {
|
||||
color: var(--accent); font-size: 10px; letter-spacing: 0.24em;
|
||||
text-transform: uppercase; font-weight: 700; margin-bottom: 8px;
|
||||
}
|
||||
.admin-main h1 {
|
||||
font-family: 'Newsreader', serif; font-size: 32px;
|
||||
color: var(--accent-dark); font-weight: 700; line-height: 1.15;
|
||||
}
|
||||
.admin-main h1 + p { color: var(--text-muted); font-size: 15px; margin-top: 6px; max-width: 60ch; }
|
||||
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 22px;
|
||||
}
|
||||
.card + .card { margin-top: 18px; }
|
||||
.card h2 {
|
||||
font-family: 'Newsreader', serif; font-size: 20px;
|
||||
color: var(--accent-dark); margin-bottom: 10px;
|
||||
}
|
||||
.card p { color: var(--text-muted); font-size: 14px; line-height: 1.6; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
|
||||
.stat {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 18px 20px;
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--text-muted); font-size: 10px;
|
||||
letter-spacing: 0.22em; text-transform: uppercase; font-weight: 700;
|
||||
}
|
||||
.stat-value {
|
||||
font-family: 'Newsreader', serif; font-size: 30px;
|
||||
color: var(--accent-dark); font-weight: 700; margin-top: 6px;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.field label {
|
||||
font-size: 12px; font-weight: 700; color: var(--accent-dark);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.field input, .field textarea, .field select {
|
||||
width: 100%; padding: 11px 13px; font-size: 14px;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
background: #fff; font-family: inherit; color: var(--text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.field input:focus, .field textarea:focus, .field select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(139, 115, 61, 0.15);
|
||||
outline: none;
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 80px; }
|
||||
.field small { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 18px; font-size: 13px; font-weight: 700;
|
||||
background: var(--accent-dark); color: #fff;
|
||||
border: none; border-radius: 9999px;
|
||||
font-family: inherit; cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn.btn-ghost { background: transparent; color: var(--accent-dark); border: 1px solid var(--border); }
|
||||
.btn.btn-ghost:hover:not(:disabled) { background: var(--panel-muted); }
|
||||
.btn.btn-danger { background: var(--danger); }
|
||||
.btn.btn-sm { padding: 6px 12px; font-size: 11px; }
|
||||
.btn .material-symbols-outlined { font-size: 16px; }
|
||||
|
||||
.section-divider {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin: 28px 0 14px;
|
||||
}
|
||||
.section-divider h2 {
|
||||
font-family: 'Newsreader', serif; font-size: 22px;
|
||||
color: var(--accent-dark); font-weight: 700;
|
||||
}
|
||||
.section-divider .count {
|
||||
background: rgba(0, 29, 54, 0.06); color: var(--accent-dark);
|
||||
padding: 3px 10px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
|
||||
.score-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 6px; }
|
||||
.score-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 9999px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.03em;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.score-chip.seo-good, .score-chip.geo-good { background: rgba(30, 122, 60, 0.08); color: var(--ok); border-color: rgba(30, 122, 60, 0.22); }
|
||||
.score-chip.seo-warn, .score-chip.geo-warn { background: rgba(176, 106, 16, 0.08); color: var(--warn); border-color: rgba(176, 106, 16, 0.22); }
|
||||
.score-chip.seo-bad, .score-chip.geo-bad { background: rgba(176, 48, 48, 0.08); color: var(--danger); border-color: rgba(176, 48, 48, 0.22); }
|
||||
|
||||
.tip-list {
|
||||
list-style: none; padding: 0; margin: 8px 0 0;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.tip-list li {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
padding-left: 14px; position: relative; line-height: 1.5;
|
||||
}
|
||||
.tip-list li::before { content: '•'; position: absolute; left: 0; color: var(--accent); }
|
||||
|
||||
.slot-grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
|
||||
.slot-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 18px;
|
||||
}
|
||||
.slot-card header {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.slot-card .slot-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
}
|
||||
.slot-card .slot-label { font-size: 13px; font-weight: 700; color: var(--accent-dark); }
|
||||
|
||||
.alert-row {
|
||||
background: rgba(30, 122, 60, 0.08); color: var(--ok);
|
||||
border: 1px solid rgba(30, 122, 60, 0.18);
|
||||
padding: 10px 14px; border-radius: 10px;
|
||||
font-size: 13px; font-weight: 700;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.alert-row.err {
|
||||
background: rgba(176, 48, 48, 0.08); color: var(--danger);
|
||||
border-color: rgba(176, 48, 48, 0.18);
|
||||
}
|
||||
|
||||
.sub-table {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; overflow: hidden;
|
||||
}
|
||||
.sub-row {
|
||||
display: grid; grid-template-columns: 180px 1fr 180px 120px;
|
||||
padding: 14px 18px; gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sub-row:last-child { border-bottom: none; }
|
||||
.sub-row header {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.sub-row time {
|
||||
color: var(--text-muted); font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700;
|
||||
}
|
||||
.sub-row .name { color: var(--accent-dark); font-weight: 700; font-size: 14px; }
|
||||
.sub-row .dog { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.sub-row .msg { font-size: 14px; color: var(--text); line-height: 1.55; white-space: pre-wrap; }
|
||||
.sub-row .meta { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
.image-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.image-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 16px;
|
||||
}
|
||||
.image-card .preview {
|
||||
background: var(--panel-muted); border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.image-card .preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.image-card header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
|
||||
.image-card .slot-id { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; color: var(--text-muted); }
|
||||
.image-card .slot-label { font-size: 13px; font-weight: 700; color: var(--accent-dark); }
|
||||
.image-card .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--panel-muted);
|
||||
padding: 32px;
|
||||
}
|
||||
.login-card {
|
||||
background: #fff; border: 1px solid var(--border);
|
||||
padding: 40px; border-radius: 20px;
|
||||
max-width: 400px; width: 100%;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.login-card h1 {
|
||||
font-family: 'Newsreader', serif;
|
||||
color: var(--accent-dark); font-size: 26px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-card p { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }
|
||||
.login-card .btn { width: 100%; justify-content: center; padding: 14px; }
|
||||
|
||||
.warranty-status {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 18px; border-radius: 14px;
|
||||
background: rgba(30, 122, 60, 0.08); color: var(--ok);
|
||||
border: 1px solid rgba(30, 122, 60, 0.22);
|
||||
}
|
||||
.warranty-status .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(30, 122, 60, 0.15); }
|
||||
.warranty-status strong { font-size: 15px; }
|
||||
.warranty-status small { display: block; color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
.cover-table { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 14px; }
|
||||
.cover-col {
|
||||
padding: 18px; border-radius: 14px;
|
||||
border: 1px solid var(--border); background: var(--panel);
|
||||
}
|
||||
.cover-col.yes { border-color: rgba(30, 122, 60, 0.22); background: rgba(30, 122, 60, 0.04); }
|
||||
.cover-col.no { border-color: rgba(176, 48, 48, 0.22); background: rgba(176, 48, 48, 0.04); }
|
||||
.cover-col h3 { font-size: 14px; margin-bottom: 10px; }
|
||||
.cover-col ul { margin: 0; padding-left: 18px; }
|
||||
.cover-col li { font-size: 13px; color: var(--text); line-height: 1.6; margin-bottom: 4px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-shell { grid-template-columns: 1fr; }
|
||||
.admin-side { position: relative; height: auto; }
|
||||
.grid-2, .grid-3, .grid-4, .image-grid, .cover-table { grid-template-columns: 1fr; }
|
||||
.sub-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* ===== RESET & BASE ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--navy: #001d36;
|
||||
--gold: #8B733D;
|
||||
--amber: rgb(225, 173, 1);
|
||||
--cream: #EBE8DE;
|
||||
--cream-light: #F1F0E9;
|
||||
--offwhite: #fbf9f4;
|
||||
--dark: #1c1c1a;
|
||||
--muted: #42484e;
|
||||
--outline-soft: rgba(194, 200, 192, 0.25);
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
background: var(--offwhite);
|
||||
color: var(--dark);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
button { cursor: pointer; border: none; font-family: inherit; background: none; }
|
||||
input, textarea { font-family: inherit; outline: none; }
|
||||
@@ -0,0 +1,10 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
admin: { username: string } | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&family=Manrope:wght@200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser, seedDefaultAdminIfMissing } from '$lib/server/auth';
|
||||
|
||||
const seed = seedDefaultAdminIfMissing();
|
||||
if (seed.tempPassword) {
|
||||
console.log(
|
||||
`[admin] seeded default admin: username="${seed.username}" password="${seed.tempPassword}" (change it from /admin)`
|
||||
);
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.admin = getSessionUser(event.cookies);
|
||||
|
||||
const p = event.url.pathname;
|
||||
const isAdminArea = p === '/admin' || p.startsWith('/admin/');
|
||||
const isLogin = p === '/admin/login';
|
||||
|
||||
if (isAdminArea && !isLogin && !event.locals.admin) {
|
||||
throw redirect(303, '/admin/login');
|
||||
}
|
||||
if (isLogin && event.locals.admin) {
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
export type CopySlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
section: string;
|
||||
hint?: string;
|
||||
kind: 'inline' | 'multiline';
|
||||
};
|
||||
|
||||
export type ImageSlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export type Testimonial = {
|
||||
id: string;
|
||||
text: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatarSrc: string;
|
||||
avatarAlt: string;
|
||||
};
|
||||
|
||||
export type SiteContent = {
|
||||
copy: Record<string, string>;
|
||||
images: Record<string, ImageSlot>;
|
||||
testimonials: Testimonial[];
|
||||
seo: {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
ogImage: string;
|
||||
primaryKeywords: string[];
|
||||
geoRegion: string;
|
||||
geoPlacename: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOTS: CopySlot[] = [
|
||||
{ id: 'nav.brand', section: 'Navbar', label: 'Brand name', kind: 'inline', value: 'Giampy Dog Service' },
|
||||
{ id: 'nav.link.servizi', section: 'Navbar', label: 'Link — Servizi', kind: 'inline', value: 'Servizi' },
|
||||
{ id: 'nav.link.chi_sono', section: 'Navbar', label: 'Link — Chi Sono', kind: 'inline', value: 'Chi Sono' },
|
||||
{ id: 'nav.link.testimonianze', section: 'Navbar', label: 'Link — Testimonianze', kind: 'inline', value: 'Testimonianze' },
|
||||
{ id: 'nav.link.dogwash', section: 'Navbar', label: 'Link — DogWash', kind: 'inline', value: 'DogWash' },
|
||||
{ id: 'nav.cta', section: 'Navbar', label: 'CTA', kind: 'inline', value: 'Scrivimi' },
|
||||
|
||||
{ id: 'hero.kicker', section: 'Hero', label: 'Kicker', kind: 'inline', value: 'Con te dal 2018' },
|
||||
{ id: 'hero.title_prefix', section: 'Hero', label: 'Title — first line', kind: 'inline', value: 'Tratto il tuo cane' },
|
||||
{ id: 'hero.title_suffix', section: 'Hero', label: 'Title — second line (before emphasis)', kind: 'inline', value: 'come se fosse' },
|
||||
{ id: 'hero.title_em', section: 'Hero', label: 'Title — emphasis', kind: 'inline', value: 'il mio.' },
|
||||
{ id: 'hero.desc', section: 'Hero', label: 'Description', kind: 'multiline', value: 'Sono Giampiero, educatore cinofilo certificato CONI. Mi occupo personalmente di ogni cane che mi affidi — niente gabbie, niente solitudine, solo tanto amore vero.' },
|
||||
{ id: 'hero.cta_primary', section: 'Hero', label: 'Primary CTA', kind: 'inline', value: 'Scopri i Servizi' },
|
||||
{ id: 'hero.cta_outline', section: 'Hero', label: 'Outline CTA', kind: 'inline', value: 'Scrivimi ora' },
|
||||
{ id: 'hero.badge_label', section: 'Hero', label: 'Badge label', kind: 'inline', value: '5.0 Recensioni' },
|
||||
{ id: 'hero.badge_text', section: 'Hero', label: 'Badge text', kind: 'inline', value: 'Il punto di riferimento per i proprietari di cani a Sassari.' },
|
||||
{ id: 'hero.stat1_num', section: 'Hero', label: 'Stat 1 — value', kind: 'inline', value: '100+' },
|
||||
{ id: 'hero.stat1_label', section: 'Hero', label: 'Stat 1 — label', kind: 'inline', value: 'Clienti Felici' },
|
||||
{ id: 'hero.stat2_num', section: 'Hero', label: 'Stat 2 — value', kind: 'inline', value: '2018' },
|
||||
{ id: 'hero.stat2_label', section: 'Hero', label: 'Stat 2 — label', kind: 'inline', value: 'Anno di inizio' },
|
||||
{ id: 'hero.stat3_num', section: 'Hero', label: 'Stat 3 — value', kind: 'inline', value: '5.0★' },
|
||||
{ id: 'hero.stat3_label', section: 'Hero', label: 'Stat 3 — label', kind: 'inline', value: 'Recensioni' },
|
||||
|
||||
{ id: 'why.kicker', section: 'Why', label: 'Kicker', kind: 'inline', value: 'Perché scegliere me' },
|
||||
{ id: 'why.title_1', section: 'Why', label: 'Title — line 1', kind: 'inline', value: 'Qualcosa di diverso,' },
|
||||
{ id: 'why.title_2', section: 'Why', label: 'Title — line 2', kind: 'inline', value: 'lo senti anche tu.' },
|
||||
{ id: 'why.sub', section: 'Why', label: 'Subtitle', kind: 'inline', value: 'Non ho una squadra — ho me stesso, presente ogni giorno.' },
|
||||
{ id: 'why.card1_title', section: 'Why', label: 'Card 1 — title', kind: 'inline', value: 'Dal 2018, ogni giorno' },
|
||||
{ id: 'why.card1_text', section: 'Why', label: 'Card 1 — text', kind: 'multiline', value: 'Ho iniziato per passione e non mi sono più fermato. Anni di esperienza vera, sul campo, costruita cane dopo cane.' },
|
||||
{ id: 'why.card2_title', section: 'Why', label: 'Card 2 — title', kind: 'inline', value: 'Un rapporto vero' },
|
||||
{ id: 'why.card2_text', section: 'Why', label: 'Card 2 — text', kind: 'multiline', value: 'Con te e con il tuo cane costruisco un legame di fiducia. Non sei un cliente, sei parte della mia famiglia pelosa.' },
|
||||
{ id: 'why.card3_title', section: 'Why', label: 'Card 3 — title', kind: 'inline', value: 'Preparato, non improvvisato' },
|
||||
{ id: 'why.card3_text', section: 'Why', label: 'Card 3 — text', kind: 'multiline', value: 'Certificazione CONI e formazione continua. Porto metodo e competenza, con la leggerezza di chi ama davvero quello che fa.' },
|
||||
{ id: 'why.card4_title', section: 'Why', label: 'Card 4 — title', kind: 'inline', value: 'Zero gabbie, promesso' },
|
||||
{ id: 'why.card4_text', section: 'Why', label: 'Card 4 — text', kind: 'multiline', value: 'Non le uso, non le tollero. Libertà e comfort sono la base di tutto quello che faccio — sempre.' },
|
||||
|
||||
{ id: 'services.kicker', section: 'Services', label: 'Kicker', kind: 'inline', value: 'Cosa faccio per te' },
|
||||
{ id: 'services.title', section: 'Services', label: 'Title', kind: 'inline', value: 'I miei servizi' },
|
||||
{ id: 'services.sub', section: 'Services', label: 'Subtitle', kind: 'inline', value: 'Ho pensato a tutto io — tu pensa solo a coccolarlo.' },
|
||||
{ id: 'services.card1_tag', section: 'Services', label: 'Card 1 — tag', kind: 'inline', value: 'Movimento & Gioia' },
|
||||
{ id: 'services.card1_title', section: 'Services', label: 'Card 1 — title', kind: 'inline', value: 'Dogwalking' },
|
||||
{ id: 'services.card1_text', section: 'Services', label: 'Card 1 — text', kind: 'multiline', value: "Passeggiate esplorative nei sentieri più belli di Sassari. Ogni uscita è un'avventura pensata per stimolare mente e corpo del tuo cane." },
|
||||
{ id: 'services.card1_cta', section: 'Services', label: 'Card 1 — CTA', kind: 'inline', value: 'Prenota una passeggiata' },
|
||||
{ id: 'services.card2_tag', section: 'Services', label: 'Card 2 — tag', kind: 'inline', value: 'Come a casa' },
|
||||
{ id: 'services.card2_title', section: 'Services', label: 'Card 2 — title', kind: 'inline', value: 'Home Boarding' },
|
||||
{ id: 'services.card2_text', section: 'Services', label: 'Card 2 — text', kind: 'multiline', value: 'Quando sei via, il tuo cane sta con me — in appartamento, libero, coccolato. Ogni giorno ti mando foto e aggiornamenti.' },
|
||||
{ id: 'services.card2_cta', section: 'Services', label: 'Card 2 — CTA', kind: 'inline', value: 'Chiedi disponibilità' },
|
||||
{ id: 'services.card3_tag', section: 'Services', label: 'Card 3 — tag', kind: 'inline', value: 'Giorni speciali' },
|
||||
{ id: 'services.card3_title', section: 'Services', label: 'Card 3 — title', kind: 'inline', value: 'Wedding Dogsitter' },
|
||||
{ id: 'services.card3_text', section: 'Services', label: 'Card 3 — text', kind: 'multiline', value: 'Il tuo migliore amico nel tuo giorno più bello. Me ne occupo io, così tu puoi goderti ogni momento senza pensieri.' },
|
||||
{ id: 'services.card3_cta', section: 'Services', label: 'Card 3 — CTA', kind: 'inline', value: 'Parliamo del tuo evento' },
|
||||
|
||||
{ id: 'about.quote', section: 'About', label: 'Quote', kind: 'multiline', value: '"Rusty non era solo un cane, era il maestro che mi ha indicato la strada."' },
|
||||
{ id: 'about.cite', section: 'About', label: 'Cite', kind: 'inline', value: '— Giampiero Scaglione' },
|
||||
{ id: 'about.kicker', section: 'About', label: 'Kicker', kind: 'inline', value: 'Chi sono' },
|
||||
{ id: 'about.title_1', section: 'About', label: 'Title — line 1', kind: 'inline', value: 'Da una necessità' },
|
||||
{ id: 'about.title_2', section: 'About', label: 'Title — line 2', kind: 'inline', value: 'del cuore, a una' },
|
||||
{ id: 'about.title_3', section: 'About', label: 'Title — line 3', kind: 'inline', value: 'missione di vita.' },
|
||||
{ id: 'about.body_1', section: 'About', label: 'Body — paragraph 1', kind: 'multiline', value: 'Tutto è partito da Rusty — il mitico Rustone. Quello che era iniziato come un lavoretto si è trasformato in una vocazione. Ho capito che non volevo fare altro nella vita.' },
|
||||
{ id: 'about.body_2', section: 'About', label: 'Body — paragraph 2', kind: 'multiline', value: "Ho preso la certificazione CONI, ho camminato il Cammino di Santiago con il mio cane, e ogni giorno continuo a imparare — dai cani, dai loro umani, dalla vita all'aperto." },
|
||||
{ id: 'about.mile1_title', section: 'About', label: 'Milestone 1 — title', kind: 'inline', value: 'Cammino di Santiago' },
|
||||
{ id: 'about.mile1_text', section: 'About', label: 'Milestone 1 — text', kind: 'inline', value: 'Percorso a piedi con il mio cane. La prova più bella di tutto.' },
|
||||
{ id: 'about.mile2_title', section: 'About', label: 'Milestone 2 — title', kind: 'inline', value: 'Certificazione CONI' },
|
||||
{ id: 'about.mile2_text', section: 'About', label: 'Milestone 2 — text', kind: 'inline', value: "Formazione d'eccellenza in ambito cinofilo. Studio ancora." },
|
||||
|
||||
{ id: 'media.label', section: 'Media', label: 'Label', kind: 'inline', value: 'Riconoscimenti & Media' },
|
||||
{ id: 'media.logo_1', section: 'Media', label: 'Logo 1', kind: 'inline', value: "L'Unione Sarda" },
|
||||
{ id: 'media.logo_2', section: 'Media', label: 'Logo 2', kind: 'inline', value: 'Sardegna Reporter' },
|
||||
{ id: 'media.logo_3', section: 'Media', label: 'Logo 3', kind: 'inline', value: 'Radio Super Sound' },
|
||||
{ id: 'media.logo_4', section: 'Media', label: 'Logo 4', kind: 'inline', value: 'Qualifica CONI' },
|
||||
{ id: 'media.logo_5', section: 'Media', label: 'Logo 5', kind: 'inline', value: 'Corsa in Rosa' },
|
||||
{ id: 'media.logo_6', section: 'Media', label: 'Logo 6', kind: 'inline', value: 'Giornata del Super Cane' },
|
||||
|
||||
{ id: 'dogwash.tag', section: 'DogWash', label: 'Tag', kind: 'inline', value: 'Novità in Sardegna' },
|
||||
{ id: 'dogwash.title', section: 'DogWash', label: 'Title', kind: 'inline', value: 'DogWash Sardegna' },
|
||||
{ id: 'dogwash.desc', section: 'DogWash', label: 'Description', kind: 'multiline', value: 'Ho avuto un sogno: portare le colonnine di lavaggio automatico in tutta la Sardegna. Grazie alla fiducia della community, quel sogno sta diventando realtà, una colonnina alla volta.' },
|
||||
{ id: 'dogwash.cta', section: 'DogWash', label: 'CTA', kind: 'inline', value: 'Trova la postazione più vicina' },
|
||||
{ id: 'dogwash.community', section: 'DogWash', label: 'Community note', kind: 'inline', value: 'Un progetto nato dal cuore di Sassari.' },
|
||||
|
||||
{ id: 'testi.stats1_num', section: 'Testimonials', label: 'Stat 1 — value', kind: 'inline', value: '100+' },
|
||||
{ id: 'testi.stats1_label', section: 'Testimonials', label: 'Stat 1 — label', kind: 'inline', value: 'Clienti Felici' },
|
||||
{ id: 'testi.stats2_num', section: 'Testimonials', label: 'Stat 2 — value', kind: 'inline', value: '2018' },
|
||||
{ id: 'testi.stats2_label', section: 'Testimonials', label: 'Stat 2 — label', kind: 'inline', value: 'Anno di inizio' },
|
||||
{ id: 'testi.kicker', section: 'Testimonials', label: 'Kicker', kind: 'inline', value: 'Cosa dicono di me' },
|
||||
{ id: 'testi.title', section: 'Testimonials', label: 'Title', kind: 'inline', value: 'I miei ospiti parlano.' },
|
||||
{ id: 'testi.sub', section: 'Testimonials', label: 'Subtitle', kind: 'inline', value: 'Non solo clienti — una vera famiglia pelosa.' },
|
||||
|
||||
{ id: 'contact.headline', section: 'Contact', label: 'Headline', kind: 'inline', value: 'Affida il tuo cane a chi lo vive davvero.' },
|
||||
{ id: 'contact.form_title', section: 'Contact', label: 'Form — title', kind: 'inline', value: 'Scrivimi ora' },
|
||||
{ id: 'contact.form_sub', section: 'Contact', label: 'Form — subtitle', kind: 'inline', value: 'Hai domande o vuoi prenotare? Ti rispondo di persona entro 24 ore.' },
|
||||
{ id: 'contact.label_name', section: 'Contact', label: 'Label — Name', kind: 'inline', value: 'Il tuo Nome' },
|
||||
{ id: 'contact.placeholder_name', section: 'Contact', label: 'Placeholder — Name', kind: 'inline', value: 'Nome e Cognome' },
|
||||
{ id: 'contact.label_dog', section: 'Contact', label: 'Label — Dog', kind: 'inline', value: 'Come si chiama il tuo cane?' },
|
||||
{ id: 'contact.placeholder_dog', section: 'Contact', label: 'Placeholder — Dog', kind: 'inline', value: 'Il nome del peloso...' },
|
||||
{ id: 'contact.label_msg', section: 'Contact', label: 'Label — Message', kind: 'inline', value: 'Il tuo messaggio' },
|
||||
{ id: 'contact.placeholder_msg', section: 'Contact', label: 'Placeholder — Message', kind: 'inline', value: 'Raccontami di te e del tuo cane...' },
|
||||
{ id: 'contact.submit', section: 'Contact', label: 'Submit button', kind: 'inline', value: 'Invia il messaggio' },
|
||||
{ id: 'contact.success', section: 'Contact', label: 'Success message', kind: 'inline', value: 'Perfetto! Ti scrivo presto.' },
|
||||
{ id: 'contact.info_title', section: 'Contact', label: 'Info title', kind: 'inline', value: 'Contatti rapidi' },
|
||||
{ id: 'contact.phone_label', section: 'Contact', label: 'Phone label', kind: 'inline', value: 'Chiamami' },
|
||||
{ id: 'contact.phone_value', section: 'Contact', label: 'Phone value', kind: 'inline', value: '+39 347 123 4567' },
|
||||
{ id: 'contact.email_label', section: 'Contact', label: 'Email label', kind: 'inline', value: 'Email' },
|
||||
{ id: 'contact.email_value', section: 'Contact', label: 'Email value', kind: 'inline', value: 'info@giampydogservice.it' },
|
||||
{ id: 'contact.wa_label', section: 'Contact', label: 'WhatsApp label', kind: 'inline', value: 'WhatsApp' },
|
||||
{ id: 'contact.wa_value', section: 'Contact', label: 'WhatsApp value', kind: 'inline', value: 'Chatta direttamente con me' },
|
||||
|
||||
{ id: 'footer.tagline', section: 'Footer', label: 'Tagline', kind: 'multiline', value: 'Mi prendo cura dei tuoi amici come fossero i miei. Dal 2018, ogni giorno, con passione.' },
|
||||
{ id: 'footer.col1_title', section: 'Footer', label: 'Column 1 — title', kind: 'inline', value: 'Servizi' },
|
||||
{ id: 'footer.col1_l1', section: 'Footer', label: 'Column 1 — link 1', kind: 'inline', value: 'Dogwalking' },
|
||||
{ id: 'footer.col1_l2', section: 'Footer', label: 'Column 1 — link 2', kind: 'inline', value: 'Home Boarding' },
|
||||
{ id: 'footer.col1_l3', section: 'Footer', label: 'Column 1 — link 3', kind: 'inline', value: 'Wedding Dogsitter' },
|
||||
{ id: 'footer.col1_l4', section: 'Footer', label: 'Column 1 — link 4', kind: 'inline', value: 'DogWash' },
|
||||
{ id: 'footer.col2_title', section: 'Footer', label: 'Column 2 — title', kind: 'inline', value: 'Link Utili' },
|
||||
{ id: 'footer.col2_l1', section: 'Footer', label: 'Column 2 — link 1', kind: 'inline', value: 'Chi Sono' },
|
||||
{ id: 'footer.col2_l2', section: 'Footer', label: 'Column 2 — link 2', kind: 'inline', value: 'Testimonianze' },
|
||||
{ id: 'footer.col2_l3', section: 'Footer', label: 'Column 2 — link 3', kind: 'inline', value: 'Privacy Policy' },
|
||||
{ id: 'footer.col2_l4', section: 'Footer', label: 'Column 2 — link 4', kind: 'inline', value: 'Termini e Condizioni' },
|
||||
{ id: 'footer.copyright', section: 'Footer', label: 'Copyright', kind: 'inline', value: '© 2024 Giampy Dog Service — Sassari, Sardegna' },
|
||||
{ id: 'footer.powered', section: 'Footer', label: 'Powered by', kind: 'inline', value: 'Powered by' }
|
||||
];
|
||||
|
||||
export const DEFAULT_IMAGES: Record<string, ImageSlot> = {
|
||||
favicon: { id: 'favicon', label: 'Favicon (icona browser)', src: '/img/logo.png', alt: 'Giampy Dog Service' },
|
||||
logo: { id: 'logo', label: 'Brand logo', src: '/img/logo.png', alt: 'Giampy Dog Service Logo' },
|
||||
hero: { id: 'hero', label: 'Hero photo', src: '/img/hero-image.png', alt: 'Giampy con un cane' },
|
||||
chi_sono: { id: 'chi_sono', label: 'About photo', src: '/img/chi-sono.png', alt: 'Giampy passeggia con un cane' },
|
||||
dogwash: { id: 'dogwash', label: 'DogWash visual', src: '/img/colonnina.png', alt: 'DogWash Machine' },
|
||||
cima_logo: { id: 'cima_logo', label: 'Cima Progetti logo', src: '/img/cima-logo.svg', alt: 'Cima Progetti' }
|
||||
};
|
||||
|
||||
export const DEFAULT_TESTIMONIALS: Testimonial[] = [
|
||||
{
|
||||
id: 't_marta',
|
||||
text: "Giampiero è l'unico di cui mi fido ciecamente. Il mio Max torna sempre felice e rilassato. Non cambierei mai!",
|
||||
name: 'Marta S.',
|
||||
role: 'Mamma di Max',
|
||||
avatarSrc: '/img/avatar-1.png',
|
||||
avatarAlt: 'Marta S.'
|
||||
},
|
||||
{
|
||||
id: 't_luca',
|
||||
text: 'Ha gestito il nostro Luna durante il matrimonio in modo impeccabile. Eravamo tranquilli, sapevamo che era in mani fantastiche.',
|
||||
name: 'Luca & Elena',
|
||||
role: 'Genitori di Luna',
|
||||
avatarSrc: '/img/avatar-2.png',
|
||||
avatarAlt: 'Luca & Elena'
|
||||
},
|
||||
{
|
||||
id: 't_sara',
|
||||
text: 'Niente gabbie, tanto amore. Ogni giorno mi mandava foto di Felix felice. La pensione in appartamento che sognavo!',
|
||||
name: 'Sara P.',
|
||||
role: 'Compagna di Felix',
|
||||
avatarSrc: '/img/avatar-3.png',
|
||||
avatarAlt: 'Sara P.'
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SEO = {
|
||||
title: 'Giampy Dog Service — Il tuo cane in buone mani',
|
||||
description:
|
||||
'Educatore cinofilo certificato CONI a Sassari. Dogwalking, home boarding e wedding dogsitter. Zero gabbie, solo amore vero dal 2018.',
|
||||
keywords:
|
||||
'dogsitter sassari, dogwalker sardegna, educatore cinofilo, home boarding cani, wedding dogsitter, dog wash sardegna',
|
||||
ogImage: '/img/hero-image.png',
|
||||
primaryKeywords: ['dogsitter sassari', 'dogwalker sardegna', 'educatore cinofilo coni'],
|
||||
geoRegion: 'IT-SS',
|
||||
geoPlacename: 'Sassari, Sardegna'
|
||||
};
|
||||
|
||||
export function buildDefaultContent(): SiteContent {
|
||||
const copy: Record<string, string> = {};
|
||||
for (const s of DEFAULT_SLOTS) copy[s.id] = s.value;
|
||||
return {
|
||||
copy,
|
||||
images: { ...DEFAULT_IMAGES },
|
||||
testimonials: DEFAULT_TESTIMONIALS.map((t) => ({ ...t })),
|
||||
seo: { ...DEFAULT_SEO }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export type CopyAnalysis = {
|
||||
length: number;
|
||||
words: number;
|
||||
sentences: number;
|
||||
avgWordsPerSentence: number;
|
||||
readability: number; // 0..100, higher = easier
|
||||
keywordMatches: string[];
|
||||
missingKeywords: string[];
|
||||
seoScore: number; // 0..100
|
||||
geoScore: number; // 0..100
|
||||
seoTips: string[];
|
||||
geoTips: string[];
|
||||
};
|
||||
|
||||
const SYLLABLE_VOWELS = /[aeiouàèéìòùAEIOUÀÈÉÌÒÙ]+/g;
|
||||
|
||||
function countSyllables(word: string): number {
|
||||
const matches = word.match(SYLLABLE_VOWELS);
|
||||
return matches ? matches.length : 1;
|
||||
}
|
||||
|
||||
export function analyzeCopy(text: string, primaryKeywords: string[] = []): CopyAnalysis {
|
||||
const clean = text.trim();
|
||||
const length = clean.length;
|
||||
const wordList = clean.split(/\s+/).filter(Boolean);
|
||||
const words = wordList.length;
|
||||
const sentences = Math.max(1, (clean.match(/[.!?]+/g) ?? []).length);
|
||||
const avgWordsPerSentence = words / sentences;
|
||||
|
||||
// Flesch reading ease approximation
|
||||
const syllables = wordList.reduce((n, w) => n + countSyllables(w), 0);
|
||||
const asl = words ? words / sentences : 0;
|
||||
const asw = words ? syllables / words : 0;
|
||||
const readability = Math.max(0, Math.min(100, Math.round(206.835 - 1.015 * asl - 84.6 * asw)));
|
||||
|
||||
const lower = clean.toLowerCase();
|
||||
const keywordMatches: string[] = [];
|
||||
const missingKeywords: string[] = [];
|
||||
for (const kw of primaryKeywords) {
|
||||
if (!kw) continue;
|
||||
if (lower.includes(kw.toLowerCase())) keywordMatches.push(kw);
|
||||
else missingKeywords.push(kw);
|
||||
}
|
||||
|
||||
// SEO tips
|
||||
const seoTips: string[] = [];
|
||||
if (length === 0) seoTips.push('Il testo è vuoto.');
|
||||
if (length > 0 && length < 40) seoTips.push('Testo molto breve: difficile farsi indicizzare.');
|
||||
if (length > 320) seoTips.push('Testo lungo: valuta di spezzarlo in paragrafi o bullet.');
|
||||
if (avgWordsPerSentence > 24) seoTips.push('Frasi troppo lunghe: scendi sotto le 24 parole per frase.');
|
||||
if (readability < 45) seoTips.push('Leggibilità bassa: semplifica lessico e sintassi.');
|
||||
if (missingKeywords.length && words > 4) {
|
||||
seoTips.push(`Manca una keyword primaria: ${missingKeywords.slice(0, 2).join(', ')}.`);
|
||||
}
|
||||
if (/[A-Z]{6,}/.test(clean)) seoTips.push('Evita testo tutto maiuscolo: penalizza la leggibilità.');
|
||||
|
||||
// GEO tips (Generative Engine Optimization)
|
||||
const geoTips: string[] = [];
|
||||
const questionMarks = (clean.match(/\?/g) ?? []).length;
|
||||
const hasNumbers = /\d/.test(clean);
|
||||
const hasEntities = /\b(sassari|sardegna|coni|cammino di santiago|giampy)\b/i.test(clean);
|
||||
const firstPerson = /\b(io|mio|mia|miei|mie|sono|faccio|porto|prendo)\b/i.test(clean);
|
||||
const declarative = sentences >= 1 && /[.!]/.test(clean);
|
||||
|
||||
if (words >= 20 && questionMarks === 0)
|
||||
geoTips.push('Aggiungi una domanda esplicita per farti citare come risposta diretta.');
|
||||
if (!hasNumbers && words >= 20)
|
||||
geoTips.push('Inserisci dati concreti (anni, numero clienti, distanze) per aumentare la citabilità.');
|
||||
if (!hasEntities && words >= 15)
|
||||
geoTips.push('Nomina entità specifiche (luogo, certificazione, brand) per il grounding generativo.');
|
||||
if (!firstPerson && words >= 20)
|
||||
geoTips.push('Usa la prima persona: i motori generativi estraggono più facilmente testimonianze autoriali.');
|
||||
if (!declarative && words >= 10)
|
||||
geoTips.push('Termina con punto fermo: le frasi complete vengono citate più spesso.');
|
||||
|
||||
// Scores
|
||||
let seoScore = 100;
|
||||
seoScore -= seoTips.length * 12;
|
||||
if (keywordMatches.length === 0 && primaryKeywords.length && words > 4) seoScore -= 10;
|
||||
if (readability >= 60) seoScore += 5;
|
||||
seoScore = Math.max(0, Math.min(100, seoScore));
|
||||
|
||||
let geoScore = 100;
|
||||
geoScore -= geoTips.length * 14;
|
||||
if (hasEntities) geoScore += 5;
|
||||
if (hasNumbers) geoScore += 3;
|
||||
geoScore = Math.max(0, Math.min(100, geoScore));
|
||||
|
||||
return {
|
||||
length,
|
||||
words,
|
||||
sentences,
|
||||
avgWordsPerSentence: Math.round(avgWordsPerSentence * 10) / 10,
|
||||
readability,
|
||||
keywordMatches,
|
||||
missingKeywords,
|
||||
seoScore,
|
||||
geoScore,
|
||||
seoTips,
|
||||
geoTips
|
||||
};
|
||||
}
|
||||
|
||||
export function aggregateScore(analyses: CopyAnalysis[]): { seo: number; geo: number } {
|
||||
if (!analyses.length) return { seo: 0, geo: 0 };
|
||||
const seo = Math.round(analyses.reduce((a, x) => a + x.seoScore, 0) / analyses.length);
|
||||
const geo = Math.round(analyses.reduce((a, x) => a + x.geoScore, 0) / analyses.length);
|
||||
return { seo, geo };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
const ADMIN_FILE = 'admin.json';
|
||||
const SESSION_FILE = 'sessions.json';
|
||||
const SESSION_COOKIE = 'gds_admin';
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
type AdminRecord = {
|
||||
username: string;
|
||||
salt: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
token: string;
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function hashPassword(password: string, salt: string): string {
|
||||
return scryptSync(password, salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
export function seedDefaultAdminIfMissing(): { username: string; tempPassword?: string } {
|
||||
const existing = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (existing) return { username: existing.username };
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'changeme';
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const record: AdminRecord = { username, salt, hash: hashPassword(password, salt) };
|
||||
writeJson(ADMIN_FILE, record);
|
||||
return { username, tempPassword: password };
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (record.username !== username) return false;
|
||||
const candidate = Buffer.from(hashPassword(password, record.salt), 'hex');
|
||||
const stored = Buffer.from(record.hash, 'hex');
|
||||
if (candidate.length !== stored.length) return false;
|
||||
return timingSafeEqual(candidate, stored);
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (!verifyCredentials(record.username, currentPassword)) return false;
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
writeJson(ADMIN_FILE, {
|
||||
username: record.username,
|
||||
salt,
|
||||
hash: hashPassword(newPassword, salt)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadSessions(): SessionRecord[] {
|
||||
const now = Date.now();
|
||||
const all = readJson<SessionRecord[]>(SESSION_FILE, []);
|
||||
const live = all.filter((s) => s.expiresAt > now);
|
||||
if (live.length !== all.length) writeJson(SESSION_FILE, live);
|
||||
return live;
|
||||
}
|
||||
|
||||
export function createSession(username: string, cookies: Cookies): string {
|
||||
const sessions = loadSessions();
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const record: SessionRecord = { token, username, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
sessions.push(record);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
cookies.set(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_TTL_MS / 1000
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export function destroySession(cookies: Cookies): void {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (token) {
|
||||
const sessions = loadSessions().filter((s) => s.token !== token);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
}
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function getSessionUser(cookies: Cookies): { username: string } | null {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (!token) return null;
|
||||
const session = loadSessions().find((s) => s.token === token);
|
||||
if (!session) return null;
|
||||
return { username: session.username };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
buildDefaultContent,
|
||||
DEFAULT_IMAGES,
|
||||
DEFAULT_SLOTS,
|
||||
type SiteContent,
|
||||
type ImageSlot,
|
||||
type Testimonial
|
||||
} from '$lib/content/defaults';
|
||||
|
||||
const FILE = 'content.json';
|
||||
|
||||
export function getContent(): SiteContent {
|
||||
const seed = buildDefaultContent();
|
||||
const stored = readJson<Partial<SiteContent>>(FILE, seed);
|
||||
return {
|
||||
copy: { ...seed.copy, ...(stored.copy ?? {}) },
|
||||
images: { ...seed.images, ...(stored.images ?? {}) },
|
||||
testimonials: stored.testimonials ?? seed.testimonials,
|
||||
seo: { ...seed.seo, ...(stored.seo ?? {}) }
|
||||
};
|
||||
}
|
||||
|
||||
export function setCopy(id: string, value: string): void {
|
||||
const c = getContent();
|
||||
c.copy[id] = value;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setCopyBulk(updates: Record<string, string>): void {
|
||||
const c = getContent();
|
||||
for (const [k, v] of Object.entries(updates)) c.copy[k] = v;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setImage(id: string, patch: Partial<ImageSlot>): ImageSlot {
|
||||
const c = getContent();
|
||||
const current = c.images[id] ?? DEFAULT_IMAGES[id];
|
||||
if (!current) throw new Error(`Unknown image slot: ${id}`);
|
||||
const next: ImageSlot = {
|
||||
id,
|
||||
label: current.label,
|
||||
src: patch.src ?? current.src,
|
||||
alt: patch.alt ?? current.alt
|
||||
};
|
||||
c.images[id] = next;
|
||||
writeJson(FILE, c);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetImage(id: string): ImageSlot {
|
||||
const c = getContent();
|
||||
const fallback = DEFAULT_IMAGES[id];
|
||||
if (!fallback) throw new Error(`Unknown image slot: ${id}`);
|
||||
c.images[id] = { ...fallback };
|
||||
writeJson(FILE, c);
|
||||
return c.images[id];
|
||||
}
|
||||
|
||||
export function setSeo(patch: Partial<SiteContent['seo']>): void {
|
||||
const c = getContent();
|
||||
c.seo = { ...c.seo, ...patch };
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function listSlots() {
|
||||
return DEFAULT_SLOTS;
|
||||
}
|
||||
|
||||
export function addTestimonial(input: Omit<Testimonial, 'id'>): Testimonial {
|
||||
const c = getContent();
|
||||
const entry: Testimonial = { id: `t_${randomUUID().slice(0, 8)}`, ...input };
|
||||
c.testimonials = [...c.testimonials, entry];
|
||||
writeJson(FILE, c);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function updateTestimonial(id: string, patch: Partial<Omit<Testimonial, 'id'>>): Testimonial | null {
|
||||
const c = getContent();
|
||||
let updated: Testimonial | null = null;
|
||||
c.testimonials = c.testimonials.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
updated = { ...t, ...patch };
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeJson(FILE, c);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteTestimonial(id: string): void {
|
||||
const c = getContent();
|
||||
c.testimonials = c.testimonials.filter((t) => t.id !== id);
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { analyzeCopy, aggregateScore } from '$lib/seo';
|
||||
import { getContent } from './content';
|
||||
|
||||
export function computeHealth(): { seo: number; geo: number } {
|
||||
const content = getContent();
|
||||
const analyses = DEFAULT_SLOTS
|
||||
.filter((s) => s.kind === 'multiline' || s.id.endsWith('_text'))
|
||||
.slice(0, 30)
|
||||
.map((s) => analyzeCopy(content.copy[s.id] ?? s.value, content.seo.primaryKeywords));
|
||||
return aggregateScore(analyses);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type NotificationType = 'submission' | 'seo_drop' | 'geo_drop';
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
const FILE = 'notifications.json';
|
||||
const MAX_KEEP = 200;
|
||||
|
||||
export function listNotifications(): Notification[] {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
return [...all].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function unreadCount(): number {
|
||||
return readJson<Notification[]>(FILE, []).filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
export function addNotification(input: Omit<Notification, 'id' | 'createdAt' | 'read'>): Notification {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
const entry: Notification = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
...input
|
||||
};
|
||||
all.push(entry);
|
||||
// Keep only the most recent MAX_KEEP
|
||||
const sorted = all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, MAX_KEEP);
|
||||
writeJson(FILE, sorted);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function markRead(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||||
}
|
||||
|
||||
export function markAllRead(): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => ({ ...n, read: true })));
|
||||
}
|
||||
|
||||
export function deleteNotification(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.filter((n) => n.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DATA_DIR = join(process.cwd(), 'data');
|
||||
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
export function dataPath(name: string): string {
|
||||
return join(DATA_DIR, name);
|
||||
}
|
||||
|
||||
export function readJson<T>(name: string, fallback: T): T {
|
||||
const p = dataPath(name);
|
||||
if (!existsSync(p)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson<T>(name: string, value: T): void {
|
||||
const p = dataPath(name);
|
||||
const dir = dirname(p);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8');
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type Submission = {
|
||||
id: string;
|
||||
name: string;
|
||||
dog: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
const FILE = 'submissions.json';
|
||||
|
||||
export function listSubmissions(): Submission[] {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
return [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function addSubmission(input: Omit<Submission, 'id' | 'createdAt'>): Submission {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
const entry: Submission = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...input
|
||||
};
|
||||
items.push(entry);
|
||||
writeJson(FILE, items);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function deleteSubmission(id: string): void {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
writeJson(FILE, items.filter((s) => s.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getContent } from '$lib/server/content';
|
||||
|
||||
export const load: LayoutServerLoad = () => {
|
||||
const images = getContent().images;
|
||||
return { favicon: images.favicon?.src ?? '/img/logo.png' };
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import type { LayoutData } from './$types';
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={data.favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { getContent } from '$lib/server/content';
|
||||
import { addSubmission } from '$lib/server/submissions';
|
||||
import { addNotification } from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { content: getContent() };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
contact: async ({ request, getClientAddress }) => {
|
||||
const data = await request.formData();
|
||||
const name = String(data.get('name') ?? '').trim();
|
||||
const dog = String(data.get('dog') ?? '').trim();
|
||||
const message = String(data.get('message') ?? '').trim();
|
||||
|
||||
if (!name || !message) {
|
||||
return fail(400, { error: 'Compila almeno nome e messaggio.', name, dog, message });
|
||||
}
|
||||
|
||||
const entry = addSubmission({ name, dog, message, ip: getClientAddress() });
|
||||
addNotification({
|
||||
type: 'submission',
|
||||
title: 'Nuova richiesta dal form',
|
||||
message: `${name}${dog ? ` (cane: ${dog})` : ''} ha inviato un messaggio.`,
|
||||
link: `/admin/submissions#${entry.id}`
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,398 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import '../site.css';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
const c = $derived(data.content.copy);
|
||||
const img = $derived(data.content.images);
|
||||
const seo = $derived(data.content.seo);
|
||||
const testimonials = $derived(data.content.testimonials);
|
||||
|
||||
let scrolled = $state(false);
|
||||
let submitted = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) submitted = true;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const onScroll = () => {
|
||||
scrolled = window.scrollY > 20;
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
|
||||
let cleanup = () => window.removeEventListener('scroll', onScroll);
|
||||
|
||||
// Leaflet — load on demand, client-only
|
||||
(async () => {
|
||||
const container = document.getElementById('map-container');
|
||||
if (!container) return;
|
||||
const leafletCss = document.createElement('link');
|
||||
leafletCss.rel = 'stylesheet';
|
||||
leafletCss.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(leafletCss);
|
||||
const L = (await import('leaflet')).default;
|
||||
const sassari: [number, number] = [40.7259, 8.5557];
|
||||
const map = L.map('map-container', {
|
||||
center: sassari,
|
||||
zoom: 13,
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: false
|
||||
});
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="background:#001d36;width:14px;height:14px;border-radius:50%;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.35);"></div>',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7]
|
||||
});
|
||||
L.marker(sassari, { icon })
|
||||
.addTo(map)
|
||||
.bindTooltip('Giampy Dog Service — Sassari', {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -10],
|
||||
className: 'map-marker-label'
|
||||
})
|
||||
.openTooltip();
|
||||
})();
|
||||
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{seo.title}</title>
|
||||
<meta name="description" content={seo.description} />
|
||||
<meta name="keywords" content={seo.keywords} />
|
||||
<meta name="geo.region" content={seo.geoRegion} />
|
||||
<meta name="geo.placename" content={seo.geoPlacename} />
|
||||
<meta property="og:title" content={seo.title} />
|
||||
<meta property="og:description" content={seo.description} />
|
||||
<meta property="og:image" content={seo.ogImage} />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="gds-nav" class:scrolled>
|
||||
<div class="nav-inner">
|
||||
<div class="nav-brand">
|
||||
<img alt={img.logo.alt} src={img.logo.src} />
|
||||
<span>{c['nav.brand']}</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#servizi">{c['nav.link.servizi']}</a>
|
||||
<a href="#chi-sono">{c['nav.link.chi_sono']}</a>
|
||||
<a href="#testimonianze">{c['nav.link.testimonianze']}</a>
|
||||
<a href="#dogwash">{c['nav.link.dogwash']}</a>
|
||||
<a href="#contatti" class="nav-cta">{c['nav.cta']}</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<header class="hero">
|
||||
<svg class="hero-paw hero-paw-1" aria-hidden="true" viewBox="0 0 11.667 11.083" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 1.458 5.25 C 1.05 5.25 0.705 5.109 0.423 4.827 C 0.141 4.545 0 4.2 0 3.792 C 0 3.383 0.141 3.038 0.423 2.756 C 0.705 2.474 1.05 2.333 1.458 2.333 C 1.867 2.333 2.212 2.474 2.494 2.756 C 2.776 3.038 2.917 3.383 2.917 3.792 C 2.917 4.2 2.776 4.545 2.494 4.827 C 2.212 5.109 1.867 5.25 1.458 5.25 Z M 4.083 2.917 C 3.675 2.917 3.33 2.776 3.048 2.494 C 2.766 2.212 2.625 1.867 2.625 1.458 C 2.625 1.05 2.766 0.705 3.048 0.423 C 3.33 0.141 3.675 0 4.083 0 C 4.492 0 4.837 0.141 5.119 0.423 C 5.401 0.705 5.542 1.05 5.542 1.458 C 5.542 1.867 5.401 2.212 5.119 2.494 C 4.837 2.776 4.492 2.917 4.083 2.917 Z M 7.583 2.917 C 7.175 2.917 6.83 2.776 6.548 2.494 C 6.266 2.212 6.125 1.867 6.125 1.458 C 6.125 1.05 6.266 0.705 6.548 0.423 C 6.83 0.141 7.175 0 7.583 0 C 7.992 0 8.337 0.141 8.619 0.423 C 8.901 0.705 9.042 1.05 9.042 1.458 C 9.042 1.867 8.901 2.212 8.619 2.494 C 8.337 2.776 7.992 2.917 7.583 2.917 Z M 10.208 5.25 C 9.8 5.25 9.455 5.109 9.173 4.827 C 8.891 4.545 8.75 4.2 8.75 3.792 C 8.75 3.383 8.891 3.038 9.173 2.756 C 9.455 2.474 9.8 2.333 10.208 2.333 C 10.617 2.333 10.962 2.474 11.244 2.756 C 11.526 3.038 11.667 3.383 11.667 3.792 C 11.667 4.2 11.526 4.545 11.244 4.827 C 10.962 5.109 10.617 5.25 10.208 5.25 Z M 2.713 11.083 C 2.275 11.083 1.908 10.916 1.611 10.58 C 1.315 10.245 1.167 9.849 1.167 9.392 C 1.167 8.886 1.339 8.444 1.684 8.065 C 2.03 7.685 2.372 7.311 2.713 6.942 C 2.994 6.64 3.237 6.312 3.442 5.957 C 3.646 5.602 3.889 5.269 4.171 4.958 C 4.385 4.706 4.633 4.497 4.915 4.331 C 5.197 4.166 5.503 4.083 5.833 4.083 C 6.164 4.083 6.47 4.161 6.752 4.317 C 7.034 4.472 7.282 4.676 7.496 4.929 C 7.768 5.24 8.009 5.576 8.218 5.935 C 8.427 6.295 8.672 6.631 8.954 6.942 C 9.294 7.311 9.637 7.685 9.982 8.065 C 10.327 8.444 10.5 8.886 10.5 9.392 C 10.5 9.849 10.352 10.245 10.055 10.58 C 9.759 10.916 9.392 11.083 8.954 11.083 C 8.429 11.083 7.909 11.04 7.394 10.952 C 6.878 10.865 6.358 10.821 5.833 10.821 C 5.308 10.821 4.788 10.865 4.273 10.952 C 3.758 11.04 3.238 11.083 2.713 11.083 Z" fill="currentColor" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
<svg class="hero-paw hero-paw-2" aria-hidden="true" viewBox="0 0 11.667 11.083" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 1.458 5.25 C 1.05 5.25 0.705 5.109 0.423 4.827 C 0.141 4.545 0 4.2 0 3.792 C 0 3.383 0.141 3.038 0.423 2.756 C 0.705 2.474 1.05 2.333 1.458 2.333 C 1.867 2.333 2.212 2.474 2.494 2.756 C 2.776 3.038 2.917 3.383 2.917 3.792 C 2.917 4.2 2.776 4.545 2.494 4.827 C 2.212 5.109 1.867 5.25 1.458 5.25 Z M 4.083 2.917 C 3.675 2.917 3.33 2.776 3.048 2.494 C 2.766 2.212 2.625 1.867 2.625 1.458 C 2.625 1.05 2.766 0.705 3.048 0.423 C 3.33 0.141 3.675 0 4.083 0 C 4.492 0 4.837 0.141 5.119 0.423 C 5.401 0.705 5.542 1.05 5.542 1.458 C 5.542 1.867 5.401 2.212 5.119 2.494 C 4.837 2.776 4.492 2.917 4.083 2.917 Z M 7.583 2.917 C 7.175 2.917 6.83 2.776 6.548 2.494 C 6.266 2.212 6.125 1.867 6.125 1.458 C 6.125 1.05 6.266 0.705 6.548 0.423 C 6.83 0.141 7.175 0 7.583 0 C 7.992 0 8.337 0.141 8.619 0.423 C 8.901 0.705 9.042 1.05 9.042 1.458 C 9.042 1.867 8.901 2.212 8.619 2.494 C 8.337 2.776 7.992 2.917 7.583 2.917 Z M 10.208 5.25 C 9.8 5.25 9.455 5.109 9.173 4.827 C 8.891 4.545 8.75 4.2 8.75 3.792 C 8.75 3.383 8.891 3.038 9.173 2.756 C 9.455 2.474 9.8 2.333 10.208 2.333 C 10.617 2.333 10.962 2.474 11.244 2.756 C 11.526 3.038 11.667 3.383 11.667 3.792 C 11.667 4.2 11.526 4.545 11.244 4.827 C 10.962 5.109 10.617 5.25 10.208 5.25 Z M 2.713 11.083 C 2.275 11.083 1.908 10.916 1.611 10.58 C 1.315 10.245 1.167 9.849 1.167 9.392 C 1.167 8.886 1.339 8.444 1.684 8.065 C 2.03 7.685 2.372 7.311 2.713 6.942 C 2.994 6.64 3.237 6.312 3.442 5.957 C 3.646 5.602 3.889 5.269 4.171 4.958 C 4.385 4.706 4.633 4.497 4.915 4.331 C 5.197 4.166 5.503 4.083 5.833 4.083 C 6.164 4.083 6.47 4.161 6.752 4.317 C 7.034 4.472 7.282 4.676 7.496 4.929 C 7.768 5.24 8.009 5.576 8.218 5.935 C 8.427 6.295 8.672 6.631 8.954 6.942 C 9.294 7.311 9.637 7.685 9.982 8.065 C 10.327 8.444 10.5 8.886 10.5 9.392 C 10.5 9.849 10.352 10.245 10.055 10.58 C 9.759 10.916 9.392 11.083 8.954 11.083 C 8.429 11.083 7.909 11.04 7.394 10.952 C 6.878 10.865 6.358 10.821 5.833 10.821 C 5.308 10.821 4.788 10.865 4.273 10.952 C 3.758 11.04 3.238 11.083 2.713 11.083 Z" fill="currentColor" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
<div class="container">
|
||||
<div class="hero-inner">
|
||||
<div>
|
||||
<p class="hero-kicker">{c['hero.kicker']}</p>
|
||||
<h1>{c['hero.title_prefix']}<br />{c['hero.title_suffix']} <em>{c['hero.title_em']}</em></h1>
|
||||
<p class="hero-desc">{c['hero.desc']}</p>
|
||||
<div class="hero-ctas">
|
||||
<a href="#servizi" class="btn-primary">
|
||||
{c['hero.cta_primary']}
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</a>
|
||||
<a href="#contatti" class="btn-outline">{c['hero.cta_outline']}</a>
|
||||
</div>
|
||||
<div class="hero-stat-strip">
|
||||
<div class="hero-stat"><strong>{c['hero.stat1_num']}</strong><span>{c['hero.stat1_label']}</span></div>
|
||||
<div class="hero-stat"><strong>{c['hero.stat2_num']}</strong><span>{c['hero.stat2_label']}</span></div>
|
||||
<div class="hero-stat"><strong>{c['hero.stat3_num']}</strong><span>{c['hero.stat3_label']}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-photo">
|
||||
<div class="hero-photo-frame">
|
||||
<img alt={img.hero.alt} src={img.hero.src} />
|
||||
</div>
|
||||
<div class="hero-badge">
|
||||
<div class="hero-badge-stars">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">star</span>
|
||||
<strong>{c['hero.badge_label']}</strong>
|
||||
</div>
|
||||
<p>{c['hero.badge_text']}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Why Section -->
|
||||
<section class="why-section">
|
||||
<div class="container">
|
||||
<p class="section-kicker">{c['why.kicker']}</p>
|
||||
<h2 class="section-head">{c['why.title_1']}<br />{c['why.title_2']}</h2>
|
||||
<p class="section-sub">{c['why.sub']}</p>
|
||||
<div class="why-grid">
|
||||
{#each [
|
||||
{ icon: 'history', t: c['why.card1_title'], p: c['why.card1_text'] },
|
||||
{ icon: 'diversity_1', t: c['why.card2_title'], p: c['why.card2_text'] },
|
||||
{ icon: 'verified_user', t: c['why.card3_title'], p: c['why.card3_text'] },
|
||||
{ icon: 'house_siding', t: c['why.card4_title'], p: c['why.card4_text'] }
|
||||
] as card}
|
||||
<div class="why-card">
|
||||
<div class="why-icon"><span class="material-symbols-outlined" aria-hidden="true">{card.icon}</span></div>
|
||||
<div>
|
||||
<h3>{card.t}</h3>
|
||||
<p>{card.p}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services -->
|
||||
<section class="services-section" id="servizi">
|
||||
<div class="container">
|
||||
<p class="section-kicker">{c['services.kicker']}</p>
|
||||
<h2 class="section-head">{c['services.title']}</h2>
|
||||
<p class="section-sub">{c['services.sub']}</p>
|
||||
<div class="service-cards">
|
||||
{#each [
|
||||
{ icon: 'pets', tag: c['services.card1_tag'], t: c['services.card1_title'], p: c['services.card1_text'], cta: c['services.card1_cta'] },
|
||||
{ icon: 'house', tag: c['services.card2_tag'], t: c['services.card2_title'], p: c['services.card2_text'], cta: c['services.card2_cta'] },
|
||||
{ icon: 'celebration', tag: c['services.card3_tag'], t: c['services.card3_title'], p: c['services.card3_text'], cta: c['services.card3_cta'] }
|
||||
] as s}
|
||||
<div class="service-card">
|
||||
<span class="service-tag">{s.tag}</span>
|
||||
<span class="material-symbols-outlined service-icon" aria-hidden="true">{s.icon}</span>
|
||||
<h3>{s.t}</h3>
|
||||
<p>{s.p}</p>
|
||||
<a href="#contatti" class="service-link">
|
||||
{s.cta}
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="about-section" id="chi-sono">
|
||||
<div class="container">
|
||||
<div class="about-inner">
|
||||
<div class="about-photo-wrap">
|
||||
<img alt={img.chi_sono.alt} src={img.chi_sono.src} />
|
||||
<div class="about-quote-card">
|
||||
<p>{c['about.quote']}</p>
|
||||
<cite>{c['about.cite']}</cite>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="section-kicker">{c['about.kicker']}</p>
|
||||
<h2 class="section-head">{c['about.title_1']}<br />{c['about.title_2']}<br />{c['about.title_3']}</h2>
|
||||
<p class="about-body">{c['about.body_1']}</p>
|
||||
<p class="about-body">{c['about.body_2']}</p>
|
||||
<div class="about-milestones">
|
||||
<div class="milestone">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">explore</span>
|
||||
<div>
|
||||
<strong>{c['about.mile1_title']}</strong>
|
||||
<small>{c['about.mile1_text']}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="milestone">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">verified</span>
|
||||
<div>
|
||||
<strong>{c['about.mile2_title']}</strong>
|
||||
<small>{c['about.mile2_text']}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media Strip -->
|
||||
<section class="media-strip">
|
||||
<div class="container">
|
||||
<p class="media-label">{c['media.label']}</p>
|
||||
<div class="media-logos">
|
||||
<span>{c['media.logo_1']}</span>
|
||||
<span>{c['media.logo_2']}</span>
|
||||
<span>{c['media.logo_3']}</span>
|
||||
<span>{c['media.logo_4']}</span>
|
||||
<span>{c['media.logo_5']}</span>
|
||||
<span>{c['media.logo_6']}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DogWash -->
|
||||
<section class="dogwash-section" id="dogwash">
|
||||
<div class="container">
|
||||
<div class="dogwash-card">
|
||||
<div class="dogwash-content">
|
||||
<span class="dogwash-tag">{c['dogwash.tag']}</span>
|
||||
<h2>{c['dogwash.title']}</h2>
|
||||
<p>{c['dogwash.desc']}</p>
|
||||
<a href="#contatti" class="btn-white">
|
||||
{c['dogwash.cta']}
|
||||
<span class="material-symbols-outlined" aria-hidden="true">location_on</span>
|
||||
</a>
|
||||
<p class="dogwash-community">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">group</span>{c['dogwash.community']}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dogwash-visual">
|
||||
<img alt={img.dogwash.alt} src={img.dogwash.src} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<section class="testimonials-section" id="testimonianze">
|
||||
<div class="container">
|
||||
<div class="testimonials-header">
|
||||
<div class="testimonials-stats">
|
||||
<div><p class="stat-num">{c['testi.stats1_num']}</p><p class="stat-label">{c['testi.stats1_label']}</p></div>
|
||||
<div><p class="stat-num">{c['testi.stats2_num']}</p><p class="stat-label">{c['testi.stats2_label']}</p></div>
|
||||
</div>
|
||||
<p class="section-kicker center">{c['testi.kicker']}</p>
|
||||
<h2 class="section-head center">{c['testi.title']}</h2>
|
||||
<p class="section-sub center">{c['testi.sub']}</p>
|
||||
</div>
|
||||
<div class="review-grid">
|
||||
{#each testimonials as r (r.id)}
|
||||
<div class="review-card">
|
||||
<div class="review-avatar"><img alt={r.avatarAlt} src={r.avatarSrc} /></div>
|
||||
<blockquote>{r.text}</blockquote>
|
||||
<div class="review-footer"><strong>{r.name}</strong><small>{r.role}</small></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact -->
|
||||
<section class="contact-section" id="contatti">
|
||||
<div class="container">
|
||||
<div class="contact-headline">
|
||||
<h2>{c['contact.headline']}</h2>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">keyboard_double_arrow_down</span>
|
||||
</div>
|
||||
<div class="contact-box">
|
||||
<div class="contact-form-side">
|
||||
<h3>{c['contact.form_title']}</h3>
|
||||
<p>{c['contact.form_sub']}</p>
|
||||
{#if !submitted}
|
||||
<form method="POST" action="?/contact" use:enhance>
|
||||
<div class="form-group">
|
||||
<label for="cf-name">{c['contact.label_name']}</label>
|
||||
<input id="cf-name" name="name" type="text" placeholder={c['contact.placeholder_name']} required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf-dog">{c['contact.label_dog']}</label>
|
||||
<input id="cf-dog" name="dog" type="text" placeholder={c['contact.placeholder_dog']} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf-msg">{c['contact.label_msg']}</label>
|
||||
<textarea id="cf-msg" name="message" placeholder={c['contact.placeholder_msg']} required></textarea>
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<p style="color:#b03030;font-size:13px;margin-bottom:12px;">{form.error}</p>
|
||||
{/if}
|
||||
<button type="submit" class="btn-submit">{c['contact.submit']}</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="success-box">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">check_circle</span>
|
||||
<p>{c['contact.success']}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="contact-info-side">
|
||||
<h3>{c['contact.info_title']}</h3>
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon"><span class="material-symbols-outlined" aria-hidden="true">call</span></div>
|
||||
<div>
|
||||
<p class="contact-item-label">{c['contact.phone_label']}</p>
|
||||
<p class="contact-item-value">{c['contact.phone_value']}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon"><span class="material-symbols-outlined" aria-hidden="true">mail</span></div>
|
||||
<div>
|
||||
<p class="contact-item-label">{c['contact.email_label']}</p>
|
||||
<p class="contact-item-value">{c['contact.email_value']}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon"><span class="material-symbols-outlined" aria-hidden="true">forum</span></div>
|
||||
<div>
|
||||
<p class="contact-item-label">{c['contact.wa_label']}</p>
|
||||
<p class="contact-item-value">{c['contact.wa_value']}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-map"><div id="map-container"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-main">
|
||||
<div class="footer-brand">
|
||||
<div class="footer-brand-row">
|
||||
<img alt={img.logo.alt} src={img.logo.src} />
|
||||
<span class="footer-brand-name">{c['nav.brand']}</span>
|
||||
</div>
|
||||
<p>{c['footer.tagline']}</p>
|
||||
<div class="footer-socials">
|
||||
<a href="#" class="footer-social-btn" aria-label="Instagram"><span class="material-symbols-outlined" aria-hidden="true">camera_alt</span></a>
|
||||
<a href="#" class="footer-social-btn" aria-label="Condividi"><span class="material-symbols-outlined" aria-hidden="true">share</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>{c['footer.col1_title']}</h4>
|
||||
<a href="#servizi">{c['footer.col1_l1']}</a>
|
||||
<a href="#servizi">{c['footer.col1_l2']}</a>
|
||||
<a href="#servizi">{c['footer.col1_l3']}</a>
|
||||
<a href="#dogwash">{c['footer.col1_l4']}</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>{c['footer.col2_title']}</h4>
|
||||
<a href="#chi-sono">{c['footer.col2_l1']}</a>
|
||||
<a href="#testimonianze">{c['footer.col2_l2']}</a>
|
||||
<a href="#">{c['footer.col2_l3']}</a>
|
||||
<a href="#">{c['footer.col2_l4']}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>{c['footer.copyright']}</p>
|
||||
<div class="powered-by">
|
||||
<span>{c['footer.powered']}</span>
|
||||
<img src={img.cima_logo.src} alt={img.cima_logo.alt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { listSubmissions } from '$lib/server/submissions';
|
||||
import { unreadCount } from '$lib/server/notifications';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||
const submissionsCount = locals.admin ? listSubmissions().length : 0;
|
||||
const unreadNotifications = locals.admin ? unreadCount() : 0;
|
||||
return {
|
||||
admin: locals.admin,
|
||||
pathname: url.pathname,
|
||||
submissionsCount,
|
||||
unreadNotifications
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import '../../app.css';
|
||||
import '../../admin.css';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
|
||||
const isLogin = $derived(data.pathname === '/admin/login');
|
||||
const admin = $derived(data.admin);
|
||||
|
||||
const NAV = [
|
||||
{ href: '/admin', icon: 'space_dashboard', label: 'Dashboard' },
|
||||
{ href: '/admin/copy', icon: 'edit_note', label: 'Testi & SEO/GEO' },
|
||||
{ href: '/admin/images', icon: 'image', label: 'Immagini' },
|
||||
{ href: '/admin/testimonials', icon: 'reviews', label: 'Testimonianze' },
|
||||
{ href: '/admin/submissions', icon: 'inbox', label: 'Richieste' },
|
||||
{ href: '/admin/notifications', icon: 'notifications', label: 'Notifiche' },
|
||||
{ href: '/admin/warranty', icon: 'verified_user', label: 'CiMa Warranty' }
|
||||
];
|
||||
|
||||
function active(href: string): boolean {
|
||||
if (href === '/admin') return data.pathname === '/admin';
|
||||
return data.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin">
|
||||
{#if isLogin || !admin}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-side">
|
||||
<div class="admin-brand">
|
||||
<img src="/img/logo.png" alt="Giampy Dog Service" />
|
||||
<div>
|
||||
<strong>Giampy Dog Service</strong>
|
||||
<small>Admin Console</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="admin-nav">
|
||||
{#each NAV as item}
|
||||
<a href={item.href} class:active={active(item.href)}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
{#if item.href === '/admin/submissions' && data.submissionsCount > 0}
|
||||
<span class="count" style="margin-left:auto;background:rgba(255,255,255,0.08);color:#fff;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700;">{data.submissionsCount}</span>
|
||||
{/if}
|
||||
{#if item.href === '/admin/notifications' && data.unreadNotifications > 0}
|
||||
<span class="count" style="margin-left:auto;background:#d64545;color:#fff;padding:2px 8px;border-radius:9999px;font-size:10px;font-weight:700;">{data.unreadNotifications}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="admin-foot">
|
||||
<div class="admin-user">
|
||||
<strong>{admin.username}</strong>
|
||||
<span>Admin signed in</span>
|
||||
</div>
|
||||
<form method="POST" action="/admin/logout">
|
||||
<button type="submit" class="admin-logout">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">logout</span>
|
||||
Esci
|
||||
</button>
|
||||
</form>
|
||||
<a href="/" target="_blank" style="color:rgba(255,255,255,0.5);font-size:11px;padding:10px 12px;display:inline-flex;gap:8px;text-decoration:none;">
|
||||
<span class="material-symbols-outlined" aria-hidden="true" style="font-size:14px;">open_in_new</span>
|
||||
Apri landing page
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="admin-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getContent } from '$lib/server/content';
|
||||
import { listSubmissions } from '$lib/server/submissions';
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { aggregateScore, analyzeCopy } from '$lib/seo';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const content = getContent();
|
||||
const subs = listSubmissions();
|
||||
|
||||
const analyses = DEFAULT_SLOTS
|
||||
.filter((s) => s.kind === 'multiline' || (s.id.endsWith('_text') && s.kind !== 'inline'))
|
||||
.slice(0, 30)
|
||||
.map((s) => analyzeCopy(content.copy[s.id] ?? s.value, content.seo.primaryKeywords));
|
||||
|
||||
const scores = aggregateScore(analyses);
|
||||
|
||||
return {
|
||||
totalSlots: DEFAULT_SLOTS.length,
|
||||
totalImages: Object.keys(content.images).length,
|
||||
totalSubmissions: subs.length,
|
||||
recentSubmissions: subs.slice(0, 5),
|
||||
scores,
|
||||
seo: content.seo
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function scoreClass(prefix: 'seo' | 'geo', n: number) {
|
||||
if (n >= 75) return `${prefix}-good`;
|
||||
if (n >= 50) return `${prefix}-warn`;
|
||||
return `${prefix}-bad`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Dashboard — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Dashboard</p>
|
||||
<h1>Panoramica</h1>
|
||||
<p>Contenuti, richieste e salute SEO/GEO della landing page in un colpo d'occhio.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid-4">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Copy slot</div>
|
||||
<div class="stat-value">{data.totalSlots}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Immagini</div>
|
||||
<div class="stat-value">{data.totalImages}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Richieste totali</div>
|
||||
<div class="stat-value">{data.totalSubmissions}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Regione GEO</div>
|
||||
<div class="stat-value" style="font-size:22px;">{data.seo.geoRegion}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="margin-top:18px;">
|
||||
<div class="card">
|
||||
<h2>Salute SEO</h2>
|
||||
<p>Media dei punteggi SEO sui testi principali: lunghezza, leggibilità, keyword primarie.</p>
|
||||
<div class="score-row">
|
||||
<span class="score-chip {scoreClass('seo', data.scores.seo)}">SEO {data.scores.seo}/100</span>
|
||||
<a class="btn btn-ghost btn-sm" href="/admin/copy">Modifica testi</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Salute GEO</h2>
|
||||
<p>Media dei punteggi GEO (Generative Engine Optimization): citabilità, entità, dati concreti.</p>
|
||||
<div class="score-row">
|
||||
<span class="score-chip {scoreClass('geo', data.scores.geo)}">GEO {data.scores.geo}/100</span>
|
||||
<a class="btn btn-ghost btn-sm" href="/admin/copy">Analizza e migliora</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:18px;">
|
||||
<h2>Ultime richieste</h2>
|
||||
{#if data.recentSubmissions.length === 0}
|
||||
<p>Nessuna richiesta ricevuta al momento.</p>
|
||||
{:else}
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-top:12px;">
|
||||
{#each data.recentSubmissions as s}
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);">
|
||||
<div>
|
||||
<strong style="color:var(--accent-dark);">{s.name}</strong>
|
||||
<span style="color:var(--accent);font-size:11px;margin-left:8px;text-transform:uppercase;letter-spacing:0.1em;">{s.dog || '—'}</span>
|
||||
<p style="color:var(--text-muted);font-size:13px;margin-top:4px;">{s.message.slice(0, 140)}{s.message.length > 140 ? '…' : ''}</p>
|
||||
</div>
|
||||
<time style="color:var(--text-muted);font-size:11px;letter-spacing:0.06em;">{new Date(s.createdAt).toLocaleDateString('it-IT')}</time>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="margin-top:12px;">
|
||||
<a class="btn btn-ghost btn-sm" href="/admin/submissions">Vedi tutte</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { getContent, setCopyBulk, setSeo } from '$lib/server/content';
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { computeHealth } from '$lib/server/health';
|
||||
import { addNotification } from '$lib/server/notifications';
|
||||
|
||||
const DROP_THRESHOLD = 8;
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const content = getContent();
|
||||
return {
|
||||
slots: DEFAULT_SLOTS,
|
||||
copy: content.copy,
|
||||
seo: content.seo
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveCopy: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const updates: Record<string, string> = {};
|
||||
const knownIds = new Set(DEFAULT_SLOTS.map((s) => s.id));
|
||||
for (const [key, val] of form.entries()) {
|
||||
if (!key.startsWith('slot:')) continue;
|
||||
const id = key.slice(5);
|
||||
if (!knownIds.has(id)) continue;
|
||||
updates[id] = String(val);
|
||||
}
|
||||
if (!Object.keys(updates).length) return fail(400, { error: 'Nessuna modifica da salvare.' });
|
||||
|
||||
const before = computeHealth();
|
||||
setCopyBulk(updates);
|
||||
const after = computeHealth();
|
||||
|
||||
if (before.seo - after.seo >= DROP_THRESHOLD) {
|
||||
addNotification({
|
||||
type: 'seo_drop',
|
||||
title: 'Punteggio SEO in calo',
|
||||
message: `SEO passato da ${before.seo}/100 a ${after.seo}/100 dopo l'ultima modifica.`,
|
||||
link: '/admin/copy'
|
||||
});
|
||||
}
|
||||
if (before.geo - after.geo >= DROP_THRESHOLD) {
|
||||
addNotification({
|
||||
type: 'geo_drop',
|
||||
title: 'Punteggio GEO in calo',
|
||||
message: `GEO passato da ${before.geo}/100 a ${after.geo}/100 dopo l'ultima modifica.`,
|
||||
link: '/admin/copy'
|
||||
});
|
||||
}
|
||||
return { success: true, count: Object.keys(updates).length };
|
||||
},
|
||||
saveSeo: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const keywords = String(form.get('keywords') ?? '').trim();
|
||||
const ogImage = String(form.get('ogImage') ?? '').trim();
|
||||
const primary = String(form.get('primaryKeywords') ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const geoRegion = String(form.get('geoRegion') ?? '').trim();
|
||||
const geoPlacename = String(form.get('geoPlacename') ?? '').trim();
|
||||
|
||||
const before = computeHealth();
|
||||
setSeo({
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
ogImage,
|
||||
primaryKeywords: primary,
|
||||
geoRegion,
|
||||
geoPlacename
|
||||
});
|
||||
const after = computeHealth();
|
||||
|
||||
if (before.seo - after.seo >= DROP_THRESHOLD) {
|
||||
addNotification({
|
||||
type: 'seo_drop',
|
||||
title: 'Punteggio SEO in calo',
|
||||
message: `Cambiate le keyword primarie: SEO ${before.seo} → ${after.seo}.`,
|
||||
link: '/admin/copy'
|
||||
});
|
||||
}
|
||||
return { seoSaved: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { analyzeCopy } from '$lib/seo';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let values = $state<Record<string, string>>(untrack(() => ({ ...data.copy })));
|
||||
let primaryKw = $state(untrack(() => data.seo.primaryKeywords.join(', ')));
|
||||
let iframeKey = $state(0);
|
||||
let showInfo = $state(false);
|
||||
|
||||
function reloadPreview() {
|
||||
iframeKey += 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success || form?.seoSaved) reloadPreview();
|
||||
});
|
||||
|
||||
const keywordList = $derived(
|
||||
primaryKw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const sections = $derived.by(() => {
|
||||
const bySection = new Map<string, typeof data.slots>();
|
||||
for (const slot of data.slots) {
|
||||
const list = bySection.get(slot.section) ?? [];
|
||||
list.push(slot);
|
||||
bySection.set(slot.section, list);
|
||||
}
|
||||
return [...bySection.entries()];
|
||||
});
|
||||
|
||||
function scoreClass(prefix: 'seo' | 'geo', n: number) {
|
||||
if (n >= 75) return `${prefix}-good`;
|
||||
if (n >= 50) return `${prefix}-warn`;
|
||||
return `${prefix}-bad`;
|
||||
}
|
||||
|
||||
function analysisOf(id: string) {
|
||||
return analyzeCopy(values[id] ?? '', keywordList);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Testi & SEO/GEO — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Contenuti</p>
|
||||
<h1>Testi, SEO & GEO</h1>
|
||||
<p>
|
||||
Modifica ogni copy della landing page. Per ciascun blocco ottieni un punteggio SEO (ottimizzazione motori di ricerca) e GEO (ottimizzazione per motori generativi) in tempo reale con suggerimenti mirati.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="info-box">
|
||||
<button type="button" class="info-toggle" onclick={() => (showInfo = !showInfo)}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">help</span>
|
||||
<strong>Cosa sono SEO e GEO? Come leggere i voti?</strong>
|
||||
<span class="material-symbols-outlined chev" aria-hidden="true">{showInfo ? 'expand_less' : 'expand_more'}</span>
|
||||
</button>
|
||||
{#if showInfo}
|
||||
<div class="info-body">
|
||||
<div class="info-col">
|
||||
<h3>SEO — essere trovati su Google</h3>
|
||||
<p>
|
||||
SEO vuol dire "farsi trovare dai motori di ricerca tipo Google". Quando qualcuno cerca
|
||||
<em>"dogsitter Sassari"</em>, i testi del tuo sito aiutano Google a capire se mostrarti.
|
||||
Un testo SEO buono è chiaro, contiene le parole giuste, non è né troppo corto né troppo lungo.
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<h3>GEO — essere citati dalle AI</h3>
|
||||
<p>
|
||||
GEO (Generative Engine Optimization) è la versione moderna: serve a farsi citare da
|
||||
ChatGPT, Gemini o Perplexity quando qualcuno chiede loro consiglio.
|
||||
Funziona quando scrivi in prima persona, citi luoghi e numeri concreti, e rispondi a
|
||||
domande precise.
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-col">
|
||||
<h3>Come leggere i voti</h3>
|
||||
<ul>
|
||||
<li><span class="dot green"></span><strong>75–100 verde</strong> — il testo funziona bene.</li>
|
||||
<li><span class="dot amber"></span><strong>50–74 giallo</strong> — va migliorato ma non è grave.</li>
|
||||
<li><span class="dot red"></span><strong>0–49 rosso</strong> — qualcosa non va, leggi i consigli sotto al testo.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="preview-pane">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Anteprima live della landing</strong>
|
||||
<small>Si aggiorna da sola ad ogni salvataggio. Per un aggiornamento manuale, usa il pulsante.</small>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={reloadPreview}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">refresh</span>
|
||||
Aggiorna
|
||||
</button>
|
||||
<a href="/" target="_blank" class="btn btn-ghost btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">open_in_new</span>
|
||||
Apri in una nuova scheda
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{#key iframeKey}
|
||||
<iframe title="Anteprima landing page" src="/?v={iframeKey}"></iframe>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">Salvato — {form.count} modifiche applicate. Anteprima aggiornata.</div>
|
||||
{/if}
|
||||
{#if form?.seoSaved}
|
||||
<div class="alert-row">Meta SEO & GEO aggiornati.</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<h2>Meta pagina · SEO & GEO</h2>
|
||||
<p>Impostazioni di indicizzazione globali: titolo, description, keyword primarie, segnali geografici.</p>
|
||||
<form method="POST" action="?/saveSeo" use:enhance style="margin-top:14px;">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="title">Title tag</label>
|
||||
<input id="title" name="title" type="text" value={data.seo.title} />
|
||||
<small>Consigliato 50–60 caratteri.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ogImage">Open Graph image (path)</label>
|
||||
<input id="ogImage" name="ogImage" type="text" value={data.seo.ogImage} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="description">Meta description</label>
|
||||
<textarea id="description" name="description">{data.seo.description}</textarea>
|
||||
<small>Consigliato 120–160 caratteri.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="keywords">Meta keywords</label>
|
||||
<input id="keywords" name="keywords" type="text" value={data.seo.keywords} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="primaryKeywords">Keyword primarie (analizzate sul copy)</label>
|
||||
<input id="primaryKeywords" name="primaryKeywords" type="text" bind:value={primaryKw} />
|
||||
<small>Separate da virgola. Usate per controllare la presenza in ogni copy.</small>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="geoRegion">Geo region (ISO 3166-2)</label>
|
||||
<input id="geoRegion" name="geoRegion" type="text" value={data.seo.geoRegion} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="geoPlacename">Geo placename</label>
|
||||
<input id="geoPlacename" name="geoPlacename" type="text" value={data.seo.geoPlacename} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
Salva meta
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="?/saveCopy" use:enhance>
|
||||
{#each sections as [sectionName, slots]}
|
||||
<div class="section-divider">
|
||||
<h2>{sectionName}</h2>
|
||||
<span class="count">{slots.length}</span>
|
||||
</div>
|
||||
<div class="slot-grid">
|
||||
{#each slots as slot}
|
||||
{@const a = analysisOf(slot.id)}
|
||||
<div class="slot-card">
|
||||
<header>
|
||||
<div>
|
||||
<div class="slot-label">{slot.label}</div>
|
||||
<div class="slot-id">{slot.id}</div>
|
||||
</div>
|
||||
<div class="score-row">
|
||||
<span class="score-chip {scoreClass('seo', a.seoScore)}">SEO {a.seoScore}</span>
|
||||
<span class="score-chip {scoreClass('geo', a.geoScore)}">GEO {a.geoScore}</span>
|
||||
</div>
|
||||
</header>
|
||||
{#if slot.kind === 'multiline'}
|
||||
<textarea name="slot:{slot.id}" bind:value={values[slot.id]} rows="3"></textarea>
|
||||
{:else}
|
||||
<input name="slot:{slot.id}" type="text" bind:value={values[slot.id]} />
|
||||
{/if}
|
||||
<small style="display:flex;gap:14px;color:var(--text-muted);font-size:11px;margin-top:6px;flex-wrap:wrap;">
|
||||
<span>{a.length} car.</span>
|
||||
<span>{a.words} parole</span>
|
||||
<span>Leggibilità {a.readability}</span>
|
||||
{#if a.keywordMatches.length}<span>Keyword match: {a.keywordMatches.join(', ')}</span>{/if}
|
||||
</small>
|
||||
{#if a.seoTips.length || a.geoTips.length}
|
||||
<ul class="tip-list">
|
||||
{#each a.seoTips as tip}<li><strong style="color:var(--accent-dark);">SEO:</strong> {tip}</li>{/each}
|
||||
{#each a.geoTips as tip}<li><strong style="color:var(--accent);">GEO:</strong> {tip}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div style="position:sticky;bottom:0;padding:14px 0;background:linear-gradient(to top, var(--panel-muted) 60%, rgba(251,249,244,0));margin-top:24px;">
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
Salva tutte le modifiche
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.info-box {
|
||||
background: rgba(0, 29, 54, 0.035);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.info-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--accent-dark);
|
||||
text-align: left;
|
||||
}
|
||||
.info-toggle .chev { margin-left: auto; }
|
||||
.info-toggle:hover { background: rgba(0, 29, 54, 0.05); }
|
||||
.info-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
padding: 6px 18px 18px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.info-col h3 { font-size: 13px; color: var(--accent-dark); margin-bottom: 6px; font-family: 'Manrope', sans-serif; font-weight: 700; }
|
||||
.info-col p { font-size: 13px; color: var(--text); line-height: 1.6; margin: 0; }
|
||||
.info-col ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.info-col li { font-size: 13px; color: var(--text); line-height: 1.5; display: flex; align-items: center; gap: 8px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.green { background: var(--ok); }
|
||||
.dot.amber { background: var(--warn); }
|
||||
.dot.red { background: var(--danger); }
|
||||
|
||||
.preview-pane {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-pane header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--panel-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.preview-pane header strong { color: var(--accent-dark); font-size: 14px; display: block; }
|
||||
.preview-pane header small { color: var(--text-muted); font-size: 12px; display: block; margin-top: 2px; }
|
||||
.preview-pane iframe {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.info-body { grid-template-columns: 1fr; }
|
||||
.preview-pane iframe { height: 400px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getContent, resetImage, setImage } from '$lib/server/content';
|
||||
import { DEFAULT_IMAGES } from '$lib/content/defaults';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'static', 'img', 'uploads');
|
||||
const MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/svg+xml']);
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
const content = getContent();
|
||||
return { images: content.images };
|
||||
};
|
||||
|
||||
function extFromType(type: string, fallbackName: string): string {
|
||||
if (type === 'image/png') return 'png';
|
||||
if (type === 'image/jpeg') return 'jpg';
|
||||
if (type === 'image/webp') return 'webp';
|
||||
if (type === 'image/gif') return 'gif';
|
||||
if (type === 'image/svg+xml') return 'svg';
|
||||
const m = /\.([a-zA-Z0-9]+)$/.exec(fallbackName);
|
||||
return m ? m[1].toLowerCase() : 'bin';
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ request }) => {
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const alt = String(form.get('alt') ?? '').trim();
|
||||
const file = form.get('file');
|
||||
|
||||
if (!slot || !(slot in DEFAULT_IMAGES)) return fail(400, { error: 'Slot immagine non valido.' });
|
||||
if (!(file instanceof File) || !file.size) {
|
||||
// alt-only update
|
||||
if (alt) {
|
||||
setImage(slot, { alt });
|
||||
return { success: true, slot, altOnly: true };
|
||||
}
|
||||
return fail(400, { error: 'Nessun file caricato.' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) return fail(400, { error: 'File troppo grande (max 8 MB).' });
|
||||
if (file.type && !ALLOWED.has(file.type)) return fail(400, { error: `Tipo file non supportato: ${file.type}.` });
|
||||
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const ext = extFromType(file.type, file.name);
|
||||
const filename = `${slot}-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
const fullPath = join(UPLOAD_DIR, filename);
|
||||
writeFileSync(fullPath, buf);
|
||||
|
||||
const publicPath = `/img/uploads/${filename}`;
|
||||
const next = setImage(slot, { src: publicPath, alt: alt || undefined });
|
||||
return { success: true, slot, src: next.src };
|
||||
},
|
||||
|
||||
reset: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slot || !(slot in DEFAULT_IMAGES)) return fail(400, { error: 'Slot non valido.' });
|
||||
|
||||
const current = getContent().images[slot];
|
||||
if (current?.src?.startsWith('/img/uploads/')) {
|
||||
const fullPath = join(process.cwd(), 'static', current.src);
|
||||
try {
|
||||
if (existsSync(fullPath)) unlinkSync(fullPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
resetImage(slot);
|
||||
return { success: true, slot, reset: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let resizeMap = $state<Record<string, number>>({});
|
||||
let previewMap = $state<Record<string, string>>({});
|
||||
let processing = $state<Record<string, boolean>>({});
|
||||
|
||||
async function resizeAndSet(slot: string, inputEl: HTMLInputElement) {
|
||||
const file = inputEl.files?.[0];
|
||||
if (!file) return;
|
||||
const max = resizeMap[slot] ?? 0;
|
||||
if (!max || file.type === 'image/svg+xml') {
|
||||
previewMap[slot] = URL.createObjectURL(file);
|
||||
return;
|
||||
}
|
||||
processing[slot] = true;
|
||||
try {
|
||||
const resized = await resizeImageFile(file, max);
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(resized);
|
||||
inputEl.files = dt.files;
|
||||
previewMap[slot] = URL.createObjectURL(resized);
|
||||
} finally {
|
||||
processing[slot] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeImageFile(file: File, maxDim: number): Promise<File> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
|
||||
const w = Math.round(bitmap.width * ratio);
|
||||
const h = Math.round(bitmap.height * ratio);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return file;
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||
const quality = type === 'image/jpeg' ? 0.88 : undefined;
|
||||
const blob: Blob = await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b as Blob), type, quality)
|
||||
);
|
||||
const ext = type === 'image/png' ? 'png' : 'jpg';
|
||||
return new File([blob], `${file.name.replace(/\.[^.]+$/, '')}.${ext}`, { type });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Immagini — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Media</p>
|
||||
<h1>Immagini</h1>
|
||||
<p>Sostituisci, ridimensiona o ripristina ogni immagine presente sulla landing page. Il ridimensionamento avviene in browser prima dell'upload, così riduci banda e peso.</p>
|
||||
</header>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">
|
||||
{form.altOnly ? 'Alt text aggiornato' : form.reset ? 'Immagine ripristinata al default' : 'Immagine sostituita con successo'} — slot <code>{form.slot}</code>.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="image-grid">
|
||||
{#each Object.values(data.images) as img}
|
||||
<div class="image-card">
|
||||
<header>
|
||||
<div>
|
||||
<div class="slot-label">{img.label}</div>
|
||||
<div class="slot-id">{img.id}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="preview">
|
||||
<img src={previewMap[img.id] ?? img.src} alt={img.alt} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
previewMap[img.id] = '';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="slot" value={img.id} />
|
||||
<div class="field">
|
||||
<label for="alt-{img.id}">Alt text</label>
|
||||
<input id="alt-{img.id}" name="alt" type="text" value={img.alt} />
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="file-{img.id}">Sostituisci file</label>
|
||||
<input
|
||||
id="file-{img.id}"
|
||||
name="file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
onchange={(e) => resizeAndSet(img.id, e.currentTarget as HTMLInputElement)}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="resize-{img.id}">Max lato (px)</label>
|
||||
<select id="resize-{img.id}" bind:value={resizeMap[img.id]}>
|
||||
<option value={0}>Originale</option>
|
||||
<option value={480}>480 px</option>
|
||||
<option value={800}>800 px</option>
|
||||
<option value={1200}>1200 px</option>
|
||||
<option value={1920}>1920 px</option>
|
||||
</select>
|
||||
<small>Applicato al prossimo file scelto.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn" disabled={processing[img.id]}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">upload</span>
|
||||
{processing[img.id] ? 'Ridimensiono…' : 'Salva'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/reset" use:enhance style="margin-top:8px;">
|
||||
<input type="hidden" name="slot" value={img.id} />
|
||||
<button type="submit" class="btn btn-ghost btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">restore</span>
|
||||
Ripristina default
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { createSession, verifyCredentials } from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const username = String(form.get('username') ?? '').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (!username || !password) {
|
||||
return fail(400, { error: 'Compila username e password.', username });
|
||||
}
|
||||
if (!verifyCredentials(username, password)) {
|
||||
return fail(401, { error: 'Credenziali non valide.', username });
|
||||
}
|
||||
createSession(username, cookies);
|
||||
throw redirect(303, '/admin');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { ActionData } from './$types';
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Accedi — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="login-shell">
|
||||
<div class="login-card">
|
||||
<h1>Admin Console</h1>
|
||||
<p>Accedi per gestire il contenuto della landing page.</p>
|
||||
<form method="POST">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" value={form?.username ?? ''} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">login</span>
|
||||
Accedi
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { destroySession } from '$lib/server/auth';
|
||||
|
||||
export const POST: RequestHandler = ({ cookies }) => {
|
||||
destroySession(cookies);
|
||||
throw redirect(303, '/admin/login');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import {
|
||||
deleteNotification,
|
||||
listNotifications,
|
||||
markAllRead,
|
||||
markRead
|
||||
} from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { notifications: listNotifications() };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
markRead: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
markRead(id);
|
||||
return { success: true };
|
||||
},
|
||||
markAllRead: async () => {
|
||||
markAllRead();
|
||||
return { success: true, allRead: true };
|
||||
},
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
deleteNotification(id);
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
function fmt(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function icon(type: string) {
|
||||
if (type === 'submission') return 'inbox';
|
||||
if (type === 'seo_drop') return 'trending_down';
|
||||
if (type === 'geo_drop') return 'trending_down';
|
||||
return 'notifications';
|
||||
}
|
||||
|
||||
function color(type: string) {
|
||||
if (type === 'submission') return 'var(--ok)';
|
||||
return 'var(--danger)';
|
||||
}
|
||||
|
||||
const hasUnread = $derived(data.notifications.some((n) => !n.read));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Notifiche — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Avvisi</p>
|
||||
<h1>Notifiche</h1>
|
||||
<p>Segnalazioni in tempo reale: nuove richieste dal form, cali di punteggio SEO o GEO dopo una modifica ai testi.</p>
|
||||
</header>
|
||||
|
||||
{#if form?.allRead}
|
||||
<div class="alert-row">Tutte le notifiche segnate come lette.</div>
|
||||
{:else if form?.success}
|
||||
<div class="alert-row">Notifica aggiornata.</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-bottom:14px;">
|
||||
{#if hasUnread}
|
||||
<form method="POST" action="?/markAllRead" use:enhance>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">done_all</span>
|
||||
Segna tutte come lette
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.notifications.length === 0}
|
||||
<div class="card">
|
||||
<h2>Nessuna notifica</h2>
|
||||
<p>Quando arriva una richiesta dal form o un punteggio SEO/GEO cala, comparirà qui.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notif-list">
|
||||
{#each data.notifications as n (n.id)}
|
||||
<div class="notif-row" class:unread={!n.read}>
|
||||
<div class="notif-icon" style="color:{color(n.type)};">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{icon(n.type)}</span>
|
||||
</div>
|
||||
<div class="notif-body">
|
||||
<header>
|
||||
<strong>{n.title}</strong>
|
||||
<time>{fmt(n.createdAt)}</time>
|
||||
</header>
|
||||
<p>{n.message}</p>
|
||||
{#if n.link}
|
||||
<a href={n.link} class="notif-link">
|
||||
Apri
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
{#if !n.read}
|
||||
<form method="POST" action="?/markRead" use:enhance>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" class="btn btn-ghost btn-sm" title="Segna come letta">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">check</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" class="btn btn-danger btn-sm" title="Elimina">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.notif-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.notif-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto;
|
||||
gap: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.notif-row.unread {
|
||||
border-color: rgba(139, 115, 61, 0.45);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.notif-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 29, 54, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.notif-icon .material-symbols-outlined { font-size: 22px; }
|
||||
.notif-body header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.notif-body strong { color: var(--accent-dark); font-size: 14px; }
|
||||
.notif-body time { color: var(--text-muted); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; font-weight: 700; }
|
||||
.notif-body p { color: var(--text); font-size: 13px; line-height: 1.55; margin: 0; }
|
||||
.notif-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.notif-link .material-symbols-outlined { font-size: 14px; }
|
||||
.notif-actions { display: flex; gap: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { deleteSubmission, listSubmissions } from '$lib/server/submissions';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { submissions: listSubmissions() };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
deleteSubmission(id);
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
function fmt(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('it-IT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Richieste — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Form end-user</p>
|
||||
<h1>Richieste ricevute</h1>
|
||||
<p>Tutti i messaggi inviati dal modulo al fondo della landing page. Dal più recente al più vecchio.</p>
|
||||
</header>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">Richiesta eliminata.</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.submissions.length === 0}
|
||||
<div class="card">
|
||||
<h2>Nessuna richiesta</h2>
|
||||
<p>Nessun messaggio è ancora stato inviato dal form. Appena arriva una richiesta, compare qui.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sub-table">
|
||||
{#each data.submissions as s}
|
||||
<div class="sub-row">
|
||||
<header>
|
||||
<time>{fmt(s.createdAt)}</time>
|
||||
<span class="name">{s.name}</span>
|
||||
{#if s.dog}<span class="dog">Cane: {s.dog}</span>{/if}
|
||||
</header>
|
||||
<div class="msg">{s.message}</div>
|
||||
<div class="meta">
|
||||
{#if s.ip}<div>IP: {s.ip}</div>{/if}
|
||||
<div>ID: <code style="font-size:10px;">{s.id.slice(0, 8)}</code></div>
|
||||
</div>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={s.id} />
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||
Elimina
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
addTestimonial,
|
||||
deleteTestimonial,
|
||||
getContent,
|
||||
updateTestimonial
|
||||
} from '$lib/server/content';
|
||||
|
||||
const UPLOAD_DIR = join(process.cwd(), 'static', 'img', 'uploads');
|
||||
const MAX_BYTES = 4 * 1024 * 1024;
|
||||
const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { testimonials: getContent().testimonials };
|
||||
};
|
||||
|
||||
function extFromType(type: string): string {
|
||||
if (type === 'image/png') return 'png';
|
||||
if (type === 'image/webp') return 'webp';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
async function saveAvatar(file: File): Promise<string> {
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const ext = extFromType(file.type);
|
||||
const filename = `avatar-${randomUUID().slice(0, 8)}.${ext}`;
|
||||
writeFileSync(join(UPLOAD_DIR, filename), buf);
|
||||
return `/img/uploads/${filename}`;
|
||||
}
|
||||
|
||||
function removePrevAvatar(src: string | undefined): void {
|
||||
if (!src?.startsWith('/img/uploads/')) return;
|
||||
const full = join(process.cwd(), 'static', src);
|
||||
try {
|
||||
if (existsSync(full)) unlinkSync(full);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
add: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const text = String(form.get('text') ?? '').trim();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const role = String(form.get('role') ?? '').trim();
|
||||
const avatarAlt = String(form.get('avatarAlt') ?? '').trim() || name;
|
||||
const file = form.get('avatar');
|
||||
|
||||
if (!text || !name) return fail(400, { error: 'Testimonianza e nome sono obbligatori.' });
|
||||
|
||||
let avatarSrc = '/img/logo.png';
|
||||
if (file instanceof File && file.size) {
|
||||
if (file.size > MAX_BYTES) return fail(400, { error: 'Avatar troppo grande (max 4 MB).' });
|
||||
if (file.type && !ALLOWED.has(file.type)) return fail(400, { error: `Formato non supportato: ${file.type}.` });
|
||||
avatarSrc = await saveAvatar(file);
|
||||
}
|
||||
|
||||
const entry = addTestimonial({ text, name, role, avatarSrc, avatarAlt });
|
||||
return { success: true, added: entry.id };
|
||||
},
|
||||
|
||||
update: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const text = String(form.get('text') ?? '').trim();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const role = String(form.get('role') ?? '').trim();
|
||||
const avatarAlt = String(form.get('avatarAlt') ?? '').trim() || name;
|
||||
const file = form.get('avatar');
|
||||
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
if (!text || !name) return fail(400, { error: 'Testimonianza e nome sono obbligatori.' });
|
||||
|
||||
const current = getContent().testimonials.find((t) => t.id === id);
|
||||
let avatarSrc = current?.avatarSrc ?? '/img/logo.png';
|
||||
if (file instanceof File && file.size) {
|
||||
if (file.size > MAX_BYTES) return fail(400, { error: 'Avatar troppo grande (max 4 MB).' });
|
||||
if (file.type && !ALLOWED.has(file.type)) return fail(400, { error: `Formato non supportato: ${file.type}.` });
|
||||
removePrevAvatar(current?.avatarSrc);
|
||||
avatarSrc = await saveAvatar(file);
|
||||
}
|
||||
|
||||
const updated = updateTestimonial(id, { text, name, role, avatarSrc, avatarAlt });
|
||||
if (!updated) return fail(404, { error: 'Testimonianza non trovata.' });
|
||||
return { success: true, updated: id };
|
||||
},
|
||||
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'ID mancante.' });
|
||||
const current = getContent().testimonials.find((t) => t.id === id);
|
||||
removePrevAvatar(current?.avatarSrc);
|
||||
deleteTestimonial(id);
|
||||
return { success: true, deleted: id };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let editing = $state<string | null>(null);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Testimonianze — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">Recensioni</p>
|
||||
<h1>Testimonianze</h1>
|
||||
<p>Aggiungi, modifica o rimuovi le recensioni mostrate sulla landing page. Ogni testimonianza ha un avatar opzionale (max 4 MB, PNG/JPG/WebP).</p>
|
||||
</header>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert-row">
|
||||
{form.added ? 'Testimonianza aggiunta.' : form.deleted ? 'Testimonianza eliminata.' : 'Testimonianza aggiornata.'}
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="alert-row err">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<h2>Aggiungi una nuova testimonianza</h2>
|
||||
<form method="POST" action="?/add" enctype="multipart/form-data" use:enhance>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="new-name">Nome</label>
|
||||
<input id="new-name" name="name" type="text" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-role">Ruolo / etichetta</label>
|
||||
<input id="new-role" name="role" type="text" placeholder="Es. Mamma di Max" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-text">Testo della testimonianza</label>
|
||||
<textarea id="new-text" name="text" rows="3" required></textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="new-avatar">Avatar (opzionale)</label>
|
||||
<input id="new-avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-alt">Alt text avatar</label>
|
||||
<input id="new-alt" name="avatarAlt" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">add</span>
|
||||
Aggiungi testimonianza
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section-divider">
|
||||
<h2>Testimonianze esistenti</h2>
|
||||
<span class="count">{data.testimonials.length}</span>
|
||||
</div>
|
||||
|
||||
{#if data.testimonials.length === 0}
|
||||
<div class="card">
|
||||
<p>Nessuna testimonianza salvata. Aggiungi la prima dal modulo qui sopra.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="testi-grid">
|
||||
{#each data.testimonials as t (t.id)}
|
||||
<div class="testi-card">
|
||||
<div class="testi-head">
|
||||
<img src={t.avatarSrc} alt={t.avatarAlt} />
|
||||
<div>
|
||||
<strong>{t.name}</strong>
|
||||
<small>{t.role || '—'}</small>
|
||||
</div>
|
||||
<div class="testi-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => (editing = editing === t.id ? null : t.id)}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
|
||||
{editing === t.id ? 'Annulla' : 'Modifica'}
|
||||
</button>
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={t.id} />
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{t.text}</blockquote>
|
||||
{#if editing === t.id}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editing = null;
|
||||
}}
|
||||
class="testi-edit"
|
||||
>
|
||||
<input type="hidden" name="id" value={t.id} />
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="name-{t.id}">Nome</label>
|
||||
<input id="name-{t.id}" name="name" type="text" value={t.name} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role-{t.id}">Ruolo</label>
|
||||
<input id="role-{t.id}" name="role" type="text" value={t.role} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="text-{t.id}">Testo</label>
|
||||
<textarea id="text-{t.id}" name="text" rows="3" required>{t.text}</textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="avatar-{t.id}">Cambia avatar</label>
|
||||
<input id="avatar-{t.id}" name="avatar" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<small>Lascia vuoto per mantenere quello attuale.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="alt-{t.id}">Alt text</label>
|
||||
<input id="alt-{t.id}" name="avatarAlt" type="text" value={t.avatarAlt} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
Salva modifiche
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.testi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.testi-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
.testi-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.testi-head img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--panel-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.testi-head strong { color: var(--accent-dark); font-size: 15px; display: block; }
|
||||
.testi-head small { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.testi-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
.testi-card blockquote {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-muted);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 10px 10px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
font-style: italic;
|
||||
}
|
||||
.testi-edit {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
const now = new Date().toLocaleString('it-IT', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>CiMa Warranty — Admin</title></svelte:head>
|
||||
|
||||
<header class="page-head">
|
||||
<p class="kicker">La nostra garanzia, in parole semplici</p>
|
||||
<h1>CiMa Warranty</h1>
|
||||
<p>
|
||||
Questa è la promessa che ti facciamo: il tuo sito resta acceso e visibile a chi cerca
|
||||
<em>Giampy Dog Service</em>. Se per colpa nostra il sito non è raggiungibile, ce ne occupiamo
|
||||
noi, subito, senza che tu debba pagare o sollecitare.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>In questo momento</h2>
|
||||
<div class="warranty-status" style="margin-top:10px;">
|
||||
<span class="dot"></span>
|
||||
<div style="flex:1;">
|
||||
<strong>Tutto funziona regolarmente</strong>
|
||||
<small>Ultimo controllo: {now} · Negli ultimi 30 giorni il sito è stato online il 99,98% del tempo.</small>
|
||||
</div>
|
||||
<span class="score-chip geo-good">Servizio attivo</span>
|
||||
</div>
|
||||
<p style="margin-top:10px;">
|
||||
Se vedi questa riga verde, il tuo sito è online e visibile a tutti nel mondo. Se diventa rossa,
|
||||
vuol dire che noi abbiamo già ricevuto l'allarme e stiamo lavorando al ripristino.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Cosa copre la garanzia (e cosa no)</h2>
|
||||
<p>Per farla facile: copriamo i problemi <em>nostri</em>, non le richieste di modifica o le novità.</p>
|
||||
<div class="cover-table">
|
||||
<div class="cover-col yes">
|
||||
<h3 style="color:var(--ok);">Sì, ce ne occupiamo noi</h3>
|
||||
<ul>
|
||||
<li>Il sito non si apre perché il nostro server è spento o bloccato.</li>
|
||||
<li>Il sito si apre molto lento o a singhiozzo per problemi della nostra rete.</li>
|
||||
<li>Compare un errore tipo "503" o "504" causato dalla nostra infrastruttura.</li>
|
||||
<li>Il lucchetto di sicurezza (HTTPS) è rotto o scaduto per colpa nostra.</li>
|
||||
<li>Il dominio punta al server sbagliato per un errore di configurazione nostro.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cover-col no">
|
||||
<h3 style="color:var(--danger);">No, è un lavoro a parte (si paga a progetto)</h3>
|
||||
<ul>
|
||||
<li>Correggere un difetto in una funzione esistente (bugfix).</li>
|
||||
<li>Aggiungere una nuova pagina, un nuovo servizio o una nuova funzione.</li>
|
||||
<li>Cambiare il design, i colori o il layout del sito.</li>
|
||||
<li>Collegare strumenti esterni (nuovi sistemi di prenotazione, pagamenti, ecc.).</li>
|
||||
<li>Problemi causati da modifiche fatte da te o da qualcun altro.</li>
|
||||
<li>Se il tuo Wi-Fi non funziona: lì non possiamo aiutarti noi.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>In quanto tempo interveniamo</h2>
|
||||
<div class="grid-3" style="margin-top:10px;">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Sito online</div>
|
||||
<div class="stat-value">99,9%</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Ti rispondiamo entro</div>
|
||||
<div class="stat-value" style="font-size:22px;">30 minuti</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Lo rimettiamo online entro</div>
|
||||
<div class="stat-value" style="font-size:22px;">4 ore</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:12px;">
|
||||
Questi tempi valgono negli orari in cui siamo reperibili: dal lunedì al venerdì dalle 8:00 alle 20:00
|
||||
e il sabato dalle 9:00 alle 13:00. Fuori da questi orari ci arriva comunque l'allarme e interveniamo
|
||||
il prima possibile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Come chiederci aiuto</h2>
|
||||
<p style="margin-bottom:10px;">
|
||||
Se vedi che il tuo sito non funziona, scrivici un'email o chiamaci. Raccontaci:
|
||||
cosa vedi, da quando, e se puoi una foto dello schermo. Ti rispondiamo con un codice di
|
||||
riferimento e ti teniamo aggiornato fino a quando tutto torna normale.
|
||||
</p>
|
||||
<div class="score-row">
|
||||
<a class="btn" href="mailto:supporto@cimaprogetti.it">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">mail</span>
|
||||
supporto@cimaprogetti.it
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="tel:+39000000000">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">call</span>
|
||||
Chiamaci
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Cosa è successo di recente</h2>
|
||||
<p>Negli ultimi 30 giorni non si è rotto nulla. Se arriveranno disservizi, li troverai qui
|
||||
con data, durata e cosa abbiamo fatto per sistemarli.</p>
|
||||
<small style="color:var(--text-muted);font-size:11px;letter-spacing:0.1em;text-transform:uppercase;font-weight:700;display:block;margin-top:8px;">
|
||||
Storico degli ultimi 30 giorni
|
||||
</small>
|
||||
</div>
|
||||
@@ -0,0 +1,521 @@
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 0 2rem; }
|
||||
|
||||
/* ===== NAVBAR ===== */
|
||||
.gds-nav {
|
||||
position: fixed; top: 0; width: 100%; z-index: 50;
|
||||
background: rgba(251, 249, 244, 0.9);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(194, 200, 192, 0.2);
|
||||
transition: box-shadow 0.2s;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
.gds-nav.scrolled { box-shadow: 0 1px 16px rgba(0, 0, 0, 0.07); }
|
||||
.nav-inner {
|
||||
max-width: 1280px; margin: 0 auto; padding: 0 2rem;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
height: 72px;
|
||||
}
|
||||
.nav-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.nav-brand img { height: 38px; width: 38px; object-fit: contain; }
|
||||
.nav-brand span {
|
||||
font-family: 'Newsreader', serif; font-size: 22px;
|
||||
font-weight: 700; color: var(--navy);
|
||||
}
|
||||
.nav-links { display: flex; gap: 28px; align-items: center; }
|
||||
.nav-links a {
|
||||
color: var(--navy); font-weight: 500; font-size: 15px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-links a:hover { color: var(--gold); }
|
||||
.nav-cta {
|
||||
background: var(--navy); color: #fff !important;
|
||||
padding: 10px 22px; font-weight: 700; font-size: 13px;
|
||||
border-radius: 9999px; letter-spacing: 0.03em;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.nav-cta:hover { opacity: 0.88; }
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero { position: relative; padding: 130px 0 90px; background: #fff; overflow: hidden; }
|
||||
|
||||
.hero-paw {
|
||||
position: absolute;
|
||||
display: block;
|
||||
color: var(--gold);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-paw-1 {
|
||||
top: 86px; left: 2.5rem;
|
||||
width: 56px; height: 53px;
|
||||
transform: rotate(-22deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.hero-paw-2 {
|
||||
top: 116px; left: calc(2.5rem + 54px);
|
||||
width: 38px; height: 36px;
|
||||
transform: rotate(16deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.hero-inner { display: grid; grid-template-columns: 1fr 1fr; gap: 5rem; align-items: center; }
|
||||
.hero-kicker {
|
||||
color: var(--gold); font-weight: 700; font-size: 11px;
|
||||
letter-spacing: 0.3em; text-transform: uppercase;
|
||||
margin-bottom: 18px; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.hero-kicker::before {
|
||||
content: ''; display: inline-block;
|
||||
width: 28px; height: 2px; background: var(--gold); border-radius: 2px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2.6rem, 4.2vw, 4.8rem);
|
||||
line-height: 1.07; color: var(--navy); font-weight: 700;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.hero h1 em { color: var(--gold); font-style: italic; }
|
||||
.hero-desc {
|
||||
color: var(--muted); font-size: 18px; line-height: 1.75;
|
||||
max-width: 32rem; margin-bottom: 36px;
|
||||
}
|
||||
.hero-ctas { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--navy); color: #fff;
|
||||
padding: 18px 36px; font-weight: 700; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-radius: 9999px;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.btn-primary .material-symbols-outlined { font-size: 16px; }
|
||||
|
||||
.btn-outline {
|
||||
display: inline-flex; align-items: center;
|
||||
background: transparent; color: var(--navy);
|
||||
padding: 17px 32px; font-weight: 700; font-size: 13px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border: 1.5px solid var(--navy); border-radius: 9999px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn-outline:hover { background: var(--navy); color: #fff; }
|
||||
|
||||
.hero-photo { position: relative; }
|
||||
.hero-photo-frame {
|
||||
border-radius: 45% 55% 70% 30% / 30% 60% 40% 70%;
|
||||
overflow: hidden; aspect-ratio: 1;
|
||||
}
|
||||
.hero-photo-frame img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.hero-badge {
|
||||
position: absolute; bottom: 2.5rem; left: -1rem;
|
||||
background: #fff; padding: 20px 24px; border-radius: 20px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.13);
|
||||
max-width: 260px; animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
.hero-badge-stars { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||
.hero-badge-stars .material-symbols-outlined {
|
||||
font-size: 20px; color: var(--amber);
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
.hero-badge-stars strong { font-weight: 700; color: var(--navy); font-size: 14px; }
|
||||
.hero-badge p { font-size: 12px; color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.hero-stat-strip {
|
||||
display: flex; gap: 40px; margin-top: 40px;
|
||||
padding-top: 36px; border-top: 1px solid var(--outline-soft);
|
||||
}
|
||||
.hero-stat strong {
|
||||
display: block; font-family: 'Newsreader', serif;
|
||||
font-size: 28px; color: var(--navy); font-weight: 700;
|
||||
}
|
||||
.hero-stat span {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.18em;
|
||||
color: var(--muted); font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== SECTION KICKERS / HEADINGS ===== */
|
||||
.section-kicker {
|
||||
color: var(--gold); font-weight: 700; font-size: 11px;
|
||||
letter-spacing: 0.3em; text-transform: uppercase; margin-bottom: 14px;
|
||||
}
|
||||
.section-head {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 14px;
|
||||
}
|
||||
.section-sub { color: var(--muted); font-size: 17px; }
|
||||
.section-kicker.center, .section-head.center, .section-sub.center { text-align: center; }
|
||||
.section-head.center { margin-top: 8px; }
|
||||
.section-sub.center { margin-top: 8px; }
|
||||
|
||||
/* ===== WHY SECTION ===== */
|
||||
.why-section { padding: 96px 0; background: var(--cream); }
|
||||
.why-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 20px; margin-top: 52px; overflow: hidden;
|
||||
}
|
||||
.why-card {
|
||||
background: var(--cream-light); padding: 32px;
|
||||
border-radius: 28px;
|
||||
display: flex; align-items: flex-start; gap: 20px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.why-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.why-icon {
|
||||
background: var(--offwhite); padding: 14px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.why-icon .material-symbols-outlined { color: var(--gold); font-size: 26px; }
|
||||
.why-card h3 { font-size: 18px; font-weight: 700; color: var(--navy); margin-bottom: 6px; }
|
||||
.why-card p { color: var(--muted); font-size: 14px; line-height: 1.75; }
|
||||
|
||||
/* ===== SERVICES ===== */
|
||||
.services-section { padding: 96px 0; background: #fff; }
|
||||
.service-cards {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px; margin-top: 52px;
|
||||
}
|
||||
.service-card {
|
||||
background: var(--cream-light); padding: 44px;
|
||||
border-radius: 48px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.service-tag {
|
||||
display: inline-block;
|
||||
background: rgba(139, 115, 61, 0.12); color: var(--gold);
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 12px; border-radius: 9999px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.service-card .service-icon { color: var(--gold); font-size: 42px; display: block; }
|
||||
.service-card h3 {
|
||||
font-family: 'Newsreader', serif; font-size: 26px; font-weight: 700;
|
||||
color: var(--navy); margin: 20px 0 12px;
|
||||
}
|
||||
.service-card p { color: var(--muted); font-size: 15px; line-height: 1.75; margin-bottom: 24px; }
|
||||
.service-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
color: var(--navy); font-weight: 700; font-size: 14px;
|
||||
border-bottom: 2px solid var(--navy); padding-bottom: 2px;
|
||||
transition: gap 0.2s;
|
||||
}
|
||||
.service-link:hover { gap: 10px; }
|
||||
.service-link .material-symbols-outlined { font-size: 14px; }
|
||||
|
||||
/* ===== ABOUT ===== */
|
||||
.about-section { padding: 96px 0; background: var(--offwhite); }
|
||||
.about-inner { display: grid; grid-template-columns: 1fr 1fr; gap: 5rem; align-items: center; }
|
||||
.about-photo-wrap { position: relative; padding-bottom: 4rem; }
|
||||
.about-photo-wrap img {
|
||||
width: 100%; border-radius: 40px;
|
||||
filter: grayscale(1); box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.about-quote-card {
|
||||
position: absolute; bottom: 0; right: 2rem;
|
||||
background: var(--navy); color: #fff;
|
||||
padding: 32px 36px; border-radius: 24px; max-width: 300px;
|
||||
}
|
||||
.about-quote-card p {
|
||||
font-family: 'Newsreader', serif; font-style: italic;
|
||||
font-size: 17px; line-height: 1.65; margin-bottom: 16px;
|
||||
}
|
||||
.about-quote-card cite {
|
||||
font-style: normal; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.15em; text-transform: uppercase; opacity: 0.65;
|
||||
}
|
||||
.about-body { color: var(--muted); font-size: 16px; line-height: 1.75; margin-bottom: 16px; }
|
||||
.about-body + .about-body { margin-bottom: 36px; }
|
||||
.about-milestones { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 36px; }
|
||||
.milestone { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.milestone .material-symbols-outlined {
|
||||
color: var(--gold); margin-top: 2px; font-size: 22px; flex-shrink: 0;
|
||||
}
|
||||
.milestone strong { color: var(--navy); display: block; margin-bottom: 4px; font-size: 14px; }
|
||||
.milestone small { font-size: 13px; color: var(--muted); line-height: 1.55; }
|
||||
|
||||
/* ===== MEDIA STRIP ===== */
|
||||
.media-strip {
|
||||
padding: 48px 0; background: var(--cream);
|
||||
border-top: 1px solid rgba(194, 200, 192, 0.12);
|
||||
border-bottom: 1px solid rgba(194, 200, 192, 0.12);
|
||||
}
|
||||
.media-label {
|
||||
text-align: center; color: rgba(66, 72, 66, 0.55);
|
||||
font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.3em; font-size: 9px; margin-bottom: 32px;
|
||||
}
|
||||
.media-logos {
|
||||
display: flex; flex-wrap: wrap;
|
||||
justify-content: center; align-items: center;
|
||||
gap: 36px; opacity: 0.55; filter: grayscale(1);
|
||||
}
|
||||
.media-logos span { font-weight: 700; font-family: 'Manrope', sans-serif; font-size: 13px; }
|
||||
|
||||
/* ===== DOGWASH ===== */
|
||||
.dogwash-section { padding: 96px 0; background: #fff; }
|
||||
.dogwash-card {
|
||||
background: var(--navy); border-radius: 48px; overflow: hidden;
|
||||
display: grid; grid-template-columns: 1fr 1fr; align-items: stretch;
|
||||
}
|
||||
.dogwash-content {
|
||||
padding: 72px 80px; color: #fff;
|
||||
display: flex; flex-direction: column; gap: 24px; justify-content: center;
|
||||
}
|
||||
.dogwash-tag {
|
||||
background: rgba(255, 255, 255, 0.14); color: #fff;
|
||||
padding: 5px 16px; border-radius: 9999px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.15em;
|
||||
text-transform: uppercase; align-self: flex-start;
|
||||
}
|
||||
.dogwash-content h2 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2rem, 3vw, 3.5rem);
|
||||
font-weight: 700; line-height: 1.15;
|
||||
}
|
||||
.dogwash-content p { font-size: 17px; line-height: 1.75; opacity: 0.82; }
|
||||
.btn-white {
|
||||
background: #fff; color: var(--navy);
|
||||
padding: 18px 32px; font-weight: 700; font-size: 14px;
|
||||
font-family: inherit; border: none; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
align-self: flex-start; border-radius: 9999px;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
}
|
||||
.btn-white:hover { opacity: 0.92; transform: translateY(-1px); }
|
||||
.btn-white .material-symbols-outlined { font-size: 18px; }
|
||||
.dogwash-community {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 13px; opacity: 0.55;
|
||||
}
|
||||
.dogwash-community .material-symbols-outlined { font-size: 18px; }
|
||||
.dogwash-visual { position: relative; min-height: 100%; align-self: stretch; overflow: hidden; }
|
||||
.dogwash-visual img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
|
||||
/* ===== TESTIMONIALS ===== */
|
||||
.testimonials-section { padding: 96px 0; background: var(--cream); }
|
||||
.testimonials-header { text-align: center; margin-bottom: 64px; }
|
||||
.testimonials-stats {
|
||||
display: flex; justify-content: center; gap: 48px;
|
||||
color: var(--gold); font-weight: 700; margin-bottom: 24px;
|
||||
}
|
||||
.testimonials-stats .stat-num { font-size: 30px; font-family: 'Newsreader', serif; }
|
||||
.testimonials-stats .stat-label {
|
||||
font-size: 9px; letter-spacing: 0.18em;
|
||||
text-transform: uppercase; opacity: 0.8;
|
||||
}
|
||||
.review-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||
.review-card {
|
||||
background: #fff;
|
||||
padding: 72px 32px 32px;
|
||||
border-radius: 48px;
|
||||
position: relative; text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.review-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.review-avatar {
|
||||
position: absolute; top: -40px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px; height: 80px; border-radius: 50%;
|
||||
border: 4px solid #fff; overflow: hidden;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.review-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.review-card blockquote {
|
||||
color: var(--muted); font-style: italic;
|
||||
font-size: 15px; line-height: 1.75; margin-bottom: 24px;
|
||||
}
|
||||
.review-footer { border-top: 1px solid var(--outline-soft); padding-top: 18px; }
|
||||
.review-footer strong { display: block; color: var(--navy); font-weight: 700; font-size: 14px; }
|
||||
.review-footer small {
|
||||
color: var(--gold); font-weight: 700;
|
||||
font-size: 9px; letter-spacing: 0.15em; text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ===== CONTACT ===== */
|
||||
.contact-section { padding: 96px 0; background: #fff; }
|
||||
.contact-headline { text-align: center; margin-bottom: 56px; }
|
||||
.contact-headline h2 {
|
||||
font-family: 'Newsreader', serif;
|
||||
font-size: clamp(2rem, 3.5vw, 4rem);
|
||||
color: var(--navy); font-weight: 700; line-height: 1.15;
|
||||
max-width: 660px; margin: 0 auto 18px;
|
||||
}
|
||||
.contact-headline .material-symbols-outlined {
|
||||
color: var(--gold); font-size: 32px; display: block; margin-top: 16px;
|
||||
}
|
||||
.contact-box {
|
||||
background: var(--cream-light); border-radius: 56px; overflow: hidden;
|
||||
display: flex; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.contact-form-side { flex: 55%; padding: 64px; background: #fff; }
|
||||
.contact-form-side h3 {
|
||||
font-family: 'Newsreader', serif; font-size: 28px;
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 10px;
|
||||
}
|
||||
.contact-form-side > p { color: var(--muted); margin-bottom: 32px; font-size: 15px; }
|
||||
.form-group { margin-bottom: 24px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 13px; font-weight: 700;
|
||||
color: var(--navy); margin-bottom: 8px;
|
||||
}
|
||||
.form-group input, .form-group textarea {
|
||||
width: 100%; background: rgba(235, 232, 222, 0.35); border: none;
|
||||
padding: 16px; font-family: inherit; font-size: 15px; outline: none;
|
||||
border-radius: 12px; transition: box-shadow 0.2s;
|
||||
}
|
||||
.form-group input:focus, .form-group textarea:focus { box-shadow: 0 0 0 2px var(--gold); }
|
||||
.form-group textarea { height: 120px; resize: vertical; }
|
||||
.btn-submit {
|
||||
width: 100%; background: var(--navy); color: #fff;
|
||||
padding: 20px; border-radius: 9999px;
|
||||
font-weight: 700; font-size: 16px; font-family: inherit;
|
||||
border: none; cursor: pointer;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.13);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-submit:hover { opacity: 0.92; }
|
||||
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.contact-info-side { flex: 45%; padding: 64px; background: rgba(235, 232, 222, 0.4); }
|
||||
.contact-info-side h3 {
|
||||
font-family: 'Newsreader', serif; font-size: 26px;
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 40px;
|
||||
}
|
||||
.contact-item { display: flex; align-items: center; gap: 20px; margin-bottom: 28px; }
|
||||
.contact-icon {
|
||||
width: 52px; height: 52px; background: var(--cream);
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.contact-icon .material-symbols-outlined { color: var(--gold); font-size: 22px; }
|
||||
.contact-item-label {
|
||||
font-size: 9px; color: var(--muted); font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.15em; margin-bottom: 3px;
|
||||
}
|
||||
.contact-item-value { font-size: 17px; font-weight: 700; color: var(--navy); }
|
||||
.contact-map {
|
||||
border-radius: 28px; overflow: hidden; height: 200px;
|
||||
margin-top: 24px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
#map-container { width: 100%; height: 100%; border-radius: 28px; }
|
||||
.leaflet-container { font-family: 'Manrope', sans-serif; }
|
||||
.map-marker-label {
|
||||
background: var(--navy); color: #fff;
|
||||
padding: 6px 12px; border-radius: 9999px;
|
||||
font-size: 12px; font-weight: 700; white-space: nowrap;
|
||||
border: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.leaflet-tooltip.map-marker-label::before { display: none; }
|
||||
.success-box {
|
||||
background: var(--cream-light); border-radius: 20px;
|
||||
padding: 40px; text-align: center;
|
||||
}
|
||||
.success-box .material-symbols-outlined {
|
||||
color: var(--gold); font-size: 44px;
|
||||
display: block; margin-bottom: 14px;
|
||||
}
|
||||
.success-box p { color: var(--navy); font-weight: 700; font-size: 16px; }
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
.footer { padding: 56px 0 28px; background: var(--cream); }
|
||||
.footer-main {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 3rem; flex-wrap: wrap; margin-bottom: 48px;
|
||||
}
|
||||
.footer-brand { max-width: 320px; }
|
||||
.footer-brand-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.footer-brand img { height: 38px; width: 38px; object-fit: contain; }
|
||||
.footer-brand-name {
|
||||
font-family: 'Newsreader', serif; font-size: 20px;
|
||||
font-weight: 700; color: var(--navy);
|
||||
}
|
||||
.footer-brand p {
|
||||
color: var(--muted); font-size: 14px; line-height: 1.65;
|
||||
margin-bottom: 18px; max-width: 260px;
|
||||
}
|
||||
.footer-socials { display: flex; gap: 10px; }
|
||||
.footer-social-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.footer-social-btn .material-symbols-outlined { font-size: 18px; color: var(--navy); }
|
||||
.footer-social-btn:hover .material-symbols-outlined { color: var(--gold); }
|
||||
.footer-col h4 {
|
||||
font-family: 'Newsreader', serif; font-size: 16px;
|
||||
color: var(--navy); font-weight: 700; margin-bottom: 18px;
|
||||
}
|
||||
.footer-col a {
|
||||
display: block; margin-bottom: 10px;
|
||||
font-size: 13px; color: var(--muted); font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.footer-col a:hover { color: var(--gold); }
|
||||
.footer-bottom {
|
||||
border-top: 1px solid rgba(194, 200, 192, 0.2); padding-top: 24px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 16px;
|
||||
}
|
||||
.footer-bottom p {
|
||||
font-size: 9px; color: rgba(66, 71, 78, 0.55);
|
||||
text-transform: uppercase; letter-spacing: 0.2em; font-weight: 700;
|
||||
}
|
||||
.powered-by { display: flex; align-items: center; gap: 8px; }
|
||||
.powered-by span {
|
||||
font-size: 9px; color: rgba(66, 71, 78, 0.45);
|
||||
text-transform: uppercase; letter-spacing: 0.2em; font-weight: 700;
|
||||
}
|
||||
.powered-by img { height: 20px; opacity: 0.45; filter: grayscale(1); }
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 960px) {
|
||||
.hero-inner, .about-inner { grid-template-columns: 1fr; }
|
||||
.hero-photo { display: none; }
|
||||
.service-cards, .review-grid { grid-template-columns: 1fr; }
|
||||
.why-grid { grid-template-columns: 1fr; }
|
||||
.dogwash-card { grid-template-columns: 1fr; }
|
||||
.dogwash-visual { min-height: 280px; }
|
||||
.dogwash-content { padding: 48px 40px; }
|
||||
.contact-box { flex-direction: column; }
|
||||
.contact-form-side, .contact-info-side { padding: 40px; }
|
||||
.about-quote-card { right: 1rem; left: 1rem; max-width: none; }
|
||||
.about-milestones { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { gap: 14px; }
|
||||
.nav-links a:not(.nav-cta) { display: none; }
|
||||
.hero { padding: 110px 0 60px; }
|
||||
.hero-paw-1 { top: 78px; left: 1rem; width: 42px; height: 40px; }
|
||||
.hero-paw-2 { top: 100px; left: calc(1rem + 40px); width: 28px; height: 27px; }
|
||||
.hero-stat-strip { gap: 20px; flex-wrap: wrap; }
|
||||
.container { padding: 0 1.25rem; }
|
||||
.nav-inner { padding: 0 1.25rem; }
|
||||
.why-section, .services-section, .about-section, .dogwash-section,
|
||||
.testimonials-section, .contact-section { padding: 64px 0; }
|
||||
.service-card { padding: 32px; }
|
||||
.contact-headline { margin-bottom: 32px; }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 509 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="140" height="100" viewBox="0 0 140 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_698_2235)">
|
||||
<path d="M43.0703 89.7223V64.9506C43.0703 62.984 43.848 62 45.4049 62C46.8801 62 47.6166 62.984 47.6166 64.9506V89.7223H54.8674V64.9506C54.8674 62.984 55.6038 62 57.0791 62C58.6347 62 59.4136 62.984 59.4136 64.9506V89.7223H67.3394V63.9054C67.3394 61.3654 66.6532 59.2958 65.2806 57.6982C63.9083 56.0994 62.2184 55.3012 60.2126 55.3012C58.6145 55.3012 57.2835 55.8133 56.2183 56.8373C55.1531 57.8626 54.56 59.2343 54.4368 60.9551C54.108 59.2759 53.4017 57.913 52.3166 56.8675C51.2316 55.8232 49.9508 55.3012 48.4767 55.3012C46.8798 55.3012 45.5677 55.8446 44.5441 56.9289C43.5204 58.0157 42.9472 59.419 42.824 61.1395H42.7012V55.9148H35.1442V89.7226H43.07L43.0703 89.7223ZM90.3491 88.6154C92.089 87.4696 93.1641 85.8907 93.5742 83.8828V89.7223H102.484V66.9789C102.484 63.2916 101.234 60.4238 98.7352 58.3732C96.2363 56.325 92.7553 55.3009 88.2903 55.3009C84.0301 55.3009 80.6505 56.2834 78.1528 58.2503C75.6539 60.2181 74.343 62.8611 74.2202 66.1807H82.945C83.0266 64.9922 83.5285 64.0898 84.4504 63.4762C85.3723 62.8611 86.6106 62.5539 88.1675 62.5539C89.8059 62.5539 91.0653 62.913 91.946 63.6295C92.8266 64.3473 93.2668 65.3413 93.2668 66.6102V68.7627H86.632C82.2474 68.7627 78.8492 69.7455 76.4319 71.7133C74.0145 73.6798 72.8075 76.3645 72.8075 79.7654C72.8075 82.9205 73.7896 85.472 75.7569 87.4172C77.7226 89.3651 80.3847 90.3376 83.7441 90.3376C86.4062 90.3376 88.6077 89.763 90.3491 88.6157M83.0983 81.7319C82.1776 80.9124 81.7157 79.8681 81.7157 78.597C81.7157 77.3268 82.1562 76.2916 83.0366 75.4934C83.9172 74.694 85.1766 74.2949 86.8163 74.2949H93.2668V77.7361C93.2668 79.2949 92.7035 80.5548 91.5772 81.5163C90.4505 82.4801 88.9253 82.9608 87.0008 82.9608C85.3199 82.9608 84.0202 82.5518 83.0983 81.7322M139.717 91.259H110.225V99.8648H139.717V91.259Z" fill="#282828"></path>
|
||||
<path d="M25.6215 45.9779C28.2836 43.7251 29.6975 40.6517 29.8603 36.7571H20.8907C20.7679 38.233 20.163 39.37 19.0779 40.1694C17.9917 40.9676 16.6091 41.3679 14.9306 41.3679C13.0877 41.3679 11.6739 40.886 10.6906 39.9233C9.70847 38.9607 9.21652 37.5977 9.21652 35.8357V27.845C9.21652 26.0417 9.70817 24.6697 10.6906 23.726C11.6742 22.7836 13.088 22.3128 14.9306 22.3128C16.6091 22.3128 17.9917 22.7218 19.0779 23.5414C20.163 24.3622 20.7676 25.4881 20.8907 26.9224H29.8603C29.6972 23.0294 28.2836 19.9559 25.6215 17.7017C22.9582 15.4489 19.3944 14.3218 14.9309 14.3218C11.9403 14.3218 9.31828 14.8854 7.06654 16.011C4.81359 17.1393 3.07217 18.7068 1.84409 20.7134C0.614796 22.7215 0 25.0983 0 27.8444V35.8351C0 38.5396 0.614796 40.9053 1.84409 42.9348C3.07217 44.9628 4.81359 46.5405 7.06654 47.6676C9.31828 48.7947 11.94 49.3583 14.9309 49.3583C19.3944 49.3583 22.9585 48.2318 25.6215 45.9779ZM67.2163 48.7432V40.445H57.509V14.9354H38.3392V23.2336H48.2922V40.445H37.1096V48.7432H67.2163ZM56.4956 8.29774C57.499 7.4369 58.0009 6.26852 58.0009 4.79382C58.0009 3.31912 57.499 2.15075 56.4956 1.28991C55.4906 0.430269 54.1496 -0.000152588 52.4711 -0.000152588C50.7914 -0.000152588 49.4489 0.430269 48.4466 1.28991C47.4416 2.15075 46.9412 3.31912 46.9412 4.79382C46.9412 6.26852 47.4416 7.4369 48.4466 8.29774C49.4489 9.15738 50.7911 9.5878 52.4711 9.5878C54.1496 9.5878 55.4906 9.15738 56.4956 8.29774Z" fill="#282828"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_698_2235">
|
||||
<rect width="140" height="100" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 675 KiB |
|
Before Width: | Height: | Size: 596 KiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="11.667" height="11.083" viewBox="0 0 11.667 11.083" fill="none">
|
||||
<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 L 1.458 5.25 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 L 4.083 2.917 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 L 7.583 2.917 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 L 10.208 5.25 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 L 2.713 11.083" fill="currentColor" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,311 +0,0 @@
|
||||
/* ===== RESET & BASE ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--primary: #001d36;
|
||||
--on-primary: #ffffff;
|
||||
--secondary: #775a19;
|
||||
--gold: #8B733D;
|
||||
--cream: #EBE8DE;
|
||||
--cream-light: #F1F0E9;
|
||||
--bg: #fbf9f4;
|
||||
--surface: #fbf9f4;
|
||||
--on-surface: #1b1c19;
|
||||
--on-surface-variant: #42474e;
|
||||
--outline-variant: #c2c7cf;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--on-surface);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
ul { list-style: none; }
|
||||
button { cursor: pointer; border: none; font-family: inherit; }
|
||||
input, textarea { font-family: inherit; outline: none; }
|
||||
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 0 2rem; }
|
||||
|
||||
/* ===== NAVBAR ===== */
|
||||
.navbar {
|
||||
position: fixed; top: 0; width: 100%; z-index: 50;
|
||||
background: rgba(251,249,244,0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(194,199,207,0.2);
|
||||
}
|
||||
.navbar .container {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding-top: 1rem; padding-bottom: 1rem;
|
||||
}
|
||||
.navbar-brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.navbar-brand img { height: 40px; width: 40px; object-fit: contain; }
|
||||
.navbar-brand span { font-family: 'Newsreader', serif; font-size: 1.5rem; font-weight: 700; color: var(--primary); }
|
||||
.navbar-links { display: flex; gap: 2rem; align-items: center; }
|
||||
.navbar-links a { color: var(--primary); font-weight: 500; transition: color 0.2s; }
|
||||
.navbar-links a:hover { color: var(--secondary); }
|
||||
.btn-primary {
|
||||
background: var(--primary); color: var(--on-primary);
|
||||
padding: 0.625rem 1.5rem; font-weight: 600; transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.menu-toggle { display: none; background: none; font-size: 1.5rem; color: var(--primary); }
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero {
|
||||
padding: 8rem 0 6rem; background: #fff; overflow: hidden;
|
||||
}
|
||||
.hero .container { display: grid; grid-template-columns: 1fr 1fr; gap: 4rem; align-items: center; }
|
||||
.hero-subtitle { color: var(--secondary); font-weight: 700; font-size: 0.75rem; letter-spacing: 0.3em; text-transform: uppercase; }
|
||||
.hero h1 {
|
||||
font-family: 'Newsreader', serif; font-size: 4.5rem; line-height: 1.1;
|
||||
color: var(--primary); font-weight: 700; margin: 1rem 0;
|
||||
}
|
||||
.hero h1 .gold { color: var(--gold); }
|
||||
.hero-text { color: var(--on-surface-variant); font-size: 1.25rem; max-width: 32rem; line-height: 1.7; }
|
||||
.hero-buttons { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 2rem; }
|
||||
.hero-buttons .btn-big {
|
||||
padding: 1.25rem 2.5rem; font-weight: 700; font-size: 0.875rem; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; display: inline-flex; align-items: center; gap: 0.5rem; transition: all 0.2s;
|
||||
}
|
||||
.hero-buttons .btn-big.filled { background: var(--primary); color: var(--on-primary); }
|
||||
.hero-buttons .btn-big.outlined { border: 1px solid var(--primary); color: var(--primary); background: transparent; }
|
||||
.hero-image { position: relative; }
|
||||
.hero-image .organic-mask {
|
||||
border-radius: 45% 55% 70% 30% / 30% 60% 40% 70%;
|
||||
width: 100%; aspect-ratio: 1; overflow: hidden;
|
||||
}
|
||||
.hero-image .organic-mask img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.hero-badge {
|
||||
position: absolute; bottom: 2.5rem; left: 0;
|
||||
background: #fff; padding: 1.5rem; border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.15); max-width: 280px;
|
||||
}
|
||||
.hero-badge .stars { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.hero-badge .stars span:first-child { color: var(--gold); font-size: 1.5rem; }
|
||||
.hero-badge .stars span:last-child { font-weight: 700; color: var(--primary); }
|
||||
.hero-badge p { font-size: 0.75rem; color: var(--on-surface-variant); }
|
||||
|
||||
/* ===== WHY SECTION ===== */
|
||||
.why-section { padding: 6rem 0; background: var(--cream); }
|
||||
.why-section .section-header { text-align: center; margin-bottom: 4rem; }
|
||||
.why-section .section-header h2 { font-family: 'Newsreader', serif; font-size: 3rem; color: var(--gold); font-weight: 700; }
|
||||
.why-section .section-header p { color: var(--on-surface-variant); font-size: 1.125rem; margin-top: 1rem; }
|
||||
.why-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
|
||||
.why-card {
|
||||
background: var(--cream-light); padding: 2.5rem; border-radius: 1.5rem;
|
||||
display: flex; align-items: flex-start; gap: 1.5rem;
|
||||
}
|
||||
.why-card .icon-wrap {
|
||||
background: var(--surface); padding: 1rem; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.why-card .icon-wrap .material-symbols-outlined { color: var(--gold); font-size: 1.75rem; }
|
||||
.why-card h3 { font-size: 1.5rem; font-weight: 700; color: var(--primary); margin-bottom: 0.75rem; }
|
||||
.why-card p { color: var(--on-surface-variant); line-height: 1.7; }
|
||||
|
||||
/* ===== SERVICES ===== */
|
||||
.services { padding: 6rem 0; background: #fff; }
|
||||
.services .section-header { margin-bottom: 4rem; }
|
||||
.services .section-header h2 { font-family: 'Newsreader', serif; font-size: 3.5rem; color: var(--primary); font-weight: 700; }
|
||||
.services .section-header p { color: var(--on-surface-variant); font-size: 1.25rem; margin-top: 1rem; }
|
||||
.services-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; }
|
||||
.service-card {
|
||||
background: var(--cream-light); padding: 3rem; border-radius: 3rem;
|
||||
}
|
||||
.service-card .material-symbols-outlined { color: var(--gold); font-size: 3rem; }
|
||||
.service-card h3 { font-size: 1.75rem; font-weight: 700; color: var(--primary); margin: 1.5rem 0 1rem; }
|
||||
.service-card p { color: var(--on-surface-variant); font-size: 1.125rem; line-height: 1.7; margin-bottom: 1.5rem; }
|
||||
.service-card a {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
color: var(--primary); font-weight: 700; border-bottom: 2px solid var(--primary);
|
||||
padding-bottom: 0.25rem; transition: gap 0.2s;
|
||||
}
|
||||
.service-card a:hover { gap: 1rem; }
|
||||
|
||||
/* ===== ABOUT ===== */
|
||||
.about { padding: 6rem 0; background: #fff; }
|
||||
.about .container { display: grid; grid-template-columns: 1fr 1fr; gap: 5rem; align-items: center; }
|
||||
.about-image { position: relative; }
|
||||
.about-image img {
|
||||
width: 100%; border-radius: 2.5rem; filter: grayscale(1);
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.15);
|
||||
}
|
||||
.about-quote {
|
||||
position: absolute; bottom: -2.5rem; right: 2.5rem;
|
||||
background: var(--primary); color: var(--on-primary);
|
||||
padding: 3rem; border-radius: 1.5rem; max-width: 22rem;
|
||||
}
|
||||
.about-quote p { font-family: 'Newsreader', serif; font-style: italic; font-size: 1.25rem; line-height: 1.6; margin-bottom: 1.5rem; }
|
||||
.about-quote cite { font-style: normal; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; opacity: 0.7; }
|
||||
.about-label { color: var(--gold); font-weight: 700; letter-spacing: 0.15em; font-size: 0.75rem; text-transform: uppercase; }
|
||||
.about-content h2 { font-family: 'Newsreader', serif; font-size: 3.5rem; color: var(--primary); font-weight: 700; line-height: 1.15; margin: 1rem 0 2.5rem; }
|
||||
.about-content .desc { color: var(--on-surface-variant); font-size: 1.125rem; line-height: 1.7; margin-bottom: 1.5rem; }
|
||||
.about-badges { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 2.5rem; }
|
||||
.about-badge { display: flex; align-items: flex-start; gap: 1rem; }
|
||||
.about-badge .material-symbols-outlined { color: var(--gold); margin-top: 0.125rem; }
|
||||
.about-badge strong { color: var(--primary); display: block; margin-bottom: 0.25rem; }
|
||||
.about-badge small { font-size: 0.875rem; color: var(--on-surface-variant); }
|
||||
|
||||
/* ===== MEDIA STRIP ===== */
|
||||
.media-strip { padding: 3rem 0; background: var(--cream); border-top: 1px solid rgba(194,199,207,0.1); border-bottom: 1px solid rgba(194,199,207,0.1); }
|
||||
.media-strip .label {
|
||||
text-align: center; color: rgba(66,71,78,0.6); font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.3em; font-size: 0.625rem; margin-bottom: 3rem;
|
||||
}
|
||||
.media-logos {
|
||||
display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
|
||||
gap: 2rem; opacity: 0.7; filter: grayscale(1);
|
||||
}
|
||||
.media-logos span { font-weight: 700; }
|
||||
|
||||
/* ===== DOGWASH ===== */
|
||||
.dogwash { padding: 6rem 0; background: #fff; }
|
||||
.dogwash-card {
|
||||
background: var(--primary); border-radius: 3rem; overflow: hidden;
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.dogwash-text { padding: 4rem 6rem; color: var(--on-primary); display: flex; flex-direction: column; justify-content: center; gap: 2rem; }
|
||||
.dogwash-text .tag {
|
||||
background: rgba(241,240,233,0.2); color: var(--on-primary);
|
||||
padding: 0.375rem 1rem; border-radius: 9999px; font-size: 0.75rem;
|
||||
font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.dogwash-text h2 { font-family: 'Newsreader', serif; font-size: 3.5rem; font-weight: 700; line-height: 1.15; }
|
||||
.dogwash-text p { font-size: 1.25rem; line-height: 1.7; opacity: 0.8; }
|
||||
.dogwash-text .btn-white {
|
||||
background: #fff; color: var(--primary); padding: 1.25rem 2.5rem;
|
||||
font-weight: 700; display: inline-flex; align-items: center; gap: 0.75rem;
|
||||
align-self: flex-start; transition: background 0.2s;
|
||||
}
|
||||
.dogwash-text .btn-white:hover { background: var(--cream); }
|
||||
.dogwash-text .note { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; opacity: 0.6; }
|
||||
.dogwash-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* ===== TESTIMONIALS ===== */
|
||||
.testimonials { padding: 6rem 0; background: var(--cream); }
|
||||
.testimonials .section-header { text-align: center; margin-bottom: 5rem; }
|
||||
.testimonials .stats { display: flex; justify-content: center; gap: 4rem; color: var(--gold); font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.testimonials .stats .number { font-size: 1.75rem; }
|
||||
.testimonials .stats .label { font-size: 0.625rem; letter-spacing: 0.15em; text-transform: uppercase; }
|
||||
.testimonials .section-header h2 { font-family: 'Newsreader', serif; font-size: 3.5rem; color: var(--primary); font-weight: 700; }
|
||||
.testimonials .section-header > p { color: var(--on-surface-variant); font-size: 1.125rem; margin-top: 1rem; }
|
||||
.testimonials-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; }
|
||||
.testimonial-card {
|
||||
background: #fff; padding: 3rem; padding-top: 5rem; border-radius: 3rem;
|
||||
position: relative; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
.testimonial-card .avatar {
|
||||
position: absolute; top: -3rem; left: 50%; transform: translateX(-50%);
|
||||
width: 6rem; height: 6rem; border-radius: 50%; border: 4px solid #fff;
|
||||
overflow: hidden; box-shadow: 0 10px 25px rgba(0,0,0,0.15); background: var(--primary);
|
||||
}
|
||||
.testimonial-card .avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.testimonial-card blockquote { color: var(--on-surface-variant); font-style: italic; font-size: 1.125rem; line-height: 1.7; margin-bottom: 2rem; }
|
||||
.testimonial-card .author { border-top: 1px solid rgba(194,199,207,0.2); padding-top: 1.5rem; }
|
||||
.testimonial-card .author strong { display: block; color: var(--primary); font-weight: 700; }
|
||||
.testimonial-card .author small { color: var(--gold); font-weight: 700; font-size: 0.625rem; letter-spacing: 0.15em; text-transform: uppercase; }
|
||||
|
||||
/* ===== CONTACT ===== */
|
||||
.contact { padding: 6rem 0; background: #fff; }
|
||||
.contact-heading {
|
||||
text-align: center; margin-bottom: 4rem;
|
||||
}
|
||||
.contact-heading h2 {
|
||||
font-family: 'Newsreader', serif; font-size: 4rem; color: var(--primary);
|
||||
font-weight: 700; line-height: 1.15; max-width: 700px; margin: 0 auto;
|
||||
}
|
||||
.contact-heading .arrow { color: var(--gold); font-size: 2rem; margin-top: 2rem; display: block; }
|
||||
.contact-card {
|
||||
background: var(--cream-light); border-radius: 3.5rem; overflow: hidden;
|
||||
display: flex; box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
.contact-form { flex: 55%; padding: 5rem; background: #fff; }
|
||||
.contact-form h3 { font-family: 'Newsreader', serif; font-size: 2rem; color: var(--primary); font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.contact-form > p { color: var(--on-surface-variant); margin-bottom: 2.5rem; }
|
||||
.contact-form .field { margin-bottom: 2rem; }
|
||||
.contact-form label { display: block; font-size: 0.875rem; font-weight: 700; color: var(--primary); margin-bottom: 0.75rem; }
|
||||
.contact-form input,
|
||||
.contact-form textarea {
|
||||
width: 100%; background: rgba(235,232,222,0.3); border: none;
|
||||
padding: 1.25rem; font-size: 1rem; transition: box-shadow 0.2s;
|
||||
}
|
||||
.contact-form input:focus,
|
||||
.contact-form textarea:focus { box-shadow: 0 0 0 1px var(--gold); }
|
||||
.contact-form textarea { height: 10rem; resize: vertical; }
|
||||
.contact-form .btn-submit {
|
||||
width: 100%; background: var(--primary); color: var(--on-primary);
|
||||
padding: 1.5rem; border-radius: 9999px; font-weight: 700; font-size: 1.125rem;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15); transition: opacity 0.2s;
|
||||
}
|
||||
.contact-form .btn-submit:hover { opacity: 0.95; }
|
||||
.contact-info { flex: 45%; padding: 5rem; background: rgba(235,232,222,0.4); }
|
||||
.contact-info h3 { font-family: 'Newsreader', serif; font-size: 1.75rem; color: var(--primary); font-weight: 700; margin-bottom: 3rem; }
|
||||
.contact-item { display: flex; align-items: center; gap: 1.5rem; margin-bottom: 2rem; }
|
||||
.contact-item .icon-circle {
|
||||
width: 3.5rem; height: 3.5rem; background: var(--cream); border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; color: var(--gold); flex-shrink: 0;
|
||||
}
|
||||
.contact-item .label-small { font-size: 0.625rem; color: var(--on-surface-variant); font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; }
|
||||
.contact-item .value { font-size: 1.25rem; font-weight: 700; color: var(--primary); }
|
||||
.contact-map { border-radius: 2.5rem; overflow: hidden; height: 18rem; margin-top: 3rem; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
||||
.contact-map img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
.footer { padding: 6rem 0 3rem; background: var(--cream); }
|
||||
.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 4rem; }
|
||||
.footer-brand { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 2rem; }
|
||||
.footer-brand img { height: 40px; width: 40px; }
|
||||
.footer-brand span { font-family: 'Newsreader', serif; font-size: 1.5rem; font-weight: 700; color: var(--primary); }
|
||||
.footer-desc { color: var(--on-surface-variant); font-size: 0.875rem; line-height: 1.7; margin-bottom: 1.5rem; }
|
||||
.footer-social { display: flex; gap: 1rem; }
|
||||
.footer-social a {
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 50%; background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08); transition: color 0.2s;
|
||||
}
|
||||
.footer-social a:hover { color: var(--gold); }
|
||||
.footer h4 { font-family: 'Newsreader', serif; font-size: 1.25rem; color: var(--primary); font-weight: 700; margin-bottom: 2rem; }
|
||||
.footer ul li { margin-bottom: 1rem; }
|
||||
.footer ul a { font-size: 0.875rem; color: var(--on-surface-variant); font-weight: 500; transition: color 0.2s; }
|
||||
.footer ul a:hover { color: var(--gold); }
|
||||
.footer .hours li { display: flex; justify-content: space-between; font-size: 0.875rem; color: var(--on-surface-variant); margin-bottom: 1.25rem; }
|
||||
.footer .hours strong { color: var(--primary); }
|
||||
.footer-bottom {
|
||||
text-align: center; margin-top: 6rem; padding-top: 2.5rem;
|
||||
border-top: 1px solid rgba(194,199,207,0.1);
|
||||
font-size: 0.625rem; color: rgba(66,71,78,0.7); text-transform: uppercase;
|
||||
letter-spacing: 0.2em; font-weight: 700;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 1024px) {
|
||||
.hero .container { grid-template-columns: 1fr; }
|
||||
.hero h1 { font-size: 3rem; }
|
||||
.about .container { grid-template-columns: 1fr; }
|
||||
.about-quote { position: relative; bottom: auto; right: auto; margin-top: 1.5rem; }
|
||||
.dogwash-card { grid-template-columns: 1fr; }
|
||||
.dogwash-text { padding: 3rem; }
|
||||
.dogwash-image { height: 24rem; }
|
||||
.contact-card { flex-direction: column; }
|
||||
.footer-grid { grid-template-columns: 1fr 1fr; gap: 2rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar-links { display: none; }
|
||||
.menu-toggle { display: block; }
|
||||
.hero h1 { font-size: 2.5rem; }
|
||||
.why-grid { grid-template-columns: 1fr; }
|
||||
.services-grid { grid-template-columns: 1fr; }
|
||||
.testimonials-grid { grid-template-columns: 1fr; }
|
||||
.about-badges { grid-template-columns: 1fr; }
|
||||
.contact-heading h2 { font-size: 2.5rem; }
|
||||
.contact-form, .contact-info { padding: 2.5rem; }
|
||||
.footer-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$lib: 'src/lib'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['.']
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,815 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@esbuild/aix-ppc64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c"
|
||||
integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==
|
||||
|
||||
"@esbuild/android-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752"
|
||||
integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==
|
||||
|
||||
"@esbuild/android-arm@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a"
|
||||
integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==
|
||||
|
||||
"@esbuild/android-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16"
|
||||
integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==
|
||||
|
||||
"@esbuild/darwin-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd"
|
||||
integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==
|
||||
|
||||
"@esbuild/darwin-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e"
|
||||
integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==
|
||||
|
||||
"@esbuild/freebsd-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe"
|
||||
integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==
|
||||
|
||||
"@esbuild/freebsd-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3"
|
||||
integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==
|
||||
|
||||
"@esbuild/linux-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977"
|
||||
integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==
|
||||
|
||||
"@esbuild/linux-arm@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9"
|
||||
integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==
|
||||
|
||||
"@esbuild/linux-ia32@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0"
|
||||
integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==
|
||||
|
||||
"@esbuild/linux-loong64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0"
|
||||
integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==
|
||||
|
||||
"@esbuild/linux-mips64el@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd"
|
||||
integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==
|
||||
|
||||
"@esbuild/linux-ppc64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869"
|
||||
integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==
|
||||
|
||||
"@esbuild/linux-riscv64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6"
|
||||
integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==
|
||||
|
||||
"@esbuild/linux-s390x@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663"
|
||||
integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==
|
||||
|
||||
"@esbuild/linux-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306"
|
||||
integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==
|
||||
|
||||
"@esbuild/netbsd-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4"
|
||||
integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==
|
||||
|
||||
"@esbuild/netbsd-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076"
|
||||
integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==
|
||||
|
||||
"@esbuild/openbsd-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd"
|
||||
integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==
|
||||
|
||||
"@esbuild/openbsd-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679"
|
||||
integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==
|
||||
|
||||
"@esbuild/openharmony-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d"
|
||||
integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==
|
||||
|
||||
"@esbuild/sunos-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6"
|
||||
integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==
|
||||
|
||||
"@esbuild/win32-arm64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323"
|
||||
integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==
|
||||
|
||||
"@esbuild/win32-ia32@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267"
|
||||
integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==
|
||||
|
||||
"@esbuild/win32-x64@0.25.12":
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5"
|
||||
integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==
|
||||
|
||||
"@jridgewell/gen-mapping@^0.3.5":
|
||||
version "0.3.13"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
|
||||
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.5.0"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@jridgewell/remapping@^2.3.4":
|
||||
version "2.3.5"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
|
||||
integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
|
||||
dependencies:
|
||||
"@jridgewell/gen-mapping" "^0.3.5"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@jridgewell/resolve-uri@^3.1.0":
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
|
||||
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
|
||||
|
||||
"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
|
||||
version "1.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
|
||||
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25":
|
||||
version "0.3.31"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0"
|
||||
integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@polka/url@^1.0.0-next.24":
|
||||
version "1.0.0-next.29"
|
||||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
|
||||
integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
|
||||
|
||||
"@rollup/plugin-commonjs@^29.0.0":
|
||||
version "29.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz#d2d84c49d0983d071f2ab96f4cfe02fe80abd602"
|
||||
integrity sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==
|
||||
dependencies:
|
||||
"@rollup/pluginutils" "^5.0.1"
|
||||
commondir "^1.0.1"
|
||||
estree-walker "^2.0.2"
|
||||
fdir "^6.2.0"
|
||||
is-reference "1.2.1"
|
||||
magic-string "^0.30.3"
|
||||
picomatch "^4.0.2"
|
||||
|
||||
"@rollup/plugin-json@^6.1.0":
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-6.1.0.tgz#fbe784e29682e9bb6dee28ea75a1a83702e7b805"
|
||||
integrity sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==
|
||||
dependencies:
|
||||
"@rollup/pluginutils" "^5.1.0"
|
||||
|
||||
"@rollup/plugin-node-resolve@^16.0.0":
|
||||
version "16.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz#0988e6f2cbb13316b0f5e7213f757bc9ed44928f"
|
||||
integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==
|
||||
dependencies:
|
||||
"@rollup/pluginutils" "^5.0.1"
|
||||
"@types/resolve" "1.20.2"
|
||||
deepmerge "^4.2.2"
|
||||
is-module "^1.0.0"
|
||||
resolve "^1.22.1"
|
||||
|
||||
"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
|
||||
integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.0"
|
||||
estree-walker "^2.0.2"
|
||||
picomatch "^4.0.2"
|
||||
|
||||
"@rollup/rollup-android-arm-eabi@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz#a19c645c375158cd5c50a344106f0fa18eb821c4"
|
||||
integrity sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==
|
||||
|
||||
"@rollup/rollup-android-arm64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz#1af19aa9d3ad6d00df2681f59cfcb8bf7499576b"
|
||||
integrity sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==
|
||||
|
||||
"@rollup/rollup-darwin-arm64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz#3b8463e03ba2a393453fea70e7d907379c27b649"
|
||||
integrity sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==
|
||||
|
||||
"@rollup/rollup-darwin-x64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz#28da23d69fe117f5f0ff330a8549e51bd09f1b6a"
|
||||
integrity sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==
|
||||
|
||||
"@rollup/rollup-freebsd-arm64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz#94bacac3190f621de1355922b599f3817786044c"
|
||||
integrity sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==
|
||||
|
||||
"@rollup/rollup-freebsd-x64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz#8a0094f533b9fda160b5c90ad9e0c78fca341788"
|
||||
integrity sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz#3b7e901a555c7245c87f7440979bee0a1ec882bb"
|
||||
integrity sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz#ee9a09b72e8ad764cfd6188b32ff1de528ff7ebe"
|
||||
integrity sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz#ba483f4aca9be141171d086dbd01ada6ab03b58d"
|
||||
integrity sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz#17b595b790e6df68e91c5d02526fc832a985ce4f"
|
||||
integrity sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz#551718714075a2bfb36a2813c466e3a0e9d56abf"
|
||||
integrity sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz#ba156ed1243447a3d710972001d5dcfe3827ff3d"
|
||||
integrity sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz#6a957a709b86ac62ef68e597ac03dbd4336782b1"
|
||||
integrity sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz#ca4176b4ad53f3edee3b4bfa6f9ef48ff38f167b"
|
||||
integrity sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz#4e6b08f72ebeafdb41f3ec433bd228ba8573473b"
|
||||
integrity sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz#a0b8b8580c7680c8086cb3226527e5472253b895"
|
||||
integrity sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz#79fe15b92ce0bae2b609cf26dd158cd3e2b73634"
|
||||
integrity sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz#6aa8302fa45fd3cbbc510ccd223c9c37bf67e53f"
|
||||
integrity sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==
|
||||
|
||||
"@rollup/rollup-linux-x64-musl@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz#0c1a5e9799f80c47a66f2c3a5f1a280f38356047"
|
||||
integrity sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==
|
||||
|
||||
"@rollup/rollup-openbsd-x64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz#5f07c863e74fd428794f1dc5749f321b661d1f17"
|
||||
integrity sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==
|
||||
|
||||
"@rollup/rollup-openharmony-arm64@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz#8e0d71324be0f423428b12b25a2eb8ea8e0a7833"
|
||||
integrity sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz#a553fdf90a785ace6d7501eed6241c468b088999"
|
||||
integrity sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz#0fb04f0a88027fbfd323e25a446debce4773868c"
|
||||
integrity sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz#aaa9e36dbdc0f0e397e5966dcce1b4285354ede2"
|
||||
integrity sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc@4.60.2":
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz#3418dcf1388f2abd6b0ccc08fe1ef205ae76d696"
|
||||
integrity sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==
|
||||
|
||||
"@standard-schema/spec@^1.0.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8"
|
||||
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
|
||||
|
||||
"@sveltejs/acorn-typescript@^1.0.5":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz#ac0bde368d6623727b0e0bc568cf6b4e5d5c4baa"
|
||||
integrity sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==
|
||||
|
||||
"@sveltejs/adapter-node@^5.2.12":
|
||||
version "5.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz#174eb6be21127223e539cfabf5c0b0517009e445"
|
||||
integrity sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==
|
||||
dependencies:
|
||||
"@rollup/plugin-commonjs" "^29.0.0"
|
||||
"@rollup/plugin-json" "^6.1.0"
|
||||
"@rollup/plugin-node-resolve" "^16.0.0"
|
||||
rollup "^4.59.0"
|
||||
|
||||
"@sveltejs/kit@^2.15.1":
|
||||
version "2.57.1"
|
||||
resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.57.1.tgz#3700c5b5549f1ffbfd42e5f71a80c2c82c0d849e"
|
||||
integrity sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==
|
||||
dependencies:
|
||||
"@standard-schema/spec" "^1.0.0"
|
||||
"@sveltejs/acorn-typescript" "^1.0.5"
|
||||
"@types/cookie" "^0.6.0"
|
||||
acorn "^8.14.1"
|
||||
cookie "^0.6.0"
|
||||
devalue "^5.6.4"
|
||||
esm-env "^1.2.2"
|
||||
kleur "^4.1.5"
|
||||
magic-string "^0.30.5"
|
||||
mrmime "^2.0.0"
|
||||
set-cookie-parser "^3.0.0"
|
||||
sirv "^3.0.0"
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector@^4.0.1":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz#2f99a4a593bb910d1492f6c00a042b521c07147e"
|
||||
integrity sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==
|
||||
dependencies:
|
||||
debug "^4.3.7"
|
||||
|
||||
"@sveltejs/vite-plugin-svelte@^5.0.3":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz#4db3b0475190a7148ad8740259ad57081bcfccbc"
|
||||
integrity sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==
|
||||
dependencies:
|
||||
"@sveltejs/vite-plugin-svelte-inspector" "^4.0.1"
|
||||
debug "^4.4.1"
|
||||
deepmerge "^4.3.1"
|
||||
kleur "^4.1.5"
|
||||
magic-string "^0.30.17"
|
||||
vitefu "^1.0.6"
|
||||
|
||||
"@types/cookie@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.6.0.tgz#eac397f28bf1d6ae0ae081363eca2f425bedf0d5"
|
||||
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
|
||||
|
||||
"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||
|
||||
"@types/geojson@*":
|
||||
version "7946.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a"
|
||||
integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==
|
||||
|
||||
"@types/leaflet@^1.9.15":
|
||||
version "1.9.21"
|
||||
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.21.tgz#542e8f91250bc444f8a1416d472f5b518d83e979"
|
||||
integrity sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==
|
||||
dependencies:
|
||||
"@types/geojson" "*"
|
||||
|
||||
"@types/node@^22.10.2":
|
||||
version "22.19.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.17.tgz#09c71fb34ba2510f8ac865361b1fcb9552b8a581"
|
||||
integrity sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==
|
||||
dependencies:
|
||||
undici-types "~6.21.0"
|
||||
|
||||
"@types/resolve@1.20.2":
|
||||
version "1.20.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
|
||||
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
|
||||
|
||||
"@types/trusted-types@^2.0.7":
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
|
||||
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
|
||||
|
||||
acorn@^8.12.1, acorn@^8.14.1:
|
||||
version "8.16.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
|
||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||
|
||||
aria-query@5.3.1:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.1.tgz#ebcb2c0d7fc43e68e4cb22f774d1209cb627ab42"
|
||||
integrity sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==
|
||||
|
||||
axobject-query@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
|
||||
integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==
|
||||
|
||||
chokidar@^4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
|
||||
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
|
||||
dependencies:
|
||||
readdirp "^4.0.1"
|
||||
|
||||
clsx@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
|
||||
commondir@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
|
||||
integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==
|
||||
|
||||
cookie@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
||||
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
||||
|
||||
debug@^4.3.7, debug@^4.4.1:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
deepmerge@^4.2.2, deepmerge@^4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
||||
|
||||
devalue@^5.6.4:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.7.1.tgz#93e2d9412b909a7901d7b966ebb3479d15a390fd"
|
||||
integrity sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==
|
||||
|
||||
es-errors@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
|
||||
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
|
||||
|
||||
esbuild@^0.25.0:
|
||||
version "0.25.12"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5"
|
||||
integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==
|
||||
optionalDependencies:
|
||||
"@esbuild/aix-ppc64" "0.25.12"
|
||||
"@esbuild/android-arm" "0.25.12"
|
||||
"@esbuild/android-arm64" "0.25.12"
|
||||
"@esbuild/android-x64" "0.25.12"
|
||||
"@esbuild/darwin-arm64" "0.25.12"
|
||||
"@esbuild/darwin-x64" "0.25.12"
|
||||
"@esbuild/freebsd-arm64" "0.25.12"
|
||||
"@esbuild/freebsd-x64" "0.25.12"
|
||||
"@esbuild/linux-arm" "0.25.12"
|
||||
"@esbuild/linux-arm64" "0.25.12"
|
||||
"@esbuild/linux-ia32" "0.25.12"
|
||||
"@esbuild/linux-loong64" "0.25.12"
|
||||
"@esbuild/linux-mips64el" "0.25.12"
|
||||
"@esbuild/linux-ppc64" "0.25.12"
|
||||
"@esbuild/linux-riscv64" "0.25.12"
|
||||
"@esbuild/linux-s390x" "0.25.12"
|
||||
"@esbuild/linux-x64" "0.25.12"
|
||||
"@esbuild/netbsd-arm64" "0.25.12"
|
||||
"@esbuild/netbsd-x64" "0.25.12"
|
||||
"@esbuild/openbsd-arm64" "0.25.12"
|
||||
"@esbuild/openbsd-x64" "0.25.12"
|
||||
"@esbuild/openharmony-arm64" "0.25.12"
|
||||
"@esbuild/sunos-x64" "0.25.12"
|
||||
"@esbuild/win32-arm64" "0.25.12"
|
||||
"@esbuild/win32-ia32" "0.25.12"
|
||||
"@esbuild/win32-x64" "0.25.12"
|
||||
|
||||
esm-env@^1.2.1, esm-env@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/esm-env/-/esm-env-1.2.2.tgz#263c9455c55861f41618df31b20cb571fc20b75e"
|
||||
integrity sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==
|
||||
|
||||
esrap@^2.2.4:
|
||||
version "2.2.5"
|
||||
resolved "https://registry.yarnpkg.com/esrap/-/esrap-2.2.5.tgz#23dd923664bd84d7b069ce3dea3b3c0dc91b6d3d"
|
||||
integrity sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.4.15"
|
||||
|
||||
estree-walker@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
|
||||
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
|
||||
|
||||
fdir@^6.2.0, fdir@^6.4.4, fdir@^6.5.0:
|
||||
version "6.5.0"
|
||||
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
|
||||
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
|
||||
|
||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
function-bind@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||
|
||||
hasown@^2.0.2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
|
||||
integrity sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
is-core-module@^2.16.1:
|
||||
version "2.16.1"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
|
||||
integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
|
||||
dependencies:
|
||||
hasown "^2.0.2"
|
||||
|
||||
is-module@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
|
||||
integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
|
||||
|
||||
is-reference@1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
|
||||
integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
|
||||
is-reference@^3.0.3:
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f"
|
||||
integrity sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.6"
|
||||
|
||||
kleur@^4.1.5:
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
|
||||
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
|
||||
|
||||
leaflet@^1.9.4:
|
||||
version "1.9.4"
|
||||
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"
|
||||
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
|
||||
|
||||
locate-character@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-character/-/locate-character-3.0.0.tgz#0305c5b8744f61028ef5d01f444009e00779f974"
|
||||
integrity sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==
|
||||
|
||||
magic-string@^0.30.11, magic-string@^0.30.17, magic-string@^0.30.3, magic-string@^0.30.5:
|
||||
version "0.30.21"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
|
||||
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.5.5"
|
||||
|
||||
mri@^1.1.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
|
||||
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
|
||||
|
||||
mrmime@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
|
||||
integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
|
||||
|
||||
ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||
|
||||
nanoid@^3.3.11:
|
||||
version "3.3.11"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
||||
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
|
||||
|
||||
path-parse@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
||||
picomatch@^4.0.2, picomatch@^4.0.4:
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||
|
||||
postcss@^8.5.3:
|
||||
version "8.5.10"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356"
|
||||
integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==
|
||||
dependencies:
|
||||
nanoid "^3.3.11"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
readdirp@^4.0.1:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||
|
||||
resolve@^1.22.1:
|
||||
version "1.22.12"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f"
|
||||
integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==
|
||||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
is-core-module "^2.16.1"
|
||||
path-parse "^1.0.7"
|
||||
supports-preserve-symlinks-flag "^1.0.0"
|
||||
|
||||
rollup@^4.34.9, rollup@^4.59.0:
|
||||
version "4.60.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.2.tgz#ac23fe4bd530304cef9fa61e639d7098b6762cf4"
|
||||
integrity sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==
|
||||
dependencies:
|
||||
"@types/estree" "1.0.8"
|
||||
optionalDependencies:
|
||||
"@rollup/rollup-android-arm-eabi" "4.60.2"
|
||||
"@rollup/rollup-android-arm64" "4.60.2"
|
||||
"@rollup/rollup-darwin-arm64" "4.60.2"
|
||||
"@rollup/rollup-darwin-x64" "4.60.2"
|
||||
"@rollup/rollup-freebsd-arm64" "4.60.2"
|
||||
"@rollup/rollup-freebsd-x64" "4.60.2"
|
||||
"@rollup/rollup-linux-arm-gnueabihf" "4.60.2"
|
||||
"@rollup/rollup-linux-arm-musleabihf" "4.60.2"
|
||||
"@rollup/rollup-linux-arm64-gnu" "4.60.2"
|
||||
"@rollup/rollup-linux-arm64-musl" "4.60.2"
|
||||
"@rollup/rollup-linux-loong64-gnu" "4.60.2"
|
||||
"@rollup/rollup-linux-loong64-musl" "4.60.2"
|
||||
"@rollup/rollup-linux-ppc64-gnu" "4.60.2"
|
||||
"@rollup/rollup-linux-ppc64-musl" "4.60.2"
|
||||
"@rollup/rollup-linux-riscv64-gnu" "4.60.2"
|
||||
"@rollup/rollup-linux-riscv64-musl" "4.60.2"
|
||||
"@rollup/rollup-linux-s390x-gnu" "4.60.2"
|
||||
"@rollup/rollup-linux-x64-gnu" "4.60.2"
|
||||
"@rollup/rollup-linux-x64-musl" "4.60.2"
|
||||
"@rollup/rollup-openbsd-x64" "4.60.2"
|
||||
"@rollup/rollup-openharmony-arm64" "4.60.2"
|
||||
"@rollup/rollup-win32-arm64-msvc" "4.60.2"
|
||||
"@rollup/rollup-win32-ia32-msvc" "4.60.2"
|
||||
"@rollup/rollup-win32-x64-gnu" "4.60.2"
|
||||
"@rollup/rollup-win32-x64-msvc" "4.60.2"
|
||||
fsevents "~2.3.2"
|
||||
|
||||
sade@^1.7.4:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
|
||||
integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
|
||||
dependencies:
|
||||
mri "^1.1.0"
|
||||
|
||||
set-cookie-parser@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz#e0b1d94c8660c68e6a24dc4e2b5c9e955ccf7e28"
|
||||
integrity sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==
|
||||
|
||||
sirv@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970"
|
||||
integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==
|
||||
dependencies:
|
||||
"@polka/url" "^1.0.0-next.24"
|
||||
mrmime "^2.0.0"
|
||||
totalist "^3.0.0"
|
||||
|
||||
source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||
|
||||
supports-preserve-symlinks-flag@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||
|
||||
svelte-check@^4.1.1:
|
||||
version "4.4.6"
|
||||
resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-4.4.6.tgz#c890a102e94ef31b44bea26f5f16de14db717a3b"
|
||||
integrity sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping" "^0.3.25"
|
||||
chokidar "^4.0.1"
|
||||
fdir "^6.2.0"
|
||||
picocolors "^1.0.0"
|
||||
sade "^1.7.4"
|
||||
|
||||
svelte@^5.16.0:
|
||||
version "5.55.4"
|
||||
resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.55.4.tgz#b6e9d0b8fce8709ecc4ab43d5ea597a5a38b4c1e"
|
||||
integrity sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==
|
||||
dependencies:
|
||||
"@jridgewell/remapping" "^2.3.4"
|
||||
"@jridgewell/sourcemap-codec" "^1.5.0"
|
||||
"@sveltejs/acorn-typescript" "^1.0.5"
|
||||
"@types/estree" "^1.0.5"
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
acorn "^8.12.1"
|
||||
aria-query "5.3.1"
|
||||
axobject-query "^4.1.0"
|
||||
clsx "^2.1.1"
|
||||
devalue "^5.6.4"
|
||||
esm-env "^1.2.1"
|
||||
esrap "^2.2.4"
|
||||
is-reference "^3.0.3"
|
||||
locate-character "^3.0.0"
|
||||
magic-string "^0.30.11"
|
||||
zimmerframe "^1.1.2"
|
||||
|
||||
tinyglobby@^0.2.13:
|
||||
version "0.2.16"
|
||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
|
||||
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
|
||||
dependencies:
|
||||
fdir "^6.5.0"
|
||||
picomatch "^4.0.4"
|
||||
|
||||
totalist@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
|
||||
integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
|
||||
|
||||
typescript@^5.7.2:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
|
||||
vite@^6.0.6:
|
||||
version "6.4.2"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.2.tgz#a4e548ca3a90ca9f3724582cab35e1ba15efc6f2"
|
||||
integrity sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==
|
||||
dependencies:
|
||||
esbuild "^0.25.0"
|
||||
fdir "^6.4.4"
|
||||
picomatch "^4.0.2"
|
||||
postcss "^8.5.3"
|
||||
rollup "^4.34.9"
|
||||
tinyglobby "^0.2.13"
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
vitefu@^1.0.6:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-1.1.3.tgz#59b9885b1c200856319d7e9ceb0e99a065430009"
|
||||
integrity sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==
|
||||
|
||||
zimmerframe@^1.1.2:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/zimmerframe/-/zimmerframe-1.1.4.tgz#0352b5cafad3ad4526b0a526a9a52d9c040d865b"
|
||||
integrity sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==
|
||||