feat: pagina Catalogo redesign Quiet Luxury — griglia servizi tokenizzata + footer conteggio
- ServiceTable: contenitore bg-card/shadow-card, header uppercase muted, righe hover:bg-muted/40, footer "Visualizzazione di N servizi" - CatalogSearch: usa il primitivo SearchInput - page: sottotitolo sezione - tokenizza OptionSelect ed EditableCell (accent-primary) — erano rimasti con hex hardcoded Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -239,6 +239,30 @@ Griglia responsive di card `bg-card rounded-xl border-border p-6 shadow-card`
|
|||||||
(Tier · Servizi · Pubblico), righe `font-mono` `divide-y divide-border`; solo i
|
(Tier · Servizi · Pubblico), righe `font-mono` `divide-y divide-border`; solo i
|
||||||
tier con `servicesTotal > 0` o `public_price` valorizzato; "Pubblico" assente → `—`.
|
tier con `servicesTotal > 0` o `public_price` valorizzato; "Pubblico" assente → `—`.
|
||||||
|
|
||||||
|
### Catalogo (griglia servizi) notes
|
||||||
|
|
||||||
|
`/admin/catalog` migrata a "Quiet Luxury" (replica `design-reference/pagina-catalogo`).
|
||||||
|
È una griglia editabile in stile foglio di calcolo: `ServiceTable` in un contenitore
|
||||||
|
`bg-card rounded-xl border-border shadow-card overflow-hidden`, header uppercase muted
|
||||||
|
(`text-[11px] font-semibold uppercase tracking-wider text-muted-foreground`, `bg-muted/50`),
|
||||||
|
righe `hover:bg-muted/40` / `divide` via `border-border`.
|
||||||
|
|
||||||
|
- **Celle inline**: nome/descrizione/prezzo/stato usano `EditableCell`; fase usa
|
||||||
|
`OptionSelect`; offerta/pacchetto usano `OptionMultiSelect`. I badge fase/offerta/
|
||||||
|
pacchetto sono colorati deterministicamente via `getOptionColor` (hash → palette a 7
|
||||||
|
colori) — nessun colore semantico fisso, coerente col sistema tag del resto dell'app.
|
||||||
|
- **Colonne**: riordinabili via drag (`GripVertical` on hover) e ordinabili sulle colonne
|
||||||
|
`sortable` (`ArrowUp/Down` in `text-primary`); ordine persistito in `localStorage`
|
||||||
|
(`catalog_col_order`).
|
||||||
|
- **Quick-add**: ultima riga `bg-muted/40` con input inline (`+ Aggiungi servizio`, prezzo,
|
||||||
|
fase datalist) — Invio ↵ crea il servizio. Servizi disattivati raggruppati sotto un header
|
||||||
|
`bg-muted/40` e resi `opacity-50`.
|
||||||
|
- **Search**: `SearchInput` primitivo (non più input+icona inline) su nome/categoria/fase/
|
||||||
|
tag/pacchetto. Footer tabella "Visualizzazione di N servizi" (singolare/plurale).
|
||||||
|
- **Nota primitivi**: in questa pass `OptionSelect` ed `EditableCell` (`accent-primary`)
|
||||||
|
sono stati tokenizzati — erano rimasti con hex hardcoded mentre `OptionMultiSelect` era
|
||||||
|
già token-based. Il fix vale per tutte le pagine che li usano (Offerte, Lead).
|
||||||
|
|
||||||
### Client Portal restyle notes
|
### Client Portal restyle notes
|
||||||
|
|
||||||
The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { SearchInput } from "@/components/ui/SearchInput";
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { ServiceTable, DEFAULT_COL_ORDER } from "@/components/admin/catalog/ServiceTable";
|
import { ServiceTable, DEFAULT_COL_ORDER } from "@/components/admin/catalog/ServiceTable";
|
||||||
import type { ColumnKey } from "@/components/admin/catalog/ServiceTable";
|
import type { ColumnKey } from "@/components/admin/catalog/ServiceTable";
|
||||||
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||||
@@ -124,16 +123,12 @@ export function CatalogSearch({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative max-w-sm">
|
<SearchInput
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
placeholder="Cerca per nome, categoria, fase, tag o pacchetto..."
|
||||||
<Input
|
value={query}
|
||||||
type="text"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Cerca per nome, categoria, fase, tag o pacchetto..."
|
containerClassName="max-w-sm"
|
||||||
value={query}
|
/>
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
className="pl-9 h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ServiceTable
|
<ServiceTable
|
||||||
activeServices={activeServices}
|
activeServices={activeServices}
|
||||||
inactiveServices={inactiveServices}
|
inactiveServices={inactiveServices}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default async function CatalogPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="Catalogo Servizi" />
|
<PageHeader
|
||||||
|
title="Catalogo Servizi"
|
||||||
|
subtitle="Configura e gestisci le singole voci di servizio offerte"
|
||||||
|
/>
|
||||||
<CatalogSearch services={services} options={options} />
|
<CatalogSearch services={services} options={options} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function ServiceRow({
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case "nome":
|
case "nome":
|
||||||
return (
|
return (
|
||||||
<td key={key} className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
<td key={key} className="py-2 px-3 font-medium text-foreground min-w-[160px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.name}
|
value={service.name}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -84,7 +84,7 @@ function ServiceRow({
|
|||||||
);
|
);
|
||||||
case "descrizione":
|
case "descrizione":
|
||||||
return (
|
return (
|
||||||
<td key={key} className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
<td key={key} className="py-2 px-3 text-muted-foreground min-w-[200px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.description ?? ""}
|
value={service.description ?? ""}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -155,7 +155,7 @@ function ServiceRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
|
className={`border-b border-border hover:bg-muted/40 transition-colors duration-150 ${
|
||||||
!service.active ? "opacity-50" : ""
|
!service.active ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -218,7 +218,7 @@ function QuickAddRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cellInput =
|
const cellInput =
|
||||||
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary";
|
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-primary";
|
||||||
|
|
||||||
function renderCell(key: ColumnKey) {
|
function renderCell(key: ColumnKey) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -279,18 +279,18 @@ function QuickAddRow({
|
|||||||
);
|
);
|
||||||
case "stato":
|
case "stato":
|
||||||
return (
|
return (
|
||||||
<td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]">
|
<td key={key} className="py-2 px-3 text-xs text-muted-foreground">
|
||||||
Invio ↵
|
Invio ↵
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]" />;
|
return <td key={key} className="py-2 px-3 text-xs text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
<tr className="border-b border-border bg-muted/40">
|
||||||
{colOrder.map((key) => renderCell(key))}
|
{colOrder.map((key) => renderCell(key))}
|
||||||
</tr>
|
</tr>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -331,15 +331,17 @@ export function ServiceTable({
|
|||||||
if (!COLUMN_DEFS[colKey].sortable) return null;
|
if (!COLUMN_DEFS[colKey].sortable) return null;
|
||||||
if (sortKey !== colKey) return <ArrowUpDown className="inline ml-1 h-3 w-3 opacity-40" />;
|
if (sortKey !== colKey) return <ArrowUpDown className="inline ml-1 h-3 w-3 opacity-40" />;
|
||||||
return sortDir === "asc"
|
return sortDir === "asc"
|
||||||
? <ArrowUp className="inline ml-1 h-3 w-3 text-[#1A463C]" />
|
? <ArrowUp className="inline ml-1 h-3 w-3 text-primary" />
|
||||||
: <ArrowDown className="inline ml-1 h-3 w-3 text-[#1A463C]" />;
|
: <ArrowDown className="inline ml-1 h-3 w-3 text-primary" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalCount = activeServices.length + inactiveServices.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
<thead className="bg-muted/50 border-b border-border sticky top-0 z-[1]">
|
||||||
<tr>
|
<tr>
|
||||||
{colOrder.map((key) => {
|
{colOrder.map((key) => {
|
||||||
const def = COLUMN_DEFS[key];
|
const def = COLUMN_DEFS[key];
|
||||||
@@ -351,8 +353,8 @@ export function ServiceTable({
|
|||||||
onDragOver={(e) => onColDragOver(e, key)}
|
onDragOver={(e) => onColDragOver(e, key)}
|
||||||
onDrop={() => onColDrop(key)}
|
onDrop={() => onColDrop(key)}
|
||||||
onClick={() => def.sortable && onSortClick(key)}
|
onClick={() => def.sortable && onSortClick(key)}
|
||||||
className={`text-left py-2 px-3 font-semibold text-[#71717a] select-none whitespace-nowrap group ${
|
className={`text-left py-3 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none whitespace-nowrap group ${
|
||||||
def.sortable ? "cursor-pointer hover:text-[#1a1a1a]" : "cursor-grab"
|
def.sortable ? "cursor-pointer hover:text-foreground" : "cursor-grab"
|
||||||
}`}
|
}`}
|
||||||
style={{ minWidth: def.minWidth }}
|
style={{ minWidth: def.minWidth }}
|
||||||
>
|
>
|
||||||
@@ -373,10 +375,10 @@ export function ServiceTable({
|
|||||||
<QuickAddRow options={options} colOrder={colOrder} />
|
<QuickAddRow options={options} colOrder={colOrder} />
|
||||||
{inactiveServices.length > 0 && (
|
{inactiveServices.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<tr className="bg-[#fafafa]">
|
<tr className="bg-muted/40">
|
||||||
<td
|
<td
|
||||||
colSpan={colOrder.length}
|
colSpan={colOrder.length}
|
||||||
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
|
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
Servizi disattivati
|
Servizi disattivati
|
||||||
</td>
|
</td>
|
||||||
@@ -389,6 +391,11 @@ export function ServiceTable({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-border px-6 py-4 text-xs text-muted-foreground">
|
||||||
|
{totalCount === 1
|
||||||
|
? "Visualizzazione di 1 servizio"
|
||||||
|
: `Visualizzazione di ${totalCount} servizi`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function EditableCell({
|
|||||||
// WR-01: onChange already saves + closes — a blur-commit here would
|
// WR-01: onChange already saves + closes — a blur-commit here would
|
||||||
// fire a second (possibly stale) updateServiceField for the same field.
|
// fire a second (possibly stale) updateServiceField for the same field.
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") cancel(); }}
|
onKeyDown={(e) => { if (e.key === "Escape") cancel(); }}
|
||||||
className="h-4 w-4 cursor-pointer accent-[#1A463C]"
|
className="h-4 w-4 cursor-pointer accent-primary"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function OptionSelect({
|
|||||||
onClick={() => !disabled && setIsOpen((v) => !v)}
|
onClick={() => !disabled && setIsOpen((v) => !v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px]",
|
"flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px]",
|
||||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]"
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value ? (
|
{value ? (
|
||||||
@@ -91,12 +91,12 @@ export function OptionSelect({
|
|||||||
{value}
|
{value}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[#71717a]">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[200px] max-h-[280px] overflow-y-auto">
|
<div className="absolute top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-1.5 z-20 min-w-[200px] max-h-[280px] overflow-y-auto">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -125,7 +125,7 @@ export function OptionSelect({
|
|||||||
onChange(null);
|
onChange(null);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left text-xs text-[#71717a]"
|
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-muted text-left text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" /> Rimuovi
|
<X className="h-3.5 w-3.5" /> Rimuovi
|
||||||
</button>
|
</button>
|
||||||
@@ -154,7 +154,7 @@ export function OptionSelect({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
key={opt}
|
key={opt}
|
||||||
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group"
|
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-muted group"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -162,7 +162,7 @@ export function OptionSelect({
|
|||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
>
|
>
|
||||||
<span className="w-3.5 flex-shrink-0">
|
<span className="w-3.5 flex-shrink-0">
|
||||||
{value === opt && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
|
{value === opt && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -181,7 +181,7 @@ export function OptionSelect({
|
|||||||
setRenameValue(opt);
|
setRenameValue(opt);
|
||||||
setRenaming(opt);
|
setRenaming(opt);
|
||||||
}}
|
}}
|
||||||
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5"
|
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5"
|
||||||
aria-label={`Rinomina ${opt}`}
|
aria-label={`Rinomina ${opt}`}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
@@ -195,9 +195,9 @@ export function OptionSelect({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={createFromQuery}
|
onClick={createFromQuery}
|
||||||
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm"
|
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-muted text-left text-sm"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 text-[#1A463C]" />
|
<Plus className="h-3.5 w-3.5 text-primary" />
|
||||||
Crea <span className="font-medium">«{query.trim()}»</span>
|
Crea <span className="font-medium">«{query.trim()}»</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user