From 11eccec01b5a603d27612925fc16e079008ecfff Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 25 Sep 2026 21:24:55 +0200 Subject: [PATCH] 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) --- src/app/admin/bestellungen/[id]/page.tsx | 11 ++- src/app/admin/bestellungen/page.tsx | 81 +++++++-------- src/app/admin/inhalte/page.tsx | 5 +- src/app/admin/produkte/page.tsx | 90 +++++++++-------- src/app/admin/retouren/page.tsx | 119 +++++++++++++---------- src/app/honig/[slug]/page.tsx | 24 ++++- src/app/kontakt/page.tsx | 4 +- src/app/konto/page.tsx | 2 +- src/components/admin-archive.tsx | 2 +- src/components/admin-list.tsx | 15 ++- src/components/apicore-sync-panel.tsx | 5 +- src/components/back-link.tsx | 2 +- src/components/product-card.tsx | 2 +- src/i18n/dictionaries/de.ts | 6 +- src/i18n/dictionaries/en.ts | 6 +- src/i18n/dictionary-type.ts | 2 + 16 files changed, 221 insertions(+), 155 deletions(-) diff --git a/src/app/admin/bestellungen/[id]/page.tsx b/src/app/admin/bestellungen/[id]/page.tsx index d344d3c..a3cb0ad 100644 --- a/src/app/admin/bestellungen/[id]/page.tsx +++ b/src/app/admin/bestellungen/[id]/page.tsx @@ -15,6 +15,7 @@ import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import { isDhlConfigured } from "@/lib/dhl"; import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui"; +import { Button } from "@/components/ui"; export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { const t = getDictionary(await getLocale()); @@ -69,9 +70,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best ))} - + @@ -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" /> - + ) : null} @@ -281,7 +282,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best {order.invoice.invoiceNumber} {t.adminOrderDetailPage.downloadPdf} diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx index 002ab4b..c113f7c 100644 --- a/src/app/admin/bestellungen/page.tsx +++ b/src/app/admin/bestellungen/page.tsx @@ -11,6 +11,7 @@ import { getDictionary } from "@/i18n/get-dictionary"; import { isArchiveView } from "@/lib/archive"; import { ensureAutoArchived } from "@/lib/archive-runner"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; +import { AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list"; export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { const t = getDictionary(await getLocale()); @@ -49,7 +50,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun return (
-

{t.adminOrdersPage.title}

+ {/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das aus wie ein folgenloser Klick. */} @@ -67,18 +68,16 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun /> {isArchive ?

{t.adminArchive.autoRuleOrders}

: null} -
- {isArchive ? : null} - +
diff --git a/src/app/admin/inhalte/page.tsx b/src/app/admin/inhalte/page.tsx index f052024..ecafba6 100644 --- a/src/app/admin/inhalte/page.tsx +++ b/src/app/admin/inhalte/page.tsx @@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import type { Dictionary } from "@/i18n/dictionary-type"; import type { Locale } from "@/i18n/config"; +import { Button } from "@/components/ui"; const ROWS_BY_TYPE: Record = { text: 1, @@ -190,9 +191,9 @@ function GroupForm({ ))} - + ); } diff --git a/src/app/admin/produkte/page.tsx b/src/app/admin/produkte/page.tsx index a8fea2b..6378040 100644 --- a/src/app/admin/produkte/page.tsx +++ b/src/app/admin/produkte/page.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; import { formatPrice } from "@/lib/format"; @@ -10,6 +9,15 @@ import { getDictionary } from "@/i18n/get-dictionary"; import { isArchiveView } from "@/lib/archive"; import { ensureAutoArchived } from "@/lib/archive-runner"; 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">) { 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 } } }), ]); + const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct }; + return (
-
-

{t.adminProductsPage.title}

- - {t.adminProductsPage.newProduct} - -
+ @@ -63,44 +68,51 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt - - - - - - - - - + + + + + + + + {products.map((product) => ( - - - + + - - - - - + + + + - ))}
{t.adminProductsPage.colProduct}{t.adminProductsPage.colCategory}{t.adminProductsPage.colHarvest}{t.adminProductsPage.colBatch}{t.adminProductsPage.colWeight}{t.adminProductsPage.colPrice}{t.adminProductsPage.colStock}{t.adminProductsPage.colStatus} + {t.adminProductsPage.colProduct}{t.adminProductsPage.colCategory}{t.adminProductsPage.colHarvest}{t.adminProductsPage.colBatch}{t.adminProductsPage.colWeight}{t.adminProductsPage.colPrice}{t.adminProductsPage.colStock}{t.adminProductsPage.colStatus}
{product.name}{t.common.categories[product.category]} + + {product.name} + {t.common.categories[product.category]} {product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"} {product.batch?.code ?? "—"}{product.weightGrams} g{formatPrice(product.priceCents, product.currency)}{product.inventory?.quantity ?? 0} - {product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive} + {product.batch?.code ?? "—"}{product.weightGrams} g{formatPrice(product.priceCents, product.currency)} + {product.inventory?.quantity ?? 0} + + {product.isActive ? ( + {t.adminProductsPage.statusActive} + ) : ( + {t.adminProductsPage.statusInactive} + )} {/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten (admin/actions/products.ts). Aus ApiCore importierte Entwuerfe sehen sonst wie ein normales inaktives Produkt aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */} {product.priceCents <= 0 ? ( - {t.adminProductsPage.draftHint} + {t.adminProductsPage.draftHint} ) : null} - - {t.adminProductsPage.edit} - - + + {/* Bearbeiten ueber den Produktnamen links — hier nur die + selteneren Aktionen, damit die Tabelle nicht seitlich scrollt. */} + - {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit onDelete: Restrict auf das Produkt, damit gestellte Rechnungen ihren Bezug behalten (siehe @@ -123,25 +134,24 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt - + {t.adminProductsPage.delete} ) : ( - {t.adminProductsPage.notDeletable} + {t.adminProductsPage.notDeletable} )} +
{products.length === 0 ? ( -

- {isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet} -

+ ) : null}
diff --git a/src/app/admin/retouren/page.tsx b/src/app/admin/retouren/page.tsx index 7806099..a1fa4d3 100644 --- a/src/app/admin/retouren/page.tsx +++ b/src/app/admin/retouren/page.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import { prisma } from "@/lib/prisma"; import { BackLink } from "@/components/back-link"; import { formatDate, formatPrice } from "@/lib/format"; @@ -9,6 +8,23 @@ import { getDictionary } from "@/i18n/get-dictionary"; import { isArchiveView } from "@/lib/archive"; import { ensureAutoArchived } from "@/lib/archive-runner"; 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 = { + ANGEFRAGT: "pending", + ANGENOMMEN: "pending", + ABGELEHNT: "neutral", + ERSTATTET: "positive", +}; export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) { const t = getDictionary(await getLocale()); @@ -53,7 +69,7 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure return (
-

{t.adminReturnsPage.title}

+ {isArchive ?

{t.adminArchive.autoRuleReturns}

: null} -
- {isArchive ? : null} - - - {query ? ( - - {t.adminReturnsPage.resetFilter} - - ) : null} -
+
{returnRequests.map((request) => ( @@ -102,11 +103,16 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure

{request.order.firstName} {request.order.lastName} ¡ {request.order.email}

-

{formatDate(request.createdAt)}

+

{formatDate(request.createdAt)}

-
-

{statusLabels[request.status] ?? request.status}

-
+
+ + {statusLabels[request.status] ?? request.status} + +
))} -

{formatPrice(request.order.totalCents, request.order.currency)}

+

{formatPrice(request.order.totalCents, request.order.currency)}

{request.reason}

{request.status === "ANGEFRAGT" ? ( -
+
- +
-
+ - - + {t.adminReturnsPage.reject} +
) : null} {request.status === "ANGENOMMEN" ? ( -
+ // Erstattung bewegt echtes Geld und laesst sich nicht zuruecknehmen — + // deshalb mit Rueckfrage samt Betrag statt Ein-Klick-Link. + - - + +
) : null}
))} {returnRequests.length === 0 ? ( -

- {query - ? t.adminReturnsPage.noResultsFiltered - : isArchive - ? t.adminArchive.emptyArchive - : t.adminReturnsPage.noRequestsYet} -

+ ) : null}
diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx index 2a1b405..7e0bd67 100644 --- a/src/app/honig/[slug]/page.tsx +++ b/src/app/honig/[slug]/page.tsx @@ -292,7 +292,7 @@ export default async function ProductDetailPage(

{t.productPage.shippingHint( baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale) @@ -367,7 +367,21 @@ export default async function ProductDetailPage(
-
@@ -413,11 +427,11 @@ export default async function ProductDetailPage( {isHoney ? (
{t.productPage.learnMoreLabel} - + {t.wissenArticles["warum-kristallisiert-honig"].title} {harvestWissenSlug ? ( - + {t.wissenArticles[harvestWissenSlug].title} ) : null} @@ -427,7 +441,7 @@ export default async function ProductDetailPage( {isHoney && product.batch ? ( {t.productPage.viewBatchOrigin} diff --git a/src/app/kontakt/page.tsx b/src/app/kontakt/page.tsx index 3317229..0da272b 100644 --- a/src/app/kontakt/page.tsx +++ b/src/app/kontakt/page.tsx @@ -28,13 +28,13 @@ export default async function KontaktPage() {
{/* Kontaktkarte auf Wabenraster: die E-Mail ist der Hauptweg und steht deshalb gross in Serifenschrift. */} - +
{company.email}

{t.account.privacy.heading}

-
+

{t.account.privacy.downloadDataHeading}

{t.account.privacy.downloadDataDescription}

diff --git a/src/components/admin-archive.tsx b/src/components/admin-archive.tsx index 6667619..2e9ea40 100644 --- a/src/components/admin-archive.tsx +++ b/src/components/admin-archive.tsx @@ -72,7 +72,7 @@ export function AdminArchiveToggle({ {archivedManually ? ` ¡ ${labels.manualHint}` : null} ) : null} - diff --git a/src/components/admin-list.tsx b/src/components/admin-list.tsx index 1047f0a..5d33f1d 100644 --- a/src/components/admin-list.tsx +++ b/src/components/admin-list.tsx @@ -39,6 +39,9 @@ export function AdminSearchForm({ submitLabel, resetHref, resetLabel, + hiddenFields = {}, + children, + isFiltered, }: { query: string; label: string; @@ -46,9 +49,18 @@ export function AdminSearchForm({ submitLabel: string; resetHref: string; resetLabel: string; + /** Mitzusendende Parameter, z.B. die Archiv-Ansicht. */ + hiddenFields?: Record; + /** Weitere Filterfelder (z.B. Status-Auswahl) zwischen Suche und Knopf. */ + children?: ReactNode; + /** Ob "Filter zuruecksetzen" erscheint; Standard: sobald gesucht wird. */ + isFiltered?: boolean; }) { return (
+ {Object.entries(hiddenFields).map(([name, value]) => ( + + ))} + {children} - {query ? ( + {(isFiltered ?? query !== "") ? ( {resetLabel} diff --git a/src/components/apicore-sync-panel.tsx b/src/components/apicore-sync-panel.tsx index fd7964a..bb58a1e 100644 --- a/src/components/apicore-sync-panel.tsx +++ b/src/components/apicore-sync-panel.tsx @@ -1,6 +1,7 @@ import { syncApiCoreAction } from "@/app/admin/actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; +import { Button } from "@/components/ui"; /** * Der eine Sync-Knopf und die Anzeige seines Ergebnisses. @@ -95,9 +96,9 @@ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {

{t.intro}

- +
); diff --git a/src/components/back-link.tsx b/src/components/back-link.tsx index 56e554b..4964838 100644 --- a/src/components/back-link.tsx +++ b/src/components/back-link.tsx @@ -9,7 +9,7 @@ export function BackLink({ href, label }: { href: string; label: string }) { return ( diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index e3ff046..f6500e8 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -331,7 +331,7 @@ export function ProductCard({ ) : null} {product.category === "HONIG" ? t.product.toHoney : t.product.toProduct} diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index ab03041..18deaaa 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1120,6 +1120,8 @@ export const de: Dictionary = { create: "Anlegen", }, 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", title: "Retouren", searchLabel: "Suche", @@ -1588,8 +1590,8 @@ export const de: Dictionary = { notifyMeLabel: "Benachrichtige mich, wenn es wieder verfügbar ist", emailPlaceholder: "E-Mail-Adresse", notifySubmit: "Anmelden", - removeFromWishlist: "★ Von der Wunschliste entfernen", - addToWishlist: "☆ Zur Wunschliste hinzufügen", + removeFromWishlist: "Von der Wunschliste entfernen", + addToWishlist: "Zur Wunschliste hinzufügen", loginToSave: "um dieses Produkt zu merken.", loginLinkLabel: "Melde dich an", checklist: { diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 543e809..d02ca48 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1106,6 +1106,8 @@ export const en: Dictionary = { create: "Create", }, 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", title: "Returns", searchLabel: "Search", @@ -1574,8 +1576,8 @@ export const en: Dictionary = { notifyMeLabel: "Notify me when it's back in stock", emailPlaceholder: "Email address", notifySubmit: "Sign up", - removeFromWishlist: "★ Remove from wishlist", - addToWishlist: "☆ Add to wishlist", + removeFromWishlist: "Remove from wishlist", + addToWishlist: "Add to wishlist", loginToSave: "to save this product.", loginLinkLabel: "Sign in", checklist: { diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index be6b32c..bf0daad 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -787,6 +787,8 @@ export interface Dictionary { create: string; }; adminReturnsPage: { + refundConfirm: (orderNumber: string, amount: string) => string; + rejectConfirm: (orderNumber: string) => string; backToDashboard: string; title: string; searchLabel: string;