feat: pricing admin — collapsible sections, card collapse, remove contact toggle
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user