From 43238341c15fd767a5c2e648cfc3d958a0aba8cd Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 18 Jun 2026 21:58:34 +0200 Subject: [PATCH] feat: service offer tags, catalog cleanup, offer editor filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import 58 offer membership tags for all 55 services (entity_type="services") via scripts/import-service-offer-tags.ts (already run on prod) - ServiceTable: remove Categoria column, rename Tag → Offerta; QuickAddRow no longer has a category field (offer tags set post-creation) - offer-queries: getOfferEditorData fetches offerTags per service (join on tags WHERE entity_type="services") and exposes them in OfferEditorData - OfferEditorClient: filter chips above "Servizi Inclusi" — Tutti / Entry Offer / Signature Offer / Retainer Offer, derived from actual tag data Co-Authored-By: Claude Sonnet 4.6 --- scripts/import-service-offer-tags.ts | 118 ++++++++++++++++++ src/components/admin/catalog/ServiceTable.tsx | 31 +---- .../admin/offers/OfferEditorClient.tsx | 44 ++++++- src/lib/offer-queries.ts | 27 +++- 4 files changed, 187 insertions(+), 33 deletions(-) create mode 100644 scripts/import-service-offer-tags.ts diff --git a/scripts/import-service-offer-tags.ts b/scripts/import-service-offer-tags.ts new file mode 100644 index 0000000..5c87d0a --- /dev/null +++ b/scripts/import-service-offer-tags.ts @@ -0,0 +1,118 @@ +// Import offer membership tags for all 55 services. +// Stores in tags table with entity_type = "services" (same as the "Offerta" column). +// Idempotent: uses onConflictDoNothing. +// Run: same SSH tunnel pattern as other migration scripts. +import postgres from "postgres"; +import { customAlphabet } from "nanoid"; + +const nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 12); + +// name → offer types from the CSV "Offerta" column (multi-value pipe-separated) +const OFFER_MEMBERSHIPS: Record = { + "Raccolta e Mappatura Materiali": ["Signature Offer", "Entry Offer"], + "Audit iniziale (UX/UI, struttura, conversione)": ["Signature Offer", "Entry Offer"], + "Workshop (1° fase)": ["Signature Offer", "Entry Offer"], + "Analisi Competitor": ["Signature Offer", "Entry Offer"], + "Documento di restituzione (problemi + lista ottimizzazioni)": ["Entry Offer"], + "Redesign visivo dell'above-the-fold / hero (il 'prima → dopo')": ["Entry Offer"], + "Call di restituzione": ["Entry Offer"], + "Call di presentazione Prima/Dopo": ["Entry Offer"], + "Roadmap / istruzioni operative + mini kit": ["Entry Offer"], + "UX Research": ["Signature Offer"], + "Customer Journey": ["Signature Offer"], + "Architettura (sitemap)": ["Signature Offer"], + "Brand Identity - Visual identity": ["Signature Offer"], + "Brand Identity - Voice identity": ["Signature Offer"], + "Direzione Creative (moodboard)": ["Signature Offer"], + "Workshop (2° fase)": ["Signature Offer"], + "Settaggio CMS (Wordpress Webflow ecc)": ["Signature Offer"], + "UX - UI": ["Signature Offer"], + "Wireframe (Low-Mid Fidelity)": ["Signature Offer"], + "Homepage (Figma + Dev)": ["Signature Offer"], + "Revisione #1": ["Signature Offer"], + "- Chi siamo": ["Signature Offer"], + "- Contatti": ["Signature Offer"], + "- Blog (Archivio)": ["Signature Offer"], + "- - Blog post (Template Singolo)": ["Signature Offer"], + "- Case Study (Archivio)": ["Signature Offer"], + "- - Case Study (Template Singolo)": ["Signature Offer"], + "- Landing Page (Metodo o Differenziante)": ["Signature Offer"], + "- - - Thank You Page": ["Signature Offer"], + "- - - 404": ["Signature Offer"], + "Responsive (inclusa nelle pagine?)": ["Signature Offer"], + "UX writing (Revisione testi)": ["Signature Offer"], + "Seo Setup (basic on-page)": ["Signature Offer"], + "Seo Avanzato (+ keyword + ricerca + blog post dentro retainer)": ["Signature Offer"], + "Revisione #2 finale": ["Signature Offer"], + "Web Core Vitals (Optimization)": ["Signature Offer"], + "QA - Test Cross Browser e responsive": ["Signature Offer"], + "Setting GA4 - Tag Manager - Hotjar/Clarify": ["Signature Offer"], + "Raccolta Feedback Post Lancio": ["Signature Offer"], + "Go-live / messa online & accessi": ["Signature Offer"], + "Audit uscita": ["Signature Offer"], + "Real User Testing": ["Signature Offer"], + "Follow Up Handover": ["Signature Offer"], + "Video Tutorial per micro modifiche in autonomia": ["Signature Offer"], + "Revisioni Extra illimitate (sicuri illimitati???)": ["Signature Offer"], + "Brand Kit (youtube - linkedin - insta)": ["Signature Offer"], + "Mantenimento tecnico (sito sempre online, bello, funzionante)": ["Retainer Offer"], + "Monitoraggio dati (GA4 / Hotjar-Clarity)": ["Retainer Offer"], + "Report mensile": ["Retainer Offer"], + "CRO - analisi UX (hotmap, punti di drop)": ["Retainer Offer"], + "CRO - implementazione miglioramenti": ["Retainer Offer"], + "Call di Mentorship/Consulenza/allineamento/revisione": ["Retainer Offer"], + "Art direction su direzione da prendere (consulente strategico interno disponibile 24/7)": ["Retainer Offer"], + "Extra landing (prezzo singolo per pompare prezzo)": ["Retainer Offer"], + "SEO avanzato (Blog post)": ["Retainer Offer"], +}; + +const TAG_ENTITY = "services"; + +async function run() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { console.error("DATABASE_URL required"); process.exit(1); } + + const client = postgres(databaseUrl); + try { + const allServices = await client`SELECT id, name FROM services`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serviceByName = new Map((allServices as any[]).map((s) => [s.name as string, s.id as string])); + + let inserted = 0; + let skipped = 0; + let missing = 0; + + for (const [name, offers] of Object.entries(OFFER_MEMBERSHIPS)) { + const serviceId = serviceByName.get(name); + if (!serviceId) { + console.warn(` ⚠ service not found: ${name}`); + missing++; + continue; + } + for (const offer of offers) { + const id = nanoid(); + const result = await client` + INSERT INTO tags (id, entity_type, entity_id, name) + VALUES (${id}, ${TAG_ENTITY}, ${serviceId}, ${offer}) + ON CONFLICT (entity_type, entity_id, name) DO NOTHING + `; + if (result.count === 0) { + skipped++; + } else { + console.log(` ✓ ${name} → ${offer}`); + inserted++; + } + } + } + + console.log(`\n✓ Done — ${inserted} tags inserted, ${skipped} skipped, ${missing} services not found`); + process.exit(0); + } catch (err) { + console.error("Error:", err instanceof Error ? err.message : err); + process.exit(1); + } finally { + await client.end(); + } +} + +run(); diff --git a/src/components/admin/catalog/ServiceTable.tsx b/src/components/admin/catalog/ServiceTable.tsx index 43681a8..ce78456 100644 --- a/src/components/admin/catalog/ServiceTable.tsx +++ b/src/components/admin/catalog/ServiceTable.tsx @@ -23,9 +23,8 @@ function formatPrice(raw: string): string { const COLUMN_HEADERS = [ "Nome", "Descrizione", - "Categoria", "Fase", - "Tag", + "Offerta", "Pacchetto", "Prezzo", "Stato", @@ -78,14 +77,6 @@ function ServiceRow({ onSave={(v) => run(() => updateServiceField(service.id, "description", v))} /> - - run(() => updateServiceField(service.id, "category", v ?? ""))} - onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))} - /> - - - setCategory(e.target.value)} - onKeyDown={onKeyDown} - placeholder="Categoria..." - list="catalog-categoria-pool" - className={cellInput} - /> - - Tag e pacchetto dopo la creazione + Offerta e pacchetto dopo la creazione Invio ↵ - - {options.categoria.map((c) => ( - {options.fase.map((f) => (