added admin dashboard
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
export type CopySlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
section: string;
|
||||
hint?: string;
|
||||
kind: 'inline' | 'multiline';
|
||||
};
|
||||
|
||||
export type ImageSlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
export type Testimonial = {
|
||||
id: string;
|
||||
text: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatarSrc: string;
|
||||
avatarAlt: string;
|
||||
};
|
||||
|
||||
export type SiteContent = {
|
||||
copy: Record<string, string>;
|
||||
images: Record<string, ImageSlot>;
|
||||
testimonials: Testimonial[];
|
||||
seo: {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
ogImage: string;
|
||||
primaryKeywords: string[];
|
||||
geoRegion: string;
|
||||
geoPlacename: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOTS: CopySlot[] = [
|
||||
{ id: 'nav.brand', section: 'Navbar', label: 'Brand name', kind: 'inline', value: 'Giampy Dog Service' },
|
||||
{ id: 'nav.link.servizi', section: 'Navbar', label: 'Link — Servizi', kind: 'inline', value: 'Servizi' },
|
||||
{ id: 'nav.link.chi_sono', section: 'Navbar', label: 'Link — Chi Sono', kind: 'inline', value: 'Chi Sono' },
|
||||
{ id: 'nav.link.testimonianze', section: 'Navbar', label: 'Link — Testimonianze', kind: 'inline', value: 'Testimonianze' },
|
||||
{ id: 'nav.link.dogwash', section: 'Navbar', label: 'Link — DogWash', kind: 'inline', value: 'DogWash' },
|
||||
{ id: 'nav.cta', section: 'Navbar', label: 'CTA', kind: 'inline', value: 'Scrivimi' },
|
||||
|
||||
{ id: 'hero.kicker', section: 'Hero', label: 'Kicker', kind: 'inline', value: 'Con te dal 2018' },
|
||||
{ id: 'hero.title_prefix', section: 'Hero', label: 'Title — first line', kind: 'inline', value: 'Tratto il tuo cane' },
|
||||
{ id: 'hero.title_suffix', section: 'Hero', label: 'Title — second line (before emphasis)', kind: 'inline', value: 'come se fosse' },
|
||||
{ id: 'hero.title_em', section: 'Hero', label: 'Title — emphasis', kind: 'inline', value: 'il mio.' },
|
||||
{ id: 'hero.desc', section: 'Hero', label: 'Description', kind: 'multiline', value: 'Sono Giampiero, educatore cinofilo certificato CONI. Mi occupo personalmente di ogni cane che mi affidi — niente gabbie, niente solitudine, solo tanto amore vero.' },
|
||||
{ id: 'hero.cta_primary', section: 'Hero', label: 'Primary CTA', kind: 'inline', value: 'Scopri i Servizi' },
|
||||
{ id: 'hero.cta_outline', section: 'Hero', label: 'Outline CTA', kind: 'inline', value: 'Scrivimi ora' },
|
||||
{ id: 'hero.badge_label', section: 'Hero', label: 'Badge label', kind: 'inline', value: '5.0 Recensioni' },
|
||||
{ id: 'hero.badge_text', section: 'Hero', label: 'Badge text', kind: 'inline', value: 'Il punto di riferimento per i proprietari di cani a Sassari.' },
|
||||
{ id: 'hero.stat1_num', section: 'Hero', label: 'Stat 1 — value', kind: 'inline', value: '100+' },
|
||||
{ id: 'hero.stat1_label', section: 'Hero', label: 'Stat 1 — label', kind: 'inline', value: 'Clienti Felici' },
|
||||
{ id: 'hero.stat2_num', section: 'Hero', label: 'Stat 2 — value', kind: 'inline', value: '2018' },
|
||||
{ id: 'hero.stat2_label', section: 'Hero', label: 'Stat 2 — label', kind: 'inline', value: 'Anno di inizio' },
|
||||
{ id: 'hero.stat3_num', section: 'Hero', label: 'Stat 3 — value', kind: 'inline', value: '5.0★' },
|
||||
{ id: 'hero.stat3_label', section: 'Hero', label: 'Stat 3 — label', kind: 'inline', value: 'Recensioni' },
|
||||
|
||||
{ id: 'why.kicker', section: 'Why', label: 'Kicker', kind: 'inline', value: 'Perché scegliere me' },
|
||||
{ id: 'why.title_1', section: 'Why', label: 'Title — line 1', kind: 'inline', value: 'Qualcosa di diverso,' },
|
||||
{ id: 'why.title_2', section: 'Why', label: 'Title — line 2', kind: 'inline', value: 'lo senti anche tu.' },
|
||||
{ id: 'why.sub', section: 'Why', label: 'Subtitle', kind: 'inline', value: 'Non ho una squadra — ho me stesso, presente ogni giorno.' },
|
||||
{ id: 'why.card1_title', section: 'Why', label: 'Card 1 — title', kind: 'inline', value: 'Dal 2018, ogni giorno' },
|
||||
{ id: 'why.card1_text', section: 'Why', label: 'Card 1 — text', kind: 'multiline', value: 'Ho iniziato per passione e non mi sono più fermato. Anni di esperienza vera, sul campo, costruita cane dopo cane.' },
|
||||
{ id: 'why.card2_title', section: 'Why', label: 'Card 2 — title', kind: 'inline', value: 'Un rapporto vero' },
|
||||
{ id: 'why.card2_text', section: 'Why', label: 'Card 2 — text', kind: 'multiline', value: 'Con te e con il tuo cane costruisco un legame di fiducia. Non sei un cliente, sei parte della mia famiglia pelosa.' },
|
||||
{ id: 'why.card3_title', section: 'Why', label: 'Card 3 — title', kind: 'inline', value: 'Preparato, non improvvisato' },
|
||||
{ id: 'why.card3_text', section: 'Why', label: 'Card 3 — text', kind: 'multiline', value: 'Certificazione CONI e formazione continua. Porto metodo e competenza, con la leggerezza di chi ama davvero quello che fa.' },
|
||||
{ id: 'why.card4_title', section: 'Why', label: 'Card 4 — title', kind: 'inline', value: 'Zero gabbie, promesso' },
|
||||
{ id: 'why.card4_text', section: 'Why', label: 'Card 4 — text', kind: 'multiline', value: 'Non le uso, non le tollero. Libertà e comfort sono la base di tutto quello che faccio — sempre.' },
|
||||
|
||||
{ id: 'services.kicker', section: 'Services', label: 'Kicker', kind: 'inline', value: 'Cosa faccio per te' },
|
||||
{ id: 'services.title', section: 'Services', label: 'Title', kind: 'inline', value: 'I miei servizi' },
|
||||
{ id: 'services.sub', section: 'Services', label: 'Subtitle', kind: 'inline', value: 'Ho pensato a tutto io — tu pensa solo a coccolarlo.' },
|
||||
{ id: 'services.card1_tag', section: 'Services', label: 'Card 1 — tag', kind: 'inline', value: 'Movimento & Gioia' },
|
||||
{ id: 'services.card1_title', section: 'Services', label: 'Card 1 — title', kind: 'inline', value: 'Dogwalking' },
|
||||
{ id: 'services.card1_text', section: 'Services', label: 'Card 1 — text', kind: 'multiline', value: "Passeggiate esplorative nei sentieri più belli di Sassari. Ogni uscita è un'avventura pensata per stimolare mente e corpo del tuo cane." },
|
||||
{ id: 'services.card1_cta', section: 'Services', label: 'Card 1 — CTA', kind: 'inline', value: 'Prenota una passeggiata' },
|
||||
{ id: 'services.card2_tag', section: 'Services', label: 'Card 2 — tag', kind: 'inline', value: 'Come a casa' },
|
||||
{ id: 'services.card2_title', section: 'Services', label: 'Card 2 — title', kind: 'inline', value: 'Home Boarding' },
|
||||
{ id: 'services.card2_text', section: 'Services', label: 'Card 2 — text', kind: 'multiline', value: 'Quando sei via, il tuo cane sta con me — in appartamento, libero, coccolato. Ogni giorno ti mando foto e aggiornamenti.' },
|
||||
{ id: 'services.card2_cta', section: 'Services', label: 'Card 2 — CTA', kind: 'inline', value: 'Chiedi disponibilità' },
|
||||
{ id: 'services.card3_tag', section: 'Services', label: 'Card 3 — tag', kind: 'inline', value: 'Giorni speciali' },
|
||||
{ id: 'services.card3_title', section: 'Services', label: 'Card 3 — title', kind: 'inline', value: 'Wedding Dogsitter' },
|
||||
{ id: 'services.card3_text', section: 'Services', label: 'Card 3 — text', kind: 'multiline', value: 'Il tuo migliore amico nel tuo giorno più bello. Me ne occupo io, così tu puoi goderti ogni momento senza pensieri.' },
|
||||
{ id: 'services.card3_cta', section: 'Services', label: 'Card 3 — CTA', kind: 'inline', value: 'Parliamo del tuo evento' },
|
||||
|
||||
{ id: 'about.quote', section: 'About', label: 'Quote', kind: 'multiline', value: '"Rusty non era solo un cane, era il maestro che mi ha indicato la strada."' },
|
||||
{ id: 'about.cite', section: 'About', label: 'Cite', kind: 'inline', value: '— Giampiero Scaglione' },
|
||||
{ id: 'about.kicker', section: 'About', label: 'Kicker', kind: 'inline', value: 'Chi sono' },
|
||||
{ id: 'about.title_1', section: 'About', label: 'Title — line 1', kind: 'inline', value: 'Da una necessità' },
|
||||
{ id: 'about.title_2', section: 'About', label: 'Title — line 2', kind: 'inline', value: 'del cuore, a una' },
|
||||
{ id: 'about.title_3', section: 'About', label: 'Title — line 3', kind: 'inline', value: 'missione di vita.' },
|
||||
{ id: 'about.body_1', section: 'About', label: 'Body — paragraph 1', kind: 'multiline', value: 'Tutto è partito da Rusty — il mitico Rustone. Quello che era iniziato come un lavoretto si è trasformato in una vocazione. Ho capito che non volevo fare altro nella vita.' },
|
||||
{ id: 'about.body_2', section: 'About', label: 'Body — paragraph 2', kind: 'multiline', value: "Ho preso la certificazione CONI, ho camminato il Cammino di Santiago con il mio cane, e ogni giorno continuo a imparare — dai cani, dai loro umani, dalla vita all'aperto." },
|
||||
{ id: 'about.mile1_title', section: 'About', label: 'Milestone 1 — title', kind: 'inline', value: 'Cammino di Santiago' },
|
||||
{ id: 'about.mile1_text', section: 'About', label: 'Milestone 1 — text', kind: 'inline', value: 'Percorso a piedi con il mio cane. La prova più bella di tutto.' },
|
||||
{ id: 'about.mile2_title', section: 'About', label: 'Milestone 2 — title', kind: 'inline', value: 'Certificazione CONI' },
|
||||
{ id: 'about.mile2_text', section: 'About', label: 'Milestone 2 — text', kind: 'inline', value: "Formazione d'eccellenza in ambito cinofilo. Studio ancora." },
|
||||
|
||||
{ id: 'media.label', section: 'Media', label: 'Label', kind: 'inline', value: 'Riconoscimenti & Media' },
|
||||
{ id: 'media.logo_1', section: 'Media', label: 'Logo 1', kind: 'inline', value: "L'Unione Sarda" },
|
||||
{ id: 'media.logo_2', section: 'Media', label: 'Logo 2', kind: 'inline', value: 'Sardegna Reporter' },
|
||||
{ id: 'media.logo_3', section: 'Media', label: 'Logo 3', kind: 'inline', value: 'Radio Super Sound' },
|
||||
{ id: 'media.logo_4', section: 'Media', label: 'Logo 4', kind: 'inline', value: 'Qualifica CONI' },
|
||||
{ id: 'media.logo_5', section: 'Media', label: 'Logo 5', kind: 'inline', value: 'Corsa in Rosa' },
|
||||
{ id: 'media.logo_6', section: 'Media', label: 'Logo 6', kind: 'inline', value: 'Giornata del Super Cane' },
|
||||
|
||||
{ id: 'dogwash.tag', section: 'DogWash', label: 'Tag', kind: 'inline', value: 'Novità in Sardegna' },
|
||||
{ id: 'dogwash.title', section: 'DogWash', label: 'Title', kind: 'inline', value: 'DogWash Sardegna' },
|
||||
{ id: 'dogwash.desc', section: 'DogWash', label: 'Description', kind: 'multiline', value: 'Ho avuto un sogno: portare le colonnine di lavaggio automatico in tutta la Sardegna. Grazie alla fiducia della community, quel sogno sta diventando realtà, una colonnina alla volta.' },
|
||||
{ id: 'dogwash.cta', section: 'DogWash', label: 'CTA', kind: 'inline', value: 'Trova la postazione più vicina' },
|
||||
{ id: 'dogwash.community', section: 'DogWash', label: 'Community note', kind: 'inline', value: 'Un progetto nato dal cuore di Sassari.' },
|
||||
|
||||
{ id: 'testi.stats1_num', section: 'Testimonials', label: 'Stat 1 — value', kind: 'inline', value: '100+' },
|
||||
{ id: 'testi.stats1_label', section: 'Testimonials', label: 'Stat 1 — label', kind: 'inline', value: 'Clienti Felici' },
|
||||
{ id: 'testi.stats2_num', section: 'Testimonials', label: 'Stat 2 — value', kind: 'inline', value: '2018' },
|
||||
{ id: 'testi.stats2_label', section: 'Testimonials', label: 'Stat 2 — label', kind: 'inline', value: 'Anno di inizio' },
|
||||
{ id: 'testi.kicker', section: 'Testimonials', label: 'Kicker', kind: 'inline', value: 'Cosa dicono di me' },
|
||||
{ id: 'testi.title', section: 'Testimonials', label: 'Title', kind: 'inline', value: 'I miei ospiti parlano.' },
|
||||
{ id: 'testi.sub', section: 'Testimonials', label: 'Subtitle', kind: 'inline', value: 'Non solo clienti — una vera famiglia pelosa.' },
|
||||
|
||||
{ id: 'contact.headline', section: 'Contact', label: 'Headline', kind: 'inline', value: 'Affida il tuo cane a chi lo vive davvero.' },
|
||||
{ id: 'contact.form_title', section: 'Contact', label: 'Form — title', kind: 'inline', value: 'Scrivimi ora' },
|
||||
{ id: 'contact.form_sub', section: 'Contact', label: 'Form — subtitle', kind: 'inline', value: 'Hai domande o vuoi prenotare? Ti rispondo di persona entro 24 ore.' },
|
||||
{ id: 'contact.label_name', section: 'Contact', label: 'Label — Name', kind: 'inline', value: 'Il tuo Nome' },
|
||||
{ id: 'contact.placeholder_name', section: 'Contact', label: 'Placeholder — Name', kind: 'inline', value: 'Nome e Cognome' },
|
||||
{ id: 'contact.label_dog', section: 'Contact', label: 'Label — Dog', kind: 'inline', value: 'Come si chiama il tuo cane?' },
|
||||
{ id: 'contact.placeholder_dog', section: 'Contact', label: 'Placeholder — Dog', kind: 'inline', value: 'Il nome del peloso...' },
|
||||
{ id: 'contact.label_msg', section: 'Contact', label: 'Label — Message', kind: 'inline', value: 'Il tuo messaggio' },
|
||||
{ id: 'contact.placeholder_msg', section: 'Contact', label: 'Placeholder — Message', kind: 'inline', value: 'Raccontami di te e del tuo cane...' },
|
||||
{ id: 'contact.submit', section: 'Contact', label: 'Submit button', kind: 'inline', value: 'Invia il messaggio' },
|
||||
{ id: 'contact.success', section: 'Contact', label: 'Success message', kind: 'inline', value: 'Perfetto! Ti scrivo presto.' },
|
||||
{ id: 'contact.info_title', section: 'Contact', label: 'Info title', kind: 'inline', value: 'Contatti rapidi' },
|
||||
{ id: 'contact.phone_label', section: 'Contact', label: 'Phone label', kind: 'inline', value: 'Chiamami' },
|
||||
{ id: 'contact.phone_value', section: 'Contact', label: 'Phone value', kind: 'inline', value: '+39 347 123 4567' },
|
||||
{ id: 'contact.email_label', section: 'Contact', label: 'Email label', kind: 'inline', value: 'Email' },
|
||||
{ id: 'contact.email_value', section: 'Contact', label: 'Email value', kind: 'inline', value: 'info@giampydogservice.it' },
|
||||
{ id: 'contact.wa_label', section: 'Contact', label: 'WhatsApp label', kind: 'inline', value: 'WhatsApp' },
|
||||
{ id: 'contact.wa_value', section: 'Contact', label: 'WhatsApp value', kind: 'inline', value: 'Chatta direttamente con me' },
|
||||
|
||||
{ id: 'footer.tagline', section: 'Footer', label: 'Tagline', kind: 'multiline', value: 'Mi prendo cura dei tuoi amici come fossero i miei. Dal 2018, ogni giorno, con passione.' },
|
||||
{ id: 'footer.col1_title', section: 'Footer', label: 'Column 1 — title', kind: 'inline', value: 'Servizi' },
|
||||
{ id: 'footer.col1_l1', section: 'Footer', label: 'Column 1 — link 1', kind: 'inline', value: 'Dogwalking' },
|
||||
{ id: 'footer.col1_l2', section: 'Footer', label: 'Column 1 — link 2', kind: 'inline', value: 'Home Boarding' },
|
||||
{ id: 'footer.col1_l3', section: 'Footer', label: 'Column 1 — link 3', kind: 'inline', value: 'Wedding Dogsitter' },
|
||||
{ id: 'footer.col1_l4', section: 'Footer', label: 'Column 1 — link 4', kind: 'inline', value: 'DogWash' },
|
||||
{ id: 'footer.col2_title', section: 'Footer', label: 'Column 2 — title', kind: 'inline', value: 'Link Utili' },
|
||||
{ id: 'footer.col2_l1', section: 'Footer', label: 'Column 2 — link 1', kind: 'inline', value: 'Chi Sono' },
|
||||
{ id: 'footer.col2_l2', section: 'Footer', label: 'Column 2 — link 2', kind: 'inline', value: 'Testimonianze' },
|
||||
{ id: 'footer.col2_l3', section: 'Footer', label: 'Column 2 — link 3', kind: 'inline', value: 'Privacy Policy' },
|
||||
{ id: 'footer.col2_l4', section: 'Footer', label: 'Column 2 — link 4', kind: 'inline', value: 'Termini e Condizioni' },
|
||||
{ id: 'footer.copyright', section: 'Footer', label: 'Copyright', kind: 'inline', value: '© 2024 Giampy Dog Service — Sassari, Sardegna' },
|
||||
{ id: 'footer.powered', section: 'Footer', label: 'Powered by', kind: 'inline', value: 'Powered by' }
|
||||
];
|
||||
|
||||
export const DEFAULT_IMAGES: Record<string, ImageSlot> = {
|
||||
favicon: { id: 'favicon', label: 'Favicon (icona browser)', src: '/img/logo.png', alt: 'Giampy Dog Service' },
|
||||
logo: { id: 'logo', label: 'Brand logo', src: '/img/logo.png', alt: 'Giampy Dog Service Logo' },
|
||||
hero: { id: 'hero', label: 'Hero photo', src: '/img/hero-image.png', alt: 'Giampy con un cane' },
|
||||
chi_sono: { id: 'chi_sono', label: 'About photo', src: '/img/chi-sono.png', alt: 'Giampy passeggia con un cane' },
|
||||
dogwash: { id: 'dogwash', label: 'DogWash visual', src: '/img/colonnina.png', alt: 'DogWash Machine' },
|
||||
cima_logo: { id: 'cima_logo', label: 'Cima Progetti logo', src: '/img/cima-logo.svg', alt: 'Cima Progetti' }
|
||||
};
|
||||
|
||||
export const DEFAULT_TESTIMONIALS: Testimonial[] = [
|
||||
{
|
||||
id: 't_marta',
|
||||
text: "Giampiero è l'unico di cui mi fido ciecamente. Il mio Max torna sempre felice e rilassato. Non cambierei mai!",
|
||||
name: 'Marta S.',
|
||||
role: 'Mamma di Max',
|
||||
avatarSrc: '/img/avatar-1.png',
|
||||
avatarAlt: 'Marta S.'
|
||||
},
|
||||
{
|
||||
id: 't_luca',
|
||||
text: 'Ha gestito il nostro Luna durante il matrimonio in modo impeccabile. Eravamo tranquilli, sapevamo che era in mani fantastiche.',
|
||||
name: 'Luca & Elena',
|
||||
role: 'Genitori di Luna',
|
||||
avatarSrc: '/img/avatar-2.png',
|
||||
avatarAlt: 'Luca & Elena'
|
||||
},
|
||||
{
|
||||
id: 't_sara',
|
||||
text: 'Niente gabbie, tanto amore. Ogni giorno mi mandava foto di Felix felice. La pensione in appartamento che sognavo!',
|
||||
name: 'Sara P.',
|
||||
role: 'Compagna di Felix',
|
||||
avatarSrc: '/img/avatar-3.png',
|
||||
avatarAlt: 'Sara P.'
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SEO = {
|
||||
title: 'Giampy Dog Service — Il tuo cane in buone mani',
|
||||
description:
|
||||
'Educatore cinofilo certificato CONI a Sassari. Dogwalking, home boarding e wedding dogsitter. Zero gabbie, solo amore vero dal 2018.',
|
||||
keywords:
|
||||
'dogsitter sassari, dogwalker sardegna, educatore cinofilo, home boarding cani, wedding dogsitter, dog wash sardegna',
|
||||
ogImage: '/img/hero-image.png',
|
||||
primaryKeywords: ['dogsitter sassari', 'dogwalker sardegna', 'educatore cinofilo coni'],
|
||||
geoRegion: 'IT-SS',
|
||||
geoPlacename: 'Sassari, Sardegna'
|
||||
};
|
||||
|
||||
export function buildDefaultContent(): SiteContent {
|
||||
const copy: Record<string, string> = {};
|
||||
for (const s of DEFAULT_SLOTS) copy[s.id] = s.value;
|
||||
return {
|
||||
copy,
|
||||
images: { ...DEFAULT_IMAGES },
|
||||
testimonials: DEFAULT_TESTIMONIALS.map((t) => ({ ...t })),
|
||||
seo: { ...DEFAULT_SEO }
|
||||
};
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
export type CopyAnalysis = {
|
||||
length: number;
|
||||
words: number;
|
||||
sentences: number;
|
||||
avgWordsPerSentence: number;
|
||||
readability: number; // 0..100, higher = easier
|
||||
keywordMatches: string[];
|
||||
missingKeywords: string[];
|
||||
seoScore: number; // 0..100
|
||||
geoScore: number; // 0..100
|
||||
seoTips: string[];
|
||||
geoTips: string[];
|
||||
};
|
||||
|
||||
const SYLLABLE_VOWELS = /[aeiouàèéìòùAEIOUÀÈÉÌÒÙ]+/g;
|
||||
|
||||
function countSyllables(word: string): number {
|
||||
const matches = word.match(SYLLABLE_VOWELS);
|
||||
return matches ? matches.length : 1;
|
||||
}
|
||||
|
||||
export function analyzeCopy(text: string, primaryKeywords: string[] = []): CopyAnalysis {
|
||||
const clean = text.trim();
|
||||
const length = clean.length;
|
||||
const wordList = clean.split(/\s+/).filter(Boolean);
|
||||
const words = wordList.length;
|
||||
const sentences = Math.max(1, (clean.match(/[.!?]+/g) ?? []).length);
|
||||
const avgWordsPerSentence = words / sentences;
|
||||
|
||||
// Flesch reading ease approximation
|
||||
const syllables = wordList.reduce((n, w) => n + countSyllables(w), 0);
|
||||
const asl = words ? words / sentences : 0;
|
||||
const asw = words ? syllables / words : 0;
|
||||
const readability = Math.max(0, Math.min(100, Math.round(206.835 - 1.015 * asl - 84.6 * asw)));
|
||||
|
||||
const lower = clean.toLowerCase();
|
||||
const keywordMatches: string[] = [];
|
||||
const missingKeywords: string[] = [];
|
||||
for (const kw of primaryKeywords) {
|
||||
if (!kw) continue;
|
||||
if (lower.includes(kw.toLowerCase())) keywordMatches.push(kw);
|
||||
else missingKeywords.push(kw);
|
||||
}
|
||||
|
||||
// SEO tips
|
||||
const seoTips: string[] = [];
|
||||
if (length === 0) seoTips.push('Il testo è vuoto.');
|
||||
if (length > 0 && length < 40) seoTips.push('Testo molto breve: difficile farsi indicizzare.');
|
||||
if (length > 320) seoTips.push('Testo lungo: valuta di spezzarlo in paragrafi o bullet.');
|
||||
if (avgWordsPerSentence > 24) seoTips.push('Frasi troppo lunghe: scendi sotto le 24 parole per frase.');
|
||||
if (readability < 45) seoTips.push('Leggibilità bassa: semplifica lessico e sintassi.');
|
||||
if (missingKeywords.length && words > 4) {
|
||||
seoTips.push(`Manca una keyword primaria: ${missingKeywords.slice(0, 2).join(', ')}.`);
|
||||
}
|
||||
if (/[A-Z]{6,}/.test(clean)) seoTips.push('Evita testo tutto maiuscolo: penalizza la leggibilità.');
|
||||
|
||||
// GEO tips (Generative Engine Optimization)
|
||||
const geoTips: string[] = [];
|
||||
const questionMarks = (clean.match(/\?/g) ?? []).length;
|
||||
const hasNumbers = /\d/.test(clean);
|
||||
const hasEntities = /\b(sassari|sardegna|coni|cammino di santiago|giampy)\b/i.test(clean);
|
||||
const firstPerson = /\b(io|mio|mia|miei|mie|sono|faccio|porto|prendo)\b/i.test(clean);
|
||||
const declarative = sentences >= 1 && /[.!]/.test(clean);
|
||||
|
||||
if (words >= 20 && questionMarks === 0)
|
||||
geoTips.push('Aggiungi una domanda esplicita per farti citare come risposta diretta.');
|
||||
if (!hasNumbers && words >= 20)
|
||||
geoTips.push('Inserisci dati concreti (anni, numero clienti, distanze) per aumentare la citabilità.');
|
||||
if (!hasEntities && words >= 15)
|
||||
geoTips.push('Nomina entità specifiche (luogo, certificazione, brand) per il grounding generativo.');
|
||||
if (!firstPerson && words >= 20)
|
||||
geoTips.push('Usa la prima persona: i motori generativi estraggono più facilmente testimonianze autoriali.');
|
||||
if (!declarative && words >= 10)
|
||||
geoTips.push('Termina con punto fermo: le frasi complete vengono citate più spesso.');
|
||||
|
||||
// Scores
|
||||
let seoScore = 100;
|
||||
seoScore -= seoTips.length * 12;
|
||||
if (keywordMatches.length === 0 && primaryKeywords.length && words > 4) seoScore -= 10;
|
||||
if (readability >= 60) seoScore += 5;
|
||||
seoScore = Math.max(0, Math.min(100, seoScore));
|
||||
|
||||
let geoScore = 100;
|
||||
geoScore -= geoTips.length * 14;
|
||||
if (hasEntities) geoScore += 5;
|
||||
if (hasNumbers) geoScore += 3;
|
||||
geoScore = Math.max(0, Math.min(100, geoScore));
|
||||
|
||||
return {
|
||||
length,
|
||||
words,
|
||||
sentences,
|
||||
avgWordsPerSentence: Math.round(avgWordsPerSentence * 10) / 10,
|
||||
readability,
|
||||
keywordMatches,
|
||||
missingKeywords,
|
||||
seoScore,
|
||||
geoScore,
|
||||
seoTips,
|
||||
geoTips
|
||||
};
|
||||
}
|
||||
|
||||
export function aggregateScore(analyses: CopyAnalysis[]): { seo: number; geo: number } {
|
||||
if (!analyses.length) return { seo: 0, geo: 0 };
|
||||
const seo = Math.round(analyses.reduce((a, x) => a + x.seoScore, 0) / analyses.length);
|
||||
const geo = Math.round(analyses.reduce((a, x) => a + x.geoScore, 0) / analyses.length);
|
||||
return { seo, geo };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
const ADMIN_FILE = 'admin.json';
|
||||
const SESSION_FILE = 'sessions.json';
|
||||
const SESSION_COOKIE = 'gds_admin';
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||
|
||||
type AdminRecord = {
|
||||
username: string;
|
||||
salt: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
token: string;
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
function hashPassword(password: string, salt: string): string {
|
||||
return scryptSync(password, salt, 64).toString('hex');
|
||||
}
|
||||
|
||||
export function seedDefaultAdminIfMissing(): { username: string; tempPassword?: string } {
|
||||
const existing = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (existing) return { username: existing.username };
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'changeme';
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const record: AdminRecord = { username, salt, hash: hashPassword(password, salt) };
|
||||
writeJson(ADMIN_FILE, record);
|
||||
return { username, tempPassword: password };
|
||||
}
|
||||
|
||||
export function verifyCredentials(username: string, password: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (record.username !== username) return false;
|
||||
const candidate = Buffer.from(hashPassword(password, record.salt), 'hex');
|
||||
const stored = Buffer.from(record.hash, 'hex');
|
||||
if (candidate.length !== stored.length) return false;
|
||||
return timingSafeEqual(candidate, stored);
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string): boolean {
|
||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
||||
if (!record) return false;
|
||||
if (!verifyCredentials(record.username, currentPassword)) return false;
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
writeJson(ADMIN_FILE, {
|
||||
username: record.username,
|
||||
salt,
|
||||
hash: hashPassword(newPassword, salt)
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadSessions(): SessionRecord[] {
|
||||
const now = Date.now();
|
||||
const all = readJson<SessionRecord[]>(SESSION_FILE, []);
|
||||
const live = all.filter((s) => s.expiresAt > now);
|
||||
if (live.length !== all.length) writeJson(SESSION_FILE, live);
|
||||
return live;
|
||||
}
|
||||
|
||||
export function createSession(username: string, cookies: Cookies): string {
|
||||
const sessions = loadSessions();
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const record: SessionRecord = { token, username, expiresAt: Date.now() + SESSION_TTL_MS };
|
||||
sessions.push(record);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
cookies.set(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_TTL_MS / 1000
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export function destroySession(cookies: Cookies): void {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (token) {
|
||||
const sessions = loadSessions().filter((s) => s.token !== token);
|
||||
writeJson(SESSION_FILE, sessions);
|
||||
}
|
||||
cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function getSessionUser(cookies: Cookies): { username: string } | null {
|
||||
const token = cookies.get(SESSION_COOKIE);
|
||||
if (!token) return null;
|
||||
const session = loadSessions().find((s) => s.token === token);
|
||||
if (!session) return null;
|
||||
return { username: session.username };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
buildDefaultContent,
|
||||
DEFAULT_IMAGES,
|
||||
DEFAULT_SLOTS,
|
||||
type SiteContent,
|
||||
type ImageSlot,
|
||||
type Testimonial
|
||||
} from '$lib/content/defaults';
|
||||
|
||||
const FILE = 'content.json';
|
||||
|
||||
export function getContent(): SiteContent {
|
||||
const seed = buildDefaultContent();
|
||||
const stored = readJson<Partial<SiteContent>>(FILE, seed);
|
||||
return {
|
||||
copy: { ...seed.copy, ...(stored.copy ?? {}) },
|
||||
images: { ...seed.images, ...(stored.images ?? {}) },
|
||||
testimonials: stored.testimonials ?? seed.testimonials,
|
||||
seo: { ...seed.seo, ...(stored.seo ?? {}) }
|
||||
};
|
||||
}
|
||||
|
||||
export function setCopy(id: string, value: string): void {
|
||||
const c = getContent();
|
||||
c.copy[id] = value;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setCopyBulk(updates: Record<string, string>): void {
|
||||
const c = getContent();
|
||||
for (const [k, v] of Object.entries(updates)) c.copy[k] = v;
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function setImage(id: string, patch: Partial<ImageSlot>): ImageSlot {
|
||||
const c = getContent();
|
||||
const current = c.images[id] ?? DEFAULT_IMAGES[id];
|
||||
if (!current) throw new Error(`Unknown image slot: ${id}`);
|
||||
const next: ImageSlot = {
|
||||
id,
|
||||
label: current.label,
|
||||
src: patch.src ?? current.src,
|
||||
alt: patch.alt ?? current.alt
|
||||
};
|
||||
c.images[id] = next;
|
||||
writeJson(FILE, c);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetImage(id: string): ImageSlot {
|
||||
const c = getContent();
|
||||
const fallback = DEFAULT_IMAGES[id];
|
||||
if (!fallback) throw new Error(`Unknown image slot: ${id}`);
|
||||
c.images[id] = { ...fallback };
|
||||
writeJson(FILE, c);
|
||||
return c.images[id];
|
||||
}
|
||||
|
||||
export function setSeo(patch: Partial<SiteContent['seo']>): void {
|
||||
const c = getContent();
|
||||
c.seo = { ...c.seo, ...patch };
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
|
||||
export function listSlots() {
|
||||
return DEFAULT_SLOTS;
|
||||
}
|
||||
|
||||
export function addTestimonial(input: Omit<Testimonial, 'id'>): Testimonial {
|
||||
const c = getContent();
|
||||
const entry: Testimonial = { id: `t_${randomUUID().slice(0, 8)}`, ...input };
|
||||
c.testimonials = [...c.testimonials, entry];
|
||||
writeJson(FILE, c);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function updateTestimonial(id: string, patch: Partial<Omit<Testimonial, 'id'>>): Testimonial | null {
|
||||
const c = getContent();
|
||||
let updated: Testimonial | null = null;
|
||||
c.testimonials = c.testimonials.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
updated = { ...t, ...patch };
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeJson(FILE, c);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteTestimonial(id: string): void {
|
||||
const c = getContent();
|
||||
c.testimonials = c.testimonials.filter((t) => t.id !== id);
|
||||
writeJson(FILE, c);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_SLOTS } from '$lib/content/defaults';
|
||||
import { analyzeCopy, aggregateScore } from '$lib/seo';
|
||||
import { getContent } from './content';
|
||||
|
||||
export function computeHealth(): { seo: number; geo: number } {
|
||||
const content = getContent();
|
||||
const analyses = DEFAULT_SLOTS
|
||||
.filter((s) => s.kind === 'multiline' || s.id.endsWith('_text'))
|
||||
.slice(0, 30)
|
||||
.map((s) => analyzeCopy(content.copy[s.id] ?? s.value, content.seo.primaryKeywords));
|
||||
return aggregateScore(analyses);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type NotificationType = 'submission' | 'seo_drop' | 'geo_drop';
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
};
|
||||
|
||||
const FILE = 'notifications.json';
|
||||
const MAX_KEEP = 200;
|
||||
|
||||
export function listNotifications(): Notification[] {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
return [...all].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function unreadCount(): number {
|
||||
return readJson<Notification[]>(FILE, []).filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
export function addNotification(input: Omit<Notification, 'id' | 'createdAt' | 'read'>): Notification {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
const entry: Notification = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
...input
|
||||
};
|
||||
all.push(entry);
|
||||
// Keep only the most recent MAX_KEEP
|
||||
const sorted = all.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, MAX_KEEP);
|
||||
writeJson(FILE, sorted);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function markRead(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => (n.id === id ? { ...n, read: true } : n)));
|
||||
}
|
||||
|
||||
export function markAllRead(): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.map((n) => ({ ...n, read: true })));
|
||||
}
|
||||
|
||||
export function deleteNotification(id: string): void {
|
||||
const all = readJson<Notification[]>(FILE, []);
|
||||
writeJson(FILE, all.filter((n) => n.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const DATA_DIR = join(process.cwd(), 'data');
|
||||
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
export function dataPath(name: string): string {
|
||||
return join(DATA_DIR, name);
|
||||
}
|
||||
|
||||
export function readJson<T>(name: string, fallback: T): T {
|
||||
const p = dataPath(name);
|
||||
if (!existsSync(p)) return fallback;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson<T>(name: string, value: T): void {
|
||||
const p = dataPath(name);
|
||||
const dir = dirname(p);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${p}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8');
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readJson, writeJson } from './storage';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type Submission = {
|
||||
id: string;
|
||||
name: string;
|
||||
dog: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
const FILE = 'submissions.json';
|
||||
|
||||
export function listSubmissions(): Submission[] {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
return [...items].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function addSubmission(input: Omit<Submission, 'id' | 'createdAt'>): Submission {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
const entry: Submission = {
|
||||
id: randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...input
|
||||
};
|
||||
items.push(entry);
|
||||
writeJson(FILE, items);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function deleteSubmission(id: string): void {
|
||||
const items = readJson<Submission[]>(FILE, []);
|
||||
writeJson(FILE, items.filter((s) => s.id !== id));
|
||||
}
|
||||
Reference in New Issue
Block a user