feat(admin): Produkt anlegen gegliedert wie Produkt bearbeiten

Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 20:41:59 +02:00
1 parent 76ebe7d042
commit c125e91941
6 files changed
+281 -235

No files matched your search

+48 -114
View File
@@ -4,12 +4,21 @@ import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field"; import { AdminImageListField } from "@/components/admin-image-list-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { updateProductAction } from "../../actions"; import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) { export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const te = t.adminProductEditPage;
const { id } = await props.params; const { id } = await props.params;
const rawProduct = await prisma.product.findUnique({ const rawProduct = await prisma.product.findUnique({
where: { id }, where: { id },
@@ -24,173 +33,98 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">
<BackLink href="/admin/produkte" label={t.adminProductEditPage.backToProducts} /> <BackLink href="/admin/produkte" label={te.backToProducts} />
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{te.title}</h1>
<p className="text-sm text-ink/65 mb-8"> <p className="text-sm text-ink/65 mb-8">
{t.common.categories[product.category]} {t.common.categories[product.category]}
{product.harvest && product.batch {product.harvest && product.batch
? t.adminProductEditPage.harvestBatchSuffix( ? te.harvestBatchSuffix(product.harvest.type, product.harvest.year, product.batch.code)
product.harvest.type,
product.harvest.year,
product.batch.code
)
: null} : null}
</p> </p>
<form action={updateProductAction} className="space-y-8 bg-background border border-ink/10 p-6 sm:p-8"> <AdminFormPanel action={updateProductAction} actions={<Button type="submit">{te.save}</Button>}>
<input type="hidden" name="id" value={product.id} /> <input type="hidden" name="id" value={product.id} />
<fieldset className="space-y-4 border-b border-ink/10 pb-8"> <AdminFieldset legend={te.sectionText}>
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionText}</legend> <AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required /> <AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required />
<AdminField name="tasteTags" label={te.tasteTagsLabel} defaultValue={product.tasteTags.join(", ")} />
<label className="block text-sm"> <AdminImageListField name="images" label={te.imagesLabel} initialValues={product.images} />
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span> </AdminFieldset>
<textarea
name="description"
defaultValue={product.description}
required
rows={3}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<Field
name="tasteTags"
label={t.adminProductEditPage.tasteTagsLabel}
defaultValue={product.tasteTags.join(", ")}
/>
<AdminImageListField
name="images"
label={t.adminProductEditPage.imagesLabel}
initialValues={product.images}
/>
</fieldset>
{product.category === "HONIG" ? ( {product.category === "HONIG" ? (
<fieldset className="space-y-4 border-b border-ink/10 pb-8"> <AdminFieldset legend={te.sectionTaste}>
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionTaste}</legend> <AdminField name="consistency" label={te.consistencyLabel} defaultValue={product.consistency} />
<Field <div className="grid items-end gap-4 sm:grid-cols-3">
name="consistency" <AdminField
label={t.adminProductEditPage.consistencyLabel}
defaultValue={product.consistency}
/>
<div className="grid grid-cols-3 gap-4">
<Field
name="intensityMild" name="intensityMild"
label={t.adminProductEditPage.intensityMildLabel} label={te.intensityMildLabel}
type="number" type="number"
defaultValue={String(product.intensityMild)} defaultValue={String(product.intensityMild)}
/> />
<Field <AdminField
name="intensityFloral" name="intensityFloral"
label={t.adminProductEditPage.intensityFloralLabel} label={te.intensityFloralLabel}
type="number" type="number"
defaultValue={String(product.intensityFloral)} defaultValue={String(product.intensityFloral)}
/> />
<Field <AdminField
name="intensityKraeftig" name="intensityKraeftig"
label={t.adminProductEditPage.intensityKraeftigLabel} label={te.intensityKraeftigLabel}
type="number" type="number"
defaultValue={String(product.intensityKraeftig)} defaultValue={String(product.intensityKraeftig)}
/> />
</div> </div>
</fieldset> </AdminFieldset>
) : null} ) : null}
<fieldset className="space-y-4 border-b border-ink/10 pb-8"> <AdminFieldset legend={te.sectionPriceStock}>
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionPriceStock}</legend> <div className="grid grid-cols-2 items-end gap-4">
<div className="grid grid-cols-2 gap-4"> <AdminField
<Field
name="weightGrams" name="weightGrams"
label={t.adminProductEditPage.weightLabel} label={te.weightLabel}
type="number" type="number"
defaultValue={String(product.weightGrams)} defaultValue={String(product.weightGrams)}
required required
/> />
<Field <AdminField
name="quantity" name="quantity"
label={t.adminProductEditPage.stockLabel} label={te.stockLabel}
type="number" type="number"
defaultValue={String(product.inventory?.quantity ?? 0)} defaultValue={String(product.inventory?.quantity ?? 0)}
required required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field <AdminField
name="priceCents" name="priceCents"
label={t.adminProductEditPage.priceLabel} label={te.priceLabel}
type="number" type="number"
defaultValue={String(product.priceCents)} defaultValue={String(product.priceCents)}
required required
/> />
<Field <AdminField
name="pricePerKgCents" name="pricePerKgCents"
label={t.adminProductEditPage.pricePerKgLabel} label={te.pricePerKgLabel}
type="number" type="number"
defaultValue={String(product.pricePerKgCents)} defaultValue={String(product.pricePerKgCents)}
required required
/> />
</div> </div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen. {/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */} steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field <AdminField
name="vatRatePercent" name="vatRatePercent"
label={t.adminProductEditPage.vatRateLabel} label={te.vatRateLabel}
type="number" type="number"
defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)} defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)}
/> />
<p className="text-xs text-ink/65">{t.adminProductEditPage.vatRateHint}</p> <AdminHint>{te.vatRateHint}</AdminHint>
</fieldset> </AdminFieldset>
<fieldset> <AdminFieldset legend={te.sectionVisibility} last>
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionVisibility}</legend> <AdminSwitch name="isActive" label={te.visibleInShop} defaultChecked={product.isActive} />
{/* Schalter statt kleiner Checkbox — dasselbe Formularfeld </AdminFieldset>
(name="isActive"), nur als Umschalter dargestellt. */} </AdminFormPanel>
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
<input type="checkbox" name="isActive" defaultChecked={product.isActive} className="peer sr-only" />
<span
aria-hidden="true"
className="relative h-6 w-11 flex-shrink-0 rounded-full bg-ink/20 transition-colors duration-300 peer-checked:bg-forest peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-honey-dark after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-cream after:shadow after:transition-transform after:duration-300 after:content-[''] peer-checked:after:translate-x-5"
/>
{t.adminProductEditPage.visibleInShop}
</label>
</fieldset>
{/* Speicherleiste bleibt beim Scrollen am unteren Rand — der Knopf
ist bei langen Formularen immer erreichbar. */}
<div className="sticky bottom-0 -mx-6 -mb-6 flex items-center justify-end gap-4 border-t border-ink/10 bg-background/95 px-6 py-4 backdrop-blur sm:-mx-8 sm:-mb-8 sm:px-8">
<Button type="submit">{t.adminProductEditPage.save}</Button>
</div>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+70 -121
View File
@@ -1,170 +1,119 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field"; import { AdminImageListField } from "@/components/admin-image-list-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSelect,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { createProductAction } from "../../actions"; import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() { export default async function AdminProductNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tn = t.adminProductNewPage;
const [harvests, batches] = await Promise.all([ const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }), prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
]); ]);
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} /> <BackLink href="/admin/produkte" label={tn.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{tn.title}</h1>
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <AdminFormPanel
<Field name="name" label={t.adminProductNewPage.nameLabel} required /> action={createProductAction}
<Field name="slug" label={t.adminProductNewPage.slugLabel} /> className="group/product"
actions={<Button type="submit">{tn.create}</Button>}
>
<AdminFieldset legend={tn.sectionText}>
<AdminField name="name" label={tn.nameLabel} required />
<AdminField name="slug" label={tn.slugLabel} />
<AdminTextarea name="description" label={tn.descriptionLabel} required />
<AdminField name="tasteTags" label={tn.tasteTagsLabel} placeholder={tn.tasteTagsPlaceholder} />
<AdminImageListField name="images" label={tn.imagesLabel} initialValues={[]} />
</AdminFieldset>
<label className="block text-sm"> <AdminFieldset legend={tn.sectionCategory}>
<span className="text-ink/70">{t.adminProductNewPage.categoryLabel}</span> <AdminSelect name="category" label={tn.categoryLabel} defaultValue="HONIG">
<select name="category" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="HONIG">{t.common.categories.HONIG}</option> <option value="HONIG">{t.common.categories.HONIG}</option>
<option value="KERZEN">{t.common.categories.KERZEN}</option> <option value="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option> <option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
</select> </AdminSelect>
</label> {/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has()
ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar
<p className="text-xs text-ink/65">{t.adminProductNewPage.categoryHint}</p> und der Server ignoriert die Auswahl ohnehin (products.ts). */}
<div className="grid grid-cols-2 gap-4"> <div className="grid gap-4 sm:grid-cols-2 group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden">
<label className="block text-sm"> <AdminSelect name="harvestId" label={tn.harvestLabel} defaultValue="">
<span className="text-ink/70">{t.adminProductNewPage.harvestLabel}</span> <option value="">{tn.noneOption}</option>
<select name="harvestId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
{harvests.map((h) => ( {harvests.map((h) => (
<option key={h.id} value={h.id}> <option key={h.id} value={h.id}>
{h.type} {h.year} {h.type} {h.year}
</option> </option>
))} ))}
</select> </AdminSelect>
</label> <AdminSelect name="batchId" label={tn.batchLabel} defaultValue="">
<label className="block text-sm"> <option value="">{tn.noneOption}</option>
<span className="text-ink/70">{t.adminProductNewPage.batchLabel}</span>
<select name="batchId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
{batches.map((b) => ( {batches.map((b) => (
<option key={b.id} value={b.id}> <option key={b.id} value={b.id}>
{t.adminProductNewPage.batchOption(b.code, b.harvest.type, b.harvest.year)} {tn.batchOption(b.code, b.harvest.type, b.harvest.year)}
</option> </option>
))} ))}
</select> </AdminSelect>
</label>
</div> </div>
<AdminHint>{tn.categoryHint}</AdminHint>
</AdminFieldset>
<label className="block text-sm"> <AdminFieldset
<span className="text-ink/70">{t.adminProductNewPage.descriptionLabel}</span> legend={tn.sectionTaste}
<textarea name="description" required rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden"
</label> >
<AdminField name="consistency" label={tn.consistencyLabel} placeholder={tn.consistencyPlaceholder} />
<Field <div className="grid items-end gap-4 sm:grid-cols-3">
name="tasteTags" <AdminField name="intensityMild" label={tn.intensityMildLabel} type="number" defaultValue="3" />
label={t.adminProductNewPage.tasteTagsLabel} <AdminField name="intensityFloral" label={tn.intensityFloralLabel} type="number" defaultValue="3" />
placeholder={t.adminProductNewPage.tasteTagsPlaceholder} <AdminField name="intensityKraeftig" label={tn.intensityKraeftigLabel} type="number" defaultValue="3" />
/>
<AdminImageListField
name="images"
label={t.adminProductNewPage.imagesLabel}
initialValues={[]}
/>
<Field
name="consistency"
label={t.adminProductNewPage.consistencyLabel}
placeholder={t.adminProductNewPage.consistencyPlaceholder}
/>
<div className="grid grid-cols-3 gap-4">
<Field
name="intensityMild"
label={t.adminProductNewPage.intensityMildLabel}
type="number"
defaultValue="3"
/>
<Field
name="intensityFloral"
label={t.adminProductNewPage.intensityFloralLabel}
type="number"
defaultValue="3"
/>
<Field
name="intensityKraeftig"
label={t.adminProductNewPage.intensityKraeftigLabel}
type="number"
defaultValue="3"
/>
</div> </div>
</AdminFieldset>
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tn.sectionPriceStock}>
<Field name="weightGrams" label={t.adminProductNewPage.weightLabel} type="number" required defaultValue="500" /> <div className="grid grid-cols-2 items-end gap-4">
<Field name="quantity" label={t.adminProductNewPage.stockLabel} type="number" required /> <AdminField name="weightGrams" label={tn.weightLabel} type="number" required defaultValue="500" />
<AdminField name="quantity" label={tn.stockLabel} type="number" required />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field <AdminField
name="priceCents" name="priceCents"
label={t.adminProductNewPage.priceLabel} label={tn.priceLabel}
type="number" type="number"
required required
placeholder={t.adminProductNewPage.pricePlaceholder} placeholder={tn.pricePlaceholder}
/> />
<Field <AdminField
name="pricePerKgCents" name="pricePerKgCents"
label={t.adminProductNewPage.pricePerKgLabel} label={tn.pricePerKgLabel}
type="number" type="number"
required required
placeholder={t.adminProductNewPage.pricePerKgPlaceholder} placeholder={tn.pricePerKgPlaceholder}
/> />
</div> </div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen. {/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */} steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field name="vatRatePercent" label={t.adminProductNewPage.vatRateLabel} type="number" /> <AdminField name="vatRatePercent" label={tn.vatRateLabel} type="number" />
<p className="text-xs text-ink/65">{t.adminProductNewPage.vatRateHint}</p> <AdminHint>{tn.vatRateHint}</AdminHint>
</AdminFieldset>
<label className="flex items-center gap-2 text-sm text-ink/70"> <AdminFieldset legend={tn.sectionVisibility} last>
<input type="checkbox" name="isActive" defaultChecked /> <AdminSwitch name="isActive" label={tn.visibleInShop} defaultChecked />
{t.adminProductNewPage.visibleInShop} </AdminFieldset>
</label> </AdminFormPanel>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductNewPage.create}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+148
View File
@@ -0,0 +1,148 @@
import type { ReactNode } from "react";
// Gemeinsame Bausteine der Admin-Formulare (Produkt anlegen/bearbeiten,
// spaeter Gutscheine, Versand, Benutzer). Reine Server-Komponenten — sie
// rendern nur Markup, damit jedes Formular gleich gegliedert aussieht.
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2";
export function AdminField({
name,
label,
type = "text",
defaultValue,
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required={required}
className={inputClass}
/>
</label>
);
}
export function AdminTextarea({
name,
label,
defaultValue,
required = false,
rows = 3,
}: {
name: string;
label: string;
defaultValue?: string;
required?: boolean;
rows?: number;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<textarea name={name} defaultValue={defaultValue} required={required} rows={rows} className={inputClass} />
</label>
);
}
export function AdminSelect({
name,
label,
defaultValue,
children,
}: {
name: string;
label: string;
defaultValue?: string;
children: ReactNode;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select name={name} defaultValue={defaultValue} className={inputClass}>
{children}
</select>
</label>
);
}
export function AdminFieldset({
legend,
last = false,
className = "",
children,
}: {
legend: string;
/** Letzter Abschnitt vor der Speicherleiste: ohne Trennlinie. */
last?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<fieldset className={`space-y-4 ${last ? "" : "border-b border-ink/10 pb-8"} ${className}`}>
<legend className="mb-4 font-serif text-xl text-forest">{legend}</legend>
{children}
</fieldset>
);
}
export function AdminHint({ children }: { children: ReactNode }) {
return <p className="text-xs text-ink/65">{children}</p>;
}
/** Schalter statt kleiner Checkbox — dasselbe Formularfeld, nur als Umschalter dargestellt. */
export function AdminSwitch({
name,
label,
defaultChecked,
}: {
name: string;
label: string;
defaultChecked?: boolean;
}) {
return (
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="peer sr-only" />
<span
aria-hidden="true"
className="relative h-6 w-11 flex-shrink-0 rounded-full bg-ink/20 transition-colors duration-300 peer-checked:bg-forest peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-honey-dark after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-cream after:shadow after:transition-transform after:duration-300 after:content-[''] peer-checked:after:translate-x-5"
/>
{label}
</label>
);
}
/** Formularrahmen samt Speicherleiste, die beim Scrollen am unteren Rand
bleibt — der Knopf ist bei langen Formularen immer erreichbar. */
export function AdminFormPanel({
action,
actions,
className = "",
children,
}: {
action: (formData: FormData) => void | Promise<void>;
actions: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<form action={action} className={`space-y-8 border border-ink/10 bg-background p-6 sm:p-8 ${className}`}>
{children}
<div className="sticky bottom-0 -mx-6 -mb-6 flex items-center justify-end gap-4 border-t border-ink/10 bg-background/95 px-6 py-4 backdrop-blur sm:-mx-8 sm:-mb-8 sm:px-8">
{actions}
</div>
</form>
);
}
+5
View File
@@ -1100,6 +1100,11 @@ export const de: Dictionary = {
pricePerKgPlaceholder: "1980", pricePerKgPlaceholder: "1980",
imagesLabel: "Produktbilder", imagesLabel: "Produktbilder",
visibleInShop: "Im Shop sichtbar", visibleInShop: "Im Shop sichtbar",
sectionText: "Text und Bilder",
sectionCategory: "Kategorie und Herkunft",
sectionTaste: "Geschmack",
sectionPriceStock: "Preis und Bestand",
sectionVisibility: "Sichtbarkeit",
create: "Anlegen", create: "Anlegen",
}, },
adminReturnsPage: { adminReturnsPage: {
+5
View File
@@ -1086,6 +1086,11 @@ export const en: Dictionary = {
pricePerKgPlaceholder: "1980", pricePerKgPlaceholder: "1980",
imagesLabel: "Product images", imagesLabel: "Product images",
visibleInShop: "Visible in shop", visibleInShop: "Visible in shop",
sectionText: "Text and images",
sectionCategory: "Category and origin",
sectionTaste: "Taste",
sectionPriceStock: "Price and stock",
sectionVisibility: "Visibility",
create: "Create", create: "Create",
}, },
adminReturnsPage: { adminReturnsPage: {
+5
View File
@@ -767,6 +767,11 @@ export interface Dictionary {
pricePerKgPlaceholder: string; pricePerKgPlaceholder: string;
imagesLabel: string; imagesLabel: string;
visibleInShop: string; visibleInShop: string;
sectionText: string;
sectionCategory: string;
sectionTaste: string;
sectionPriceStock: string;
sectionVisibility: string;
create: string; create: string;
}; };
adminReturnsPage: { adminReturnsPage: {