feat: service offer tags, catalog cleanup, offer editor filter
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string[]> = {
|
||||||
|
"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();
|
||||||
@@ -23,9 +23,8 @@ function formatPrice(raw: string): string {
|
|||||||
const COLUMN_HEADERS = [
|
const COLUMN_HEADERS = [
|
||||||
"Nome",
|
"Nome",
|
||||||
"Descrizione",
|
"Descrizione",
|
||||||
"Categoria",
|
|
||||||
"Fase",
|
"Fase",
|
||||||
"Tag",
|
"Offerta",
|
||||||
"Pacchetto",
|
"Pacchetto",
|
||||||
"Prezzo",
|
"Prezzo",
|
||||||
"Stato",
|
"Stato",
|
||||||
@@ -78,14 +77,6 @@ function ServiceRow({
|
|||||||
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 min-w-[140px]">
|
|
||||||
<OptionSelect
|
|
||||||
value={service.category}
|
|
||||||
options={options.categoria}
|
|
||||||
onChange={(v) => run(() => updateServiceField(service.id, "category", v ?? ""))}
|
|
||||||
onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 min-w-[140px]">
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
<OptionSelect
|
<OptionSelect
|
||||||
value={service.fase}
|
value={service.fase}
|
||||||
@@ -142,7 +133,6 @@ function ServiceRow({
|
|||||||
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [category, setCategory] = useState("");
|
|
||||||
const [fase, setFase] = useState("");
|
const [fase, setFase] = useState("");
|
||||||
const [price, setPrice] = useState("");
|
const [price, setPrice] = useState("");
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -158,13 +148,11 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
await quickAddService({
|
await quickAddService({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
category: category || undefined,
|
|
||||||
fase: fase || undefined,
|
fase: fase || undefined,
|
||||||
unit_price: price || undefined,
|
unit_price: price || undefined,
|
||||||
});
|
});
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setCategory("");
|
|
||||||
setFase("");
|
setFase("");
|
||||||
setPrice("");
|
setPrice("");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -205,16 +193,6 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
className={cellInput}
|
className={cellInput}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">
|
|
||||||
<Input
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
placeholder="Categoria..."
|
|
||||||
list="catalog-categoria-pool"
|
|
||||||
className={cellInput}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<Input
|
<Input
|
||||||
value={fase}
|
value={fase}
|
||||||
@@ -226,7 +204,7 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
|
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
|
||||||
Tag e pacchetto dopo la creazione
|
Offerta e pacchetto dopo la creazione
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -239,11 +217,6 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio ↵</td>
|
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio ↵</td>
|
||||||
<datalist id="catalog-categoria-pool">
|
|
||||||
{options.categoria.map((c) => (
|
|
||||||
<option key={c} value={c} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
<datalist id="catalog-fase-pool">
|
<datalist id="catalog-fase-pool">
|
||||||
{options.fase.map((f) => (
|
{options.fase.map((f) => (
|
||||||
<option key={f} value={f} />
|
<option key={f} value={f} />
|
||||||
|
|||||||
@@ -93,8 +93,21 @@ export function OfferEditorClient({
|
|||||||
|
|
||||||
const [initialArchived] = useState<boolean>(data.macro.is_archived);
|
const [initialArchived] = useState<boolean>(data.macro.is_archived);
|
||||||
const [savedDraft, setSavedDraft] = useState(false);
|
const [savedDraft, setSavedDraft] = useState(false);
|
||||||
|
const [offerFilter, setOfferFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
const filteredServices = data.availableServices;
|
const offerFilterOptions = useMemo(() => {
|
||||||
|
const all = new Set<string>();
|
||||||
|
for (const s of data.availableServices) s.offerTags.forEach((t) => all.add(t));
|
||||||
|
return Array.from(all).sort();
|
||||||
|
}, [data.availableServices]);
|
||||||
|
|
||||||
|
const filteredServices = useMemo(
|
||||||
|
() =>
|
||||||
|
offerFilter
|
||||||
|
? data.availableServices.filter((s) => s.offerTags.includes(offerFilter))
|
||||||
|
: data.availableServices,
|
||||||
|
[data.availableServices, offerFilter]
|
||||||
|
);
|
||||||
|
|
||||||
// Totals use the full catalog (not filtered) so changing category doesn't zero them.
|
// Totals use the full catalog (not filtered) so changing category doesn't zero them.
|
||||||
const serviceById = useMemo(
|
const serviceById = useMemo(
|
||||||
@@ -334,6 +347,35 @@ export function OfferEditorClient({
|
|||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-base font-semibold text-[#1a1a1a]">Servizi Inclusi</h3>
|
<h3 className="text-base font-semibold text-[#1a1a1a]">Servizi Inclusi</h3>
|
||||||
|
|
||||||
|
{/* Offer filter chips */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOfferFilter(null)}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full border transition-colors duration-150 ${
|
||||||
|
offerFilter === null
|
||||||
|
? "bg-[#1A463C] text-white border-[#1A463C]"
|
||||||
|
: "border-[#e5e7eb] text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tutti
|
||||||
|
</button>
|
||||||
|
{offerFilterOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOfferFilter(offerFilter === opt ? null : opt)}
|
||||||
|
className={`text-xs px-3 py-1 rounded-full border transition-colors duration-150 ${
|
||||||
|
offerFilter === opt
|
||||||
|
? "bg-[#1A463C] text-white border-[#1A463C]"
|
||||||
|
: "border-[#e5e7eb] text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export type OfferEditorData = {
|
|||||||
name: string;
|
name: string;
|
||||||
unit_price: string;
|
unit_price: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
|
offerTags: string[];
|
||||||
}>;
|
}>;
|
||||||
tipoTags: string[];
|
tipoTags: string[];
|
||||||
obiettivoTags: string[];
|
obiettivoTags: string[];
|
||||||
@@ -206,9 +207,8 @@ export async function getOfferEditorData(macroId: string): Promise<OfferEditorDa
|
|||||||
servicesTotal: totalsMap.get(tier.id) ?? "0",
|
servicesTotal: totalsMap.get(tier.id) ?? "0",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Full service catalog (unfiltered by category) — category filtering happens
|
// Full service catalog with offer membership tags (entity_type = "services").
|
||||||
// client-side so changing the category field doesn't wipe the service list.
|
const allServices = await db
|
||||||
const availableServices = await db
|
|
||||||
.select({
|
.select({
|
||||||
id: services.id,
|
id: services.id,
|
||||||
name: services.name,
|
name: services.name,
|
||||||
@@ -218,6 +218,27 @@ export async function getOfferEditorData(macroId: string): Promise<OfferEditorDa
|
|||||||
.from(services)
|
.from(services)
|
||||||
.where(eq(services.active, true));
|
.where(eq(services.active, true));
|
||||||
|
|
||||||
|
const serviceIds = allServices.map((s) => s.id);
|
||||||
|
const offerTagRows =
|
||||||
|
serviceIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({ entity_id: tags.entity_id, name: tags.name })
|
||||||
|
.from(tags)
|
||||||
|
.where(and(eq(tags.entity_type, "services"), inArray(tags.entity_id, serviceIds)))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const tagsByServiceId = new Map<string, string[]>();
|
||||||
|
for (const row of offerTagRows) {
|
||||||
|
const list = tagsByServiceId.get(row.entity_id) ?? [];
|
||||||
|
list.push(row.name);
|
||||||
|
tagsByServiceId.set(row.entity_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableServices = allServices.map((s) => ({
|
||||||
|
...s,
|
||||||
|
offerTags: tagsByServiceId.get(s.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
// Tipo/Obiettivo tags for this macro.
|
// Tipo/Obiettivo tags for this macro.
|
||||||
const tagRows = await db
|
const tagRows = await db
|
||||||
.select({ name: tags.name, type: tags.entity_type })
|
.select({ name: tags.name, type: tags.entity_type })
|
||||||
|
|||||||
Reference in New Issue
Block a user