feat: tier names + per-tier prices on offer cards, unified admin UI
- offer editor: per-tier name input (mirrors public_name/internal_name) - offer list cards: show 3-tier services total + manual public price - shared PageHeader component, full-width layout across all admin pages - UI-RULES.md design conventions doc Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PageHeader({ title, subtitle, action }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-[#71717a]">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,6 +162,12 @@ export function OfferEditorClient({
|
||||
);
|
||||
}
|
||||
|
||||
function updateTierName(tierIdx: number, value: string) {
|
||||
setTiers((prev) =>
|
||||
prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_name: value } : tier))
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null);
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
@@ -176,15 +182,18 @@ export function OfferEditorClient({
|
||||
tempo: macro.tempo,
|
||||
pain: macro.pain,
|
||||
metodo: macro.metodo,
|
||||
tiers: tiers.map((t) => ({
|
||||
id: t.id || undefined,
|
||||
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
|
||||
internal_name: t.internal_name || `Tier ${t.tier_letter}`,
|
||||
public_name: t.public_name || t.tier_letter || "Tier",
|
||||
duration_months: t.duration_months || 1,
|
||||
public_price: t.public_price ? Number(t.public_price) : null,
|
||||
assignedServiceIds: t.assignedServiceIds,
|
||||
})),
|
||||
tiers: tiers.map((t) => {
|
||||
const tierName = t.public_name?.trim();
|
||||
return {
|
||||
id: t.id || undefined,
|
||||
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
|
||||
internal_name: tierName || `Tier ${t.tier_letter}`,
|
||||
public_name: tierName || t.tier_letter || "Tier",
|
||||
duration_months: t.duration_months || 1,
|
||||
public_price: t.public_price ? Number(t.public_price) : null,
|
||||
assignedServiceIds: t.assignedServiceIds,
|
||||
};
|
||||
}),
|
||||
tipoTags,
|
||||
obiettivoTags,
|
||||
};
|
||||
@@ -474,6 +483,22 @@ export function OfferEditorClient({
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-[#e5e7eb]">
|
||||
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
|
||||
Nome Tier
|
||||
</td>
|
||||
{tiers.map((tier, tierIdx) => (
|
||||
<td key={tier.tier_letter} className="py-2 px-3 text-center">
|
||||
<input
|
||||
type="text"
|
||||
value={tier.public_name ?? ""}
|
||||
onChange={(e) => updateTierName(tierIdx, e.target.value)}
|
||||
placeholder="Nome…"
|
||||
className="w-20 text-center text-sm border border-[#e5e7eb] rounded px-1 py-1 focus-visible:ring-1 focus-visible:ring-primary focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-[#e5e7eb]">
|
||||
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
|
||||
Totale Servizi
|
||||
|
||||
@@ -4,10 +4,15 @@ import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createOfferMacro } from "@/app/admin/offers/actions";
|
||||
import type { OfferListCard } from "@/lib/offer-queries";
|
||||
import type { OfferListCard, OfferListTier } from "@/lib/offer-queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
function formatEuro(value: number): string {
|
||||
return `€${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export function OfferListClient({
|
||||
cards,
|
||||
@@ -77,11 +82,7 @@ export function OfferListClient({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
|
||||
{createCta}
|
||||
</div>
|
||||
<PageHeader title="Offerte" action={createCta} />
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
@@ -149,6 +150,27 @@ export function OfferListClient({
|
||||
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
|
||||
)}
|
||||
</div>
|
||||
{card.tiers.filter(
|
||||
(t) => Number(t.servicesTotal) > 0 || t.public_price !== null
|
||||
).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[#e5e7eb] space-y-1">
|
||||
{card.tiers
|
||||
.filter((t) => Number(t.servicesTotal) > 0 || t.public_price !== null)
|
||||
.map((t) => (
|
||||
<div key={t.tier_letter} className="flex items-baseline gap-2 text-xs">
|
||||
<span className="font-medium text-[#1a1a1a] w-16 shrink-0 truncate">
|
||||
{t.public_name || `Tier ${t.tier_letter}`}
|
||||
</span>
|
||||
<span className="tabular-nums text-[#71717a]">
|
||||
Servizi: {formatEuro(Number(t.servicesTotal))}
|
||||
</span>
|
||||
<span className="tabular-nums text-[#71717a]">
|
||||
Pubblico: {t.public_price !== null ? formatEuro(Number(t.public_price)) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user