feat(ui): Polish-Durchgang Admin und Mobil, Erstattung nur mit Rueckfrage

Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.

Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.

Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.

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 21:24:55 +02:00
1 parent b7a75dddca
commit 11eccec01b
16 files changed
+221 -155

No files matched your search

+6 -5
View File
@@ -15,6 +15,7 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl"; import { isDhlConfigured } from "@/lib/dhl";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui"; import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { Button } from "@/components/ui";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -69,9 +70,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
</option> </option>
))} ))}
</select> </select>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveStatus} {t.adminOrderDetailPage.saveStatus}
</button> </Button>
</form> </form>
</div> </div>
@@ -192,9 +193,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm" className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
/> />
</label> </label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveTracking} {t.adminOrderDetailPage.saveTracking}
</button> </Button>
</form> </form>
</div> </div>
) : null} ) : null}
@@ -281,7 +282,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<span>{order.invoice.invoiceNumber}</span> <span>{order.invoice.invoiceNumber}</span>
<a <a
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`} href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
className="text-forest underline text-sm" className="link-draw text-sm text-forest"
> >
{t.adminOrderDetailPage.downloadPdf} {t.adminOrderDetailPage.downloadPdf}
</a> </a>
+41 -40
View File
@@ -11,6 +11,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive"; import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner"; import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import { AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -49,7 +50,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1> <AdminPageHeader title={t.adminOrdersPage.title} />
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung {/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */} aus wie ein folgenloser Klick. */}
@@ -67,18 +68,16 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
/> />
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null} {isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminSearchForm
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null} query={query}
<label className="text-sm"> label={t.adminOrdersPage.searchLabel}
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span> placeholder={t.adminOrdersPage.searchPlaceholder}
<input submitLabel={t.adminOrdersPage.filterButton}
type="text" resetHref={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
name="q" resetLabel={t.adminOrdersPage.resetFilter}
defaultValue={query} hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
placeholder={t.adminOrdersPage.searchPlaceholder} isFiltered={Boolean(query || statusFilter)}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" >
/>
</label>
<label className="text-sm"> <label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span> <span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
<select <select
@@ -94,18 +93,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
))} ))}
</select> </select>
</label> </label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> </AdminSearchForm>
{t.adminOrdersPage.filterButton}
</button>
{query || statusFilter ? (
<Link
href={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
className="text-sm text-ink/65 underline"
>
{t.adminOrdersPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -125,12 +113,19 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
{orders.map((order) => ( {orders.map((order) => (
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest whitespace-nowrap"> <td className="p-4 text-forest whitespace-nowrap">
<Link href={`/admin/bestellungen/${order.id}`} className="underline"> <Link href={`/admin/bestellungen/${order.id}`} className="link-draw">
{order.orderNumber} {order.orderNumber}
</Link> </Link>
{order.giftMessage ? ( {order.giftMessage ? (
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}> <span
🎁 className="ml-1.5 inline-flex align-[-2px] text-honey-dark"
title={t.adminOrdersPage.giftOrderTitle}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
<rect x="3.5" y="8.5" width="17" height="4" />
<path d="M5 12.5v8h14v-8M12 8.5v12M12 8.5c-1.5-3.5-5-4-5-1.5 0 1.5 2.5 1.5 5 1.5zm0 0c1.5-3.5 5-4 5-1.5 0 1.5-2.5 1.5-5 1.5z" />
</svg>
<span className="sr-only">{t.adminOrdersPage.giftOrderTitle}</span>
</span> </span>
) : null} ) : null}
</td> </td>
@@ -139,9 +134,9 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<br /> <br />
<span className="text-ink/65 text-xs">{order.email}</span> <span className="text-ink/65 text-xs">{order.email}</span>
</td> </td>
<td className="p-4">{formatDate(order.createdAt)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(order.createdAt)}</td>
<td className="p-4">{order.items.length}</td> <td className="p-4 tabular-nums">{order.items.length}</td>
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
<td className="p-4"> <td className="p-4">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} /> <OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2"> <form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
@@ -149,7 +144,8 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<select <select
name="status" name="status"
defaultValue={order.status} defaultValue={order.status}
className="border border-ink/20 bg-background px-2 py-1 text-xs" aria-label={t.adminOrdersPage.statusLabel}
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
> >
{Object.entries(statusLabels).map(([value, label]) => ( {Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}> <option key={value} value={value}>
@@ -157,7 +153,10 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
</option> </option>
))} ))}
</select> </select>
<button type="submit" className="text-xs text-forest underline"> <button
type="submit"
className="border border-forest px-3 py-1.5 text-xs text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{t.adminOrdersPage.save} {t.adminOrdersPage.save}
</button> </button>
</form> </form>
@@ -184,13 +183,15 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
</tbody> </tbody>
</table> </table>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query || statusFilter message={
? t.adminOrdersPage.noResultsFiltered query || statusFilter
: isArchive ? t.adminOrdersPage.noResultsFiltered
? t.adminArchive.emptyArchive : isArchive
: t.adminOrdersPage.noOrdersYet} ? t.adminArchive.emptyArchive
</p> : t.adminOrdersPage.noOrdersYet
}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+3 -2
View File
@@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type"; import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
import { Button } from "@/components/ui";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = { const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1, text: 1,
@@ -190,9 +191,9 @@ function GroupForm({
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} /> <FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))} ))}
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminContentPage.save} {t.adminContentPage.save}
</button> </Button>
</form> </form>
); );
} }
+50 -40
View File
@@ -1,4 +1,3 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
@@ -10,6 +9,15 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive"; import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner"; import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import {
AdminActionButton,
AdminActionLink,
AdminActionNote,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) { export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -34,15 +42,12 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
prisma.product.count({ where: { archivedAt: { not: null } } }), prisma.product.count({ where: { archivedAt: { not: null } } }),
]); ]);
const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminProductsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminProductsPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminProductsPage.title} action={newProduct} />
<h1 className="text-2xl text-forest">{t.adminProductsPage.title}</h1>
<Link href="/admin/produkte/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminProductsPage.newProduct}
</Link>
</div>
<ApiCoreSyncResult searchParams={searchParams} /> <ApiCoreSyncResult searchParams={searchParams} />
@@ -63,44 +68,51 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
<tr> <tr>
<th className="p-4">{t.adminProductsPage.colProduct}</th> <th className="px-3 py-4">{t.adminProductsPage.colProduct}</th>
<th className="p-4">{t.adminProductsPage.colCategory}</th> <th className="px-3 py-4">{t.adminProductsPage.colCategory}</th>
<th className="p-4">{t.adminProductsPage.colHarvest}</th> <th className="px-3 py-4">{t.adminProductsPage.colHarvest}</th>
<th className="p-4">{t.adminProductsPage.colBatch}</th> <th className="px-3 py-4">{t.adminProductsPage.colBatch}</th>
<th className="p-4">{t.adminProductsPage.colWeight}</th> <th className="px-3 py-4">{t.adminProductsPage.colWeight}</th>
<th className="p-4">{t.adminProductsPage.colPrice}</th> <th className="px-3 py-4">{t.adminProductsPage.colPrice}</th>
<th className="p-4">{t.adminProductsPage.colStock}</th> <th className="px-3 py-4">{t.adminProductsPage.colStock}</th>
<th className="p-4">{t.adminProductsPage.colStatus}</th> <th className="px-3 py-4">{t.adminProductsPage.colStatus}</th>
<th className="p-4" /> <th className="px-3 py-4" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{products.map((product) => ( {products.map((product) => (
<tr key={product.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={product.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{product.name}</td> <td className="px-3 py-4">
<td className="p-4">{t.common.categories[product.category]}</td> <AdminActionLink href={`/admin/produkte/${product.id}`}>{product.name}</AdminActionLink>
<td className="p-4"> </td>
<td className="px-3 py-4">{t.common.categories[product.category]}</td>
<td className="px-3 py-4">
{product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"} {product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"}
</td> </td>
<td className="p-4">{product.batch?.code ?? "—"}</td> <td className="px-3 py-4 whitespace-nowrap">{product.batch?.code ?? "—"}</td>
<td className="p-4">{product.weightGrams} g</td> <td className="px-3 py-4 whitespace-nowrap tabular-nums">{product.weightGrams} g</td>
<td className="p-4">{formatPrice(product.priceCents, product.currency)}</td> <td className="px-3 py-4 whitespace-nowrap tabular-nums">{formatPrice(product.priceCents, product.currency)}</td>
<td className="p-4">{product.inventory?.quantity ?? 0}</td> <td className={`px-3 py-4 tabular-nums ${(product.inventory?.quantity ?? 0) === 0 ? "text-ink/55" : ""}`}>
<td className="p-4"> {product.inventory?.quantity ?? 0}
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive} </td>
<td className="px-3 py-4">
{product.isActive ? (
<AdminBadge tone="positive">{t.adminProductsPage.statusActive}</AdminBadge>
) : (
<AdminBadge tone="neutral">{t.adminProductsPage.statusInactive}</AdminBadge>
)}
{/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten {/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten
(admin/actions/products.ts). Aus ApiCore importierte (admin/actions/products.ts). Aus ApiCore importierte
Entwuerfe sehen sonst wie ein normales inaktives Produkt Entwuerfe sehen sonst wie ein normales inaktives Produkt
aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */} aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */}
{product.priceCents <= 0 ? ( {product.priceCents <= 0 ? (
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span> <span className="mt-1.5 block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
) : null} ) : null}
</td> </td>
<td className="p-4 whitespace-nowrap"> <td className="px-3 py-4">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4"> {/* Bearbeiten ueber den Produktnamen links — hier nur die
{t.adminProductsPage.edit} selteneren Aktionen, damit die Tabelle nicht seitlich scrollt. */}
</Link> <AdminRowActions>
<span className="mr-4">
<AdminArchiveToggle <AdminArchiveToggle
kind="products" kind="products"
id={product.id} id={product.id}
@@ -113,7 +125,6 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
manualHint: t.adminArchive.manualHint, manualHint: t.adminArchive.manualHint,
}} }}
/> />
</span>
{/* Loeschen nur, solange nie bestellt: OrderItem verweist mit {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit
onDelete: Restrict auf das Produkt, damit gestellte onDelete: Restrict auf das Produkt, damit gestellte
Rechnungen ihren Bezug behalten (siehe Rechnungen ihren Bezug behalten (siehe
@@ -123,25 +134,24 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<ConfirmForm <ConfirmForm
action={deleteProductAction} action={deleteProductAction}
confirmMessage={t.adminProductsPage.deleteConfirm(product.name)} confirmMessage={t.adminProductsPage.deleteConfirm(product.name)}
className="inline"
> >
<input type="hidden" name="id" value={product.id} /> <input type="hidden" name="id" value={product.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminProductsPage.delete}</AdminActionButton>
{t.adminProductsPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : ( ) : (
<span className="text-xs text-ink/50">{t.adminProductsPage.notDeletable}</span> <AdminActionNote>{t.adminProductsPage.notDeletable}</AdminActionNote>
)} )}
</AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{products.length === 0 ? ( {products.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet} message={isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet}
</p> action={isArchive ? undefined : newProduct}
/>
) : null} ) : null}
</div> </div>
+69 -50
View File
@@ -1,4 +1,3 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDate, formatPrice } from "@/lib/format"; import { formatDate, formatPrice } from "@/lib/format";
@@ -9,6 +8,23 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive"; import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner"; import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import { ConfirmForm } from "@/components/confirm-form";
import { Button } from "@/components/ui";
import {
AdminActionButton,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminSearchForm,
type AdminBadgeTone,
} from "@/components/admin-list";
const STATUS_TONES: Record<string, AdminBadgeTone> = {
ANGEFRAGT: "pending",
ANGENOMMEN: "pending",
ABGELEHNT: "neutral",
ERSTATTET: "positive",
};
export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) { export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -53,7 +69,7 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1> <AdminPageHeader title={t.adminReturnsPage.title} />
<AdminArchiveTabs <AdminArchiveTabs
basePath="/admin/retouren" basePath="/admin/retouren"
@@ -68,30 +84,15 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
/> />
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReturns}</p> : null} {isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReturns}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminSearchForm
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null} query={query}
<label className="text-sm"> label={t.adminReturnsPage.searchLabel}
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span> placeholder={t.adminReturnsPage.searchPlaceholder}
<input submitLabel={t.adminReturnsPage.filterButton}
type="text" resetHref={isArchive ? "/admin/retouren?ansicht=archiv" : "/admin/retouren"}
name="q" resetLabel={t.adminReturnsPage.resetFilter}
defaultValue={query} hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
placeholder={t.adminReturnsPage.searchPlaceholder} />
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
/>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminReturnsPage.filterButton}
</button>
{query ? (
<Link
href={isArchive ? "/admin/retouren?ansicht=archiv" : "/admin/retouren"}
className="text-sm text-ink/65 underline"
>
{t.adminReturnsPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4"> <div className="space-y-4">
{returnRequests.map((request) => ( {returnRequests.map((request) => (
@@ -102,11 +103,16 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
<p className="text-sm text-ink/70"> <p className="text-sm text-ink/70">
{request.order.firstName} {request.order.lastName} · {request.order.email} {request.order.firstName} {request.order.lastName} · {request.order.email}
</p> </p>
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p> <p className="text-xs text-ink/65 mt-1 tabular-nums">{formatDate(request.createdAt)}</p>
</div> </div>
<div className="text-right"> <div className="flex flex-col items-end gap-2">
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p> <AdminBadge
<div className="mt-2"> tone={STATUS_TONES[request.status] ?? "neutral"}
pulse={request.status === "ANGEFRAGT"}
>
{statusLabels[request.status] ?? request.status}
</AdminBadge>
<div>
<AdminArchiveToggle <AdminArchiveToggle
kind="returns" kind="returns"
id={request.id} id={request.id}
@@ -130,46 +136,59 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
</li> </li>
))} ))}
</ul> </ul>
<p className="mt-2 text-forest">{formatPrice(request.order.totalCents, request.order.currency)}</p> <p className="mt-2 text-forest tabular-nums">{formatPrice(request.order.totalCents, request.order.currency)}</p>
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p> <p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p>
{request.status === "ANGEFRAGT" ? ( {request.status === "ANGEFRAGT" ? (
<div className="mt-4 flex gap-4"> <div className="mt-4 flex flex-wrap items-center gap-6 text-sm">
<form action={updateReturnRequestStatusAction}> <form action={updateReturnRequestStatusAction}>
<input type="hidden" name="id" value={request.id} /> <input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ANGENOMMEN" /> <input type="hidden" name="status" value="ANGENOMMEN" />
<button type="submit" className="text-xs text-forest underline"> <Button type="submit" variant="secondary" className="px-5 py-2.5">
{t.adminReturnsPage.accept} {t.adminReturnsPage.accept}
</button> </Button>
</form> </form>
<form action={updateReturnRequestStatusAction}> <ConfirmForm
action={updateReturnRequestStatusAction}
confirmMessage={t.adminReturnsPage.rejectConfirm(request.order.orderNumber)}
>
<input type="hidden" name="id" value={request.id} /> <input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ABGELEHNT" /> <input type="hidden" name="status" value="ABGELEHNT" />
<button type="submit" className="text-xs text-red-700 underline"> <AdminActionButton tone="danger">{t.adminReturnsPage.reject}</AdminActionButton>
{t.adminReturnsPage.reject} </ConfirmForm>
</button>
</form>
</div> </div>
) : null} ) : null}
{request.status === "ANGENOMMEN" ? ( {request.status === "ANGENOMMEN" ? (
<form action={refundReturnRequestAction} className="mt-4"> // Erstattung bewegt echtes Geld und laesst sich nicht zuruecknehmen —
// deshalb mit Rueckfrage samt Betrag statt Ein-Klick-Link.
<ConfirmForm
action={refundReturnRequestAction}
confirmMessage={t.adminReturnsPage.refundConfirm(
request.order.orderNumber,
formatPrice(request.order.totalCents, request.order.currency)
)}
className="mt-4"
>
<input type="hidden" name="id" value={request.id} /> <input type="hidden" name="id" value={request.id} />
<button type="submit" className="text-xs text-forest underline"> <Button type="submit" className="px-5 py-2.5">
{t.adminReturnsPage.refundNow} {t.adminReturnsPage.refundNow}
</button> </Button>
</form> </ConfirmForm>
) : null} ) : null}
</div> </div>
))} ))}
{returnRequests.length === 0 ? ( {returnRequests.length === 0 ? (
<p className="text-ink/65"> <AdminEmptyState
{query bordered
? t.adminReturnsPage.noResultsFiltered message={
: isArchive query
? t.adminArchive.emptyArchive ? t.adminReturnsPage.noResultsFiltered
: t.adminReturnsPage.noRequestsYet} : isArchive
</p> ? t.adminArchive.emptyArchive
: t.adminReturnsPage.noRequestsYet
}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+19 -5
View File
@@ -292,7 +292,7 @@ export default async function ProductDetailPage(
</p> </p>
<Link <Link
href="/versand-zahlung" href="/versand-zahlung"
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest" className="mt-1 inline-block py-1 text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
> >
{t.productPage.shippingHint( {t.productPage.shippingHint(
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale) baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
@@ -367,7 +367,21 @@ export default async function ProductDetailPage(
<form action={toggleWishlistAction} className="mt-3"> <form action={toggleWishlistAction} className="mt-3">
<input type="hidden" name="productId" value={product.id} /> <input type="hidden" name="productId" value={product.id} />
<input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} /> <input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} />
<button type="submit" className="text-sm text-ink/70 hover:text-forest underline"> <button
type="submit"
className="inline-flex items-center gap-2 py-1.5 text-sm text-ink/70 underline hover:text-forest"
>
<svg
viewBox="0 0 20 20"
className={`h-4 w-4 ${isWishlisted ? "text-honey" : "text-honey-dark"}`}
fill={isWishlisted ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10 1.5l2.47 5.27 5.66.6-4.24 3.9 1.15 5.6L10 14.9l-5.04 2.97 1.15-5.6-4.24-3.9 5.66-.6z" />
</svg>
{isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist} {isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist}
</button> </button>
</form> </form>
@@ -413,11 +427,11 @@ export default async function ProductDetailPage(
{isHoney ? ( {isHoney ? (
<div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm"> <div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span className="text-ink/65">{t.productPage.learnMoreLabel}</span> <span className="text-ink/65">{t.productPage.learnMoreLabel}</span>
<Link href="/wissen/warum-kristallisiert-honig" className="text-forest underline"> <Link href="/wissen/warum-kristallisiert-honig" className="inline-block py-0.5 text-forest underline">
{t.wissenArticles["warum-kristallisiert-honig"].title} {t.wissenArticles["warum-kristallisiert-honig"].title}
</Link> </Link>
{harvestWissenSlug ? ( {harvestWissenSlug ? (
<Link href={`/wissen/${harvestWissenSlug}`} className="text-forest underline"> <Link href={`/wissen/${harvestWissenSlug}`} className="inline-block py-0.5 text-forest underline">
{t.wissenArticles[harvestWissenSlug].title} {t.wissenArticles[harvestWissenSlug].title}
</Link> </Link>
) : null} ) : null}
@@ -427,7 +441,7 @@ export default async function ProductDetailPage(
{isHoney && product.batch ? ( {isHoney && product.batch ? (
<Link <Link
href={`/charge/${product.batch.code}`} href={`/charge/${product.batch.code}`}
className="mt-3 inline-block text-sm text-forest underline" className="mt-2.5 inline-block py-0.5 text-sm text-forest underline"
> >
{t.productPage.viewBatchOrigin} {t.productPage.viewBatchOrigin}
</Link> </Link>
+2 -2
View File
@@ -28,13 +28,13 @@ export default async function KontaktPage() {
<div className="mt-12 grid gap-6 md:grid-cols-5"> <div className="mt-12 grid gap-6 md:grid-cols-5">
{/* Kontaktkarte auf Wabenraster: die E-Mail ist der Hauptweg und {/* Kontaktkarte auf Wabenraster: die E-Mail ist der Hauptweg und
steht deshalb gross in Serifenschrift. */} steht deshalb gross in Serifenschrift. */}
<ScrollReveal delay={0.1} className="md:col-span-3"> <ScrollReveal delay={0.1} className="min-w-0 md:col-span-3">
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10"> <div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" /> <HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
<div className="relative"> <div className="relative">
<a <a
href={`mailto:${company.email}`} href={`mailto:${company.email}`}
className="group inline-flex items-center gap-3 font-serif text-3xl md:text-4xl text-cream" className="group inline-flex items-center gap-3 font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
> >
<span className="link-draw pb-1">{company.email}</span> <span className="link-draw pb-1">{company.email}</span>
<svg <svg
+1 -1
View File
@@ -209,7 +209,7 @@ export default async function KontoPage() {
</form> </form>
<h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2> <h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2>
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4"> <div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div> <div>
<h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3> <h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p> <p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
+1 -1
View File
@@ -72,7 +72,7 @@ export function AdminArchiveToggle({
{archivedManually ? ` · ${labels.manualHint}` : null} {archivedManually ? ` · ${labels.manualHint}` : null}
</span> </span>
) : null} ) : null}
<button type="submit" className="text-xs text-ink/65 underline hover:text-forest"> <button type="submit" className="link-draw cursor-pointer text-ink/70 hover:text-forest">
{archivedAt ? labels.restore : labels.archive} {archivedAt ? labels.restore : labels.archive}
</button> </button>
</form> </form>
+14 -1
View File
@@ -39,6 +39,9 @@ export function AdminSearchForm({
submitLabel, submitLabel,
resetHref, resetHref,
resetLabel, resetLabel,
hiddenFields = {},
children,
isFiltered,
}: { }: {
query: string; query: string;
label: string; label: string;
@@ -46,9 +49,18 @@ export function AdminSearchForm({
submitLabel: string; submitLabel: string;
resetHref: string; resetHref: string;
resetLabel: string; resetLabel: string;
/** Mitzusendende Parameter, z.B. die Archiv-Ansicht. */
hiddenFields?: Record<string, string>;
/** Weitere Filterfelder (z.B. Status-Auswahl) zwischen Suche und Knopf. */
children?: ReactNode;
/** Ob "Filter zuruecksetzen" erscheint; Standard: sobald gesucht wird. */
isFiltered?: boolean;
}) { }) {
return ( return (
<form method="GET" role="search" className="mb-6 flex flex-wrap items-end gap-3"> <form method="GET" role="search" className="mb-6 flex flex-wrap items-end gap-3">
{Object.entries(hiddenFields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
<label className="text-sm"> <label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{label}</span> <span className="mb-1 block text-xs text-ink/65">{label}</span>
<input <input
@@ -59,13 +71,14 @@ export function AdminSearchForm({
className="w-64 max-w-full border border-ink/20 bg-background px-3 py-2 text-sm" className="w-64 max-w-full border border-ink/20 bg-background px-3 py-2 text-sm"
/> />
</label> </label>
{children}
<button <button
type="submit" type="submit"
className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream" className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
> >
{submitLabel} {submitLabel}
</button> </button>
{query ? ( {(isFiltered ?? query !== "") ? (
<Link href={resetHref} className="link-draw py-2 text-sm text-ink/65"> <Link href={resetHref} className="link-draw py-2 text-sm text-ink/65">
{resetLabel} {resetLabel}
</Link> </Link>
+3 -2
View File
@@ -1,6 +1,7 @@
import { syncApiCoreAction } from "@/app/admin/actions"; import { syncApiCoreAction } from "@/app/admin/actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { Button } from "@/components/ui";
/** /**
* Der eine Sync-Knopf und die Anzeige seines Ergebnisses. * Der eine Sync-Knopf und die Anzeige seines Ergebnisses.
@@ -95,9 +96,9 @@ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
<p className="text-sm text-ink/65 mb-4">{t.intro}</p> <p className="text-sm text-ink/65 mb-4">{t.intro}</p>
<form action={syncApiCoreAction}> <form action={syncApiCoreAction}>
<input type="hidden" name="returnTo" value={returnTo} /> <input type="hidden" name="returnTo" value={returnTo} />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.button} {t.button}
</button> </Button>
</form> </form>
</div> </div>
); );
+1 -1
View File
@@ -9,7 +9,7 @@ export function BackLink({ href, label }: { href: string; label: string }) {
return ( return (
<Link <Link
href={href} href={href}
className="inline-flex items-center gap-1.5 text-sm text-ink/70 hover:text-forest mb-6" className="-mt-1.5 mb-4.5 inline-flex items-center gap-1.5 py-1.5 text-sm text-ink/70 hover:text-forest"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M15 6l-6 6 6 6" /> <path d="M15 6l-6 6 6 6" />
+1 -1
View File
@@ -331,7 +331,7 @@ export function ProductCard({
) : null} ) : null}
<Link <Link
href={`/honig/${product.slug}`} href={`/honig/${product.slug}`}
className="group/more mt-4 inline-flex items-center gap-2 self-start text-sm tracking-wide-caps text-forest" className="group/more mt-3 inline-flex items-center gap-2 self-start py-1 text-sm tracking-wide-caps text-forest"
> >
<span className="link-draw pb-0.5"> <span className="link-draw pb-0.5">
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct} {product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
+4 -2
View File
@@ -1120,6 +1120,8 @@ export const de: Dictionary = {
create: "Anlegen", create: "Anlegen",
}, },
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Bestellung ${orderNumber} jetzt mit ${amount} per Stripe erstatten? Das lässt sich nicht rückgängig machen.`,
rejectConfirm: (orderNumber) => `Retoure zu Bestellung ${orderNumber} wirklich ablehnen?`,
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
title: "Retouren", title: "Retouren",
searchLabel: "Suche", searchLabel: "Suche",
@@ -1588,8 +1590,8 @@ export const de: Dictionary = {
notifyMeLabel: "Benachrichtige mich, wenn es wieder verfügbar ist", notifyMeLabel: "Benachrichtige mich, wenn es wieder verfügbar ist",
emailPlaceholder: "E-Mail-Adresse", emailPlaceholder: "E-Mail-Adresse",
notifySubmit: "Anmelden", notifySubmit: "Anmelden",
removeFromWishlist: "★ Von der Wunschliste entfernen", removeFromWishlist: "Von der Wunschliste entfernen",
addToWishlist: "☆ Zur Wunschliste hinzufügen", addToWishlist: "Zur Wunschliste hinzufügen",
loginToSave: "um dieses Produkt zu merken.", loginToSave: "um dieses Produkt zu merken.",
loginLinkLabel: "Melde dich an", loginLinkLabel: "Melde dich an",
checklist: { checklist: {
+4 -2
View File
@@ -1106,6 +1106,8 @@ export const en: Dictionary = {
create: "Create", create: "Create",
}, },
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Refund ${amount} for order ${orderNumber} via Stripe now? This cannot be undone.`,
rejectConfirm: (orderNumber) => `Really reject the return for order ${orderNumber}?`,
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
title: "Returns", title: "Returns",
searchLabel: "Search", searchLabel: "Search",
@@ -1574,8 +1576,8 @@ export const en: Dictionary = {
notifyMeLabel: "Notify me when it's back in stock", notifyMeLabel: "Notify me when it's back in stock",
emailPlaceholder: "Email address", emailPlaceholder: "Email address",
notifySubmit: "Sign up", notifySubmit: "Sign up",
removeFromWishlist: "★ Remove from wishlist", removeFromWishlist: "Remove from wishlist",
addToWishlist: "☆ Add to wishlist", addToWishlist: "Add to wishlist",
loginToSave: "to save this product.", loginToSave: "to save this product.",
loginLinkLabel: "Sign in", loginLinkLabel: "Sign in",
checklist: { checklist: {
+2
View File
@@ -787,6 +787,8 @@ export interface Dictionary {
create: string; create: string;
}; };
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber: string, amount: string) => string;
rejectConfirm: (orderNumber: string) => string;
backToDashboard: string; backToDashboard: string;
title: string; title: string;
searchLabel: string; searchLabel: string;