feat: pricing admin — collapsible sections, card collapse, remove contact toggle

This commit is contained in:
2026-03-26 00:48:00 +03:00
parent 64e923460f
commit 95c33391e5

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField } from "../_components/FormField"; import { InputField, SelectField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
@@ -23,7 +25,6 @@ interface PricingData {
} }
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
// Strip "BYN" suffix for editing, add back on save
const raw = value.replace(/\s*BYN\s*$/i, "").trim(); const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return ( return (
@@ -48,157 +49,205 @@ function PriceField({ label, value, onChange }: { label: string; value: string;
); );
} }
function CollapsibleSection({
title,
count,
defaultOpen = true,
children,
}: {
title: string;
count?: number;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors"
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
{count !== undefined && (
<span className="text-xs text-neutral-500">{count}</span>
)}
</div>
<ChevronDown
size={16}
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">
{children}
</div>
</div>
</div>
</div>
);
}
export default function PricingEditorPage() { export default function PricingEditorPage() {
return ( return (
<SectionEditor<PricingData> sectionKey="pricing" title="Цены"> <SectionEditor<PricingData> sectionKey="pricing" title="Цены">
{(data, update) => ( {(data, update) => (
<> <div className="space-y-4">
<InputField <div className="grid gap-3 sm:grid-cols-2">
label="Заголовок секции" <InputField
value={data.title} label="Заголовок секции"
onChange={(v) => update({ ...data, title: v })} value={data.title}
/> onChange={(v) => update({ ...data, title: v })}
<InputField />
label="Подзаголовок" <InputField
value={data.subtitle} label="Подзаголовок"
onChange={(v) => update({ ...data, subtitle: v })} value={data.subtitle}
/> onChange={(v) => update({ ...data, subtitle: v })}
/>
</div>
<label className="inline-flex items-center gap-2 cursor-pointer select-none"> {/* Абонементы */}
<button <CollapsibleSection title="Абонементы" count={data.items.length}>
type="button" {(() => {
role="switch" const itemOptions = data.items
aria-checked={data.showContactHint !== false} .map((it, idx) => ({ value: String(idx), label: it.name }))
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })} .filter((o) => o.label.trim() !== "");
className={`relative h-5 w-9 rounded-full transition-colors ${ const noneOption = { value: "", label: "— Нет —" };
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600" const featuredIdx = data.items.findIndex((it) => it.featured);
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
data.showContactHint !== false ? "translate-x-4" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
</label>
{/* Featured selector */} return (
{(() => { <SelectField
const itemOptions = data.items label="Выделенный абонемент (безлимит)"
.map((it, idx) => ({ value: String(idx), label: it.name })) value={featuredIdx >= 0 ? String(featuredIdx) : ""}
.filter((o) => o.label.trim() !== ""); onChange={(v) => {
const noneOption = { value: "", label: "— Нет —" }; const items = data.items.map((it, idx) => ({
const featuredIdx = data.items.findIndex((it) => it.featured); ...it,
featured: v ? idx === Number(v) : false,
}));
update({ ...data, items });
}}
options={[noneOption, ...itemOptions]}
placeholder="Выберите..."
/>
);
})()}
return ( <ArrayEditor
<SelectField items={data.items}
label="Выделенный абонемент (безлимит)" onChange={(items) => update({ ...data, items })}
value={featuredIdx >= 0 ? String(featuredIdx) : ""} collapsible
onChange={(v) => { getItemTitle={(item) => item.name || "Без названия"}
const items = data.items.map((it, idx) => ({ getItemBadge={(item) =>
...it, item.popular ? (
featured: v ? idx === Number(v) : false, <span className="shrink-0 rounded-full bg-gold/20 px-2 py-0.5 text-[10px] font-medium text-gold">
})); Популярный
update({ ...data, items }); </span>
}} ) : null
options={[noneOption, ...itemOptions]} }
placeholder="Выберите..." renderItem={(item, _i, updateItem) => (
/> <div className="space-y-3">
); <div className="grid gap-3 sm:grid-cols-2">
})()} <InputField
label="Название"
<ArrayEditor value={item.name}
label="Абонементы" onChange={(v) => updateItem({ ...item, name: v })}
items={data.items} />
onChange={(items) => update({ ...data, items })} <PriceField
renderItem={(item, _i, updateItem) => ( label="Цена"
<div className="space-y-3"> value={item.price}
<div className="grid gap-3 sm:grid-cols-3"> onChange={(v) => updateItem({ ...item, price: v })}
<InputField />
label="Название" </div>
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<PriceField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
<InputField <InputField
label="Примечание" label="Примечание"
value={item.note || ""} value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v })} onChange={(v) => updateItem({ ...item, note: v || undefined })}
placeholder="Например: 8 занятий, срок 30 дней"
/>
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={!!item.popular}
onClick={() => updateItem({ ...item, popular: !item.popular })}
className={`relative h-5 w-9 rounded-full transition-colors ${
item.popular ? "bg-gold" : "bg-neutral-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
item.popular ? "translate-x-4" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-400">Популярный</span>
</label>
</div>
)}
createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить абонемент"
/>
</CollapsibleSection>
{/* Аренда */}
<CollapsibleSection title="Аренда" count={data.rentalItems.length}>
<InputField
label="Заголовок"
value={data.rentalTitle}
onChange={(v) => update({ ...data, rentalTitle: v })}
/>
<ArrayEditor
items={data.rentalItems}
onChange={(rentalItems) => update({ ...data, rentalItems })}
collapsible
getItemTitle={(item) => item.name || "Без названия"}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<PriceField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
</div>
<InputField
label="Примечание"
value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v || undefined })}
placeholder="Например: за 1 час"
/> />
</div> </div>
<label className="inline-flex items-center gap-2 cursor-pointer select-none"> )}
<button createItem={() => ({ name: "", price: "", note: "" })}
type="button" addLabel="Добавить вариант аренды"
role="switch" />
aria-checked={!!item.popular} </CollapsibleSection>
onClick={() => updateItem({ ...item, popular: !item.popular })}
className={`relative h-5 w-9 rounded-full transition-colors ${
item.popular ? "bg-gold" : "bg-neutral-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
item.popular ? "translate-x-4" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-400">Популярный</span>
</label>
</div>
)}
createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить абонемент"
/>
<InputField {/* Правила */}
label="Заголовок аренды" <CollapsibleSection title="Правила" count={data.rules.length} defaultOpen={false}>
value={data.rentalTitle} <ArrayEditor
onChange={(v) => update({ ...data, rentalTitle: v })} items={data.rules}
/> onChange={(rules) => update({ ...data, rules })}
renderItem={(rule, _i, updateItem) => (
<ArrayEditor <InputField label="Правило" value={rule} onChange={updateItem} />
label="Аренда" )}
items={data.rentalItems} createItem={() => ""}
onChange={(rentalItems) => update({ ...data, rentalItems })} addLabel="Добавить правило"
renderItem={(item, _i, updateItem) => ( />
<div className="grid gap-3 sm:grid-cols-3"> </CollapsibleSection>
<InputField </div>
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<PriceField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
<InputField
label="Примечание"
value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v })}
/>
</div>
)}
createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить вариант аренды"
/>
<ArrayEditor
label="Правила"
items={data.rules}
onChange={(rules) => update({ ...data, rules })}
renderItem={(rule, _i, updateItem) => (
<InputField label="Правило" value={rule} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить правило"
/>
</>
)} )}
</SectionEditor> </SectionEditor>
); );