Compare commits

..
3 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 b77516b9f5 feat(ui): Startseite lebt - fliegende Biene, Imker-Portraet, bewegte Fotos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
- Eine gezeichnete Biene fliegt beim Scrollen eine geschwungene Bahn ueber
  "Aktuelle Ernte" und zieht eine gepunktete Honigspur hinter sich her;
  Fluegelschlag und leichtes Schweben per CSS (components/bee.tsx).
- Neuer Abschnitt nach der Philosophie: Portraet des Imkers in Wabenform,
  die Punkte des Wabenrings wandern an der Kante entlang, eine Biene
  umkreist das Bild. Daneben der eigene Satz von der Imkerei-Seite als
  Zitat - kein neuer Text.
- Herkunft-, Region- und Firmenkunden-Foto bewegen sich beim Scrollen
  leicht mit (Zoom und Parallax, nur transform; components/parallax-media).

Alles rein dekorativ (aria-hidden) und mit Pfad fuer reduzierte Bewegung:
die Biene sitzt dann still, Ring und Fotos stehen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:57:45 +02:00
Nick SzaunigandClaude Opus 5.5 d52a762e8a fix: Produkt-Formular ueberschreibt keinen zwischenzeitlich gebuchten Bestand
Das Speichern schrieb den Formularwert immer fest in den Lagerbestand.
Wurde zwischen Oeffnen und Speichern ein Glas verkauft, kam es dadurch
wieder in den Bestand, ohne dass es je zurueckkam.

- Das Formular schickt den Bestand beim Oeffnen mit (quantityLoaded)
- Feld unveraendert: Bestand wird nicht angefasst
- Feld geaendert: Schreiben nur, wenn der Bestand noch dem geladenen Stand
  entspricht (Bedingung atomar im UPDATE); sonst wird nichts gespeichert
  und der aktuelle Wert angezeigt
- Fehlt quantityLoaded (alter Tab), muss neu geladen werden

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:55:36 +02:00
Nick SzaunigandClaude Opus 5.5 5848eb6ad5 fix: Bestand bei Storno nach erstatteter Retoure nicht doppelt zurueckbuchen
Wurde eine Retoure erstattet und die Bestellung danach auf "STORNIERT"
gesetzt, buchten beide Schritte die Ware zurueck. Bei einer Testbestellung
stieg der Bestand so von 105 auf 106 Glaeser.

- Stornieren bucht nur noch zurueck, wenn keine Retoure erstattet ist
  (needsRestockOnCancel in lib/inventory.ts)
- Retoure einer bereits stornierten Bestellung laesst sich nicht mehr
  erstatten, auch nicht im Wettlauf (Bedingung im atomaren Claim)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 03:12:37 +02:00
12 changed files with 496 additions and 56 deletions

No files matched your search

+5 -2
View File
@@ -6,7 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates"; import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking"; import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory"; import { needsRestockOnCancel, restockOrderItems } from "@/lib/inventory";
import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete"; import { checkOrderDeletable, needsRestockOnDelete } from "@/lib/order-delete";
import { getStripeClient } from "@/lib/stripe"; import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
@@ -139,6 +139,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
stripeCheckoutSessionId: true, stripeCheckoutSessionId: true,
payment: { select: { status: true, stripePaymentIntentId: true } }, payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true } }, items: { select: { productId: true, quantity: true } },
returnRequest: { select: { status: true } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
@@ -164,7 +165,9 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
}); });
if (claim.count === 1) { if (claim.count === 1) {
cancelledNow = true; cancelledNow = true;
await restockOrderItems(tx, previous.items); if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
await restockOrderItems(tx, previous.items);
}
} }
} else { } else {
await tx.order.update({ where: { id: orderId }, data: { status } }); await tx.order.update({ where: { id: orderId }, data: { status } });
+75 -25
View File
@@ -6,6 +6,8 @@ import { logAdminAction } from "@/lib/audit-log";
import { notifyStockAvailable } from "@/lib/stock-notifications"; import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation"; import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { planInventoryEdit } from "@/lib/inventory";
import type { Prisma } from "@/generated/prisma/client";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash"; import { succeed } from "@/lib/flash";
@@ -160,6 +162,35 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
return succeed("/admin/produkte", "Produkt gelöscht."); return succeed("/admin/produkte", "Produkt gelöscht.");
} }
/**
* Schreibt den Bestand nur, wenn er noch dem Stand beim Oeffnen des
* Formulars entspricht. Die Bedingung steht im UPDATE selbst und wird damit
* atomar gegen den aktuellen Zeilenstand geprueft — ein vorheriges Lesen
* koennte eine gleichzeitige Bestellung nicht ausschliessen.
*
* @returns false, wenn sich der Bestand inzwischen geaendert hat.
*/
async function setInventoryIfUnchanged(
tx: Prisma.TransactionClient,
productId: string,
plan: { expected: number; quantity: number }
): Promise<boolean> {
const updated = await tx.inventory.updateMany({
where: { productId, quantity: plan.expected },
data: { quantity: plan.quantity },
});
if (updated.count === 1) return true;
// Kein Bestands-Datensatz gilt im Formular als 0 (siehe produkte/[id]).
// Nur dann anlegen; existiert der Datensatz mit anderem Wert, ueberspringt
// skipDuplicates das Anlegen und count bleibt 0 — also ein Konflikt.
if (plan.expected !== 0) return false;
const created = await tx.inventory.createMany({
data: [{ productId, quantity: plan.quantity }],
skipDuplicates: true,
});
return created.count === 1;
}
export async function updateProductAction(formData: FormData): Promise<void> { export async function updateProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
@@ -182,32 +213,50 @@ export async function updateProductAction(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") === "on"; const isActive = formData.get("isActive") === "on";
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents); await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const previousInventory = await prisma.inventory.findUnique({ const inventoryPlan = planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
where: { productId: id }, if (inventoryPlan.kind === "reload") {
select: { quantity: true }, return fail(
`/admin/produkte/${id}`,
"Das Formular ist veraltet. Bitte die Seite neu laden und die Änderung erneut eintragen."
);
}
// Bestand und Produktdaten in einer Transaktion: passt der Bestand nicht
// mehr zum geladenen Stand, wird auch der Rest nicht gespeichert — sonst
// haette die Imkerei halb gespeicherte Aenderungen, ohne es zu merken.
const product = await prisma.$transaction(async (tx) => {
if (inventoryPlan.kind === "set" && !(await setInventoryIfUnchanged(tx, id, inventoryPlan))) {
return null;
}
return tx.product.update({
where: { id },
data: {
name: str(formData, "name"),
description: str(formData, "description"),
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
intensityMild: numbers.intensityMild,
intensityFloral: numbers.intensityFloral,
intensityKraeftig: numbers.intensityKraeftig,
consistency: str(formData, "consistency") || "feincremig",
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive,
},
});
}); });
const product = await prisma.product.update({ if (!product) {
where: { id }, const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
data: { return fail(
name: str(formData, "name"), `/admin/produkte/${id}`,
description: str(formData, "description"), `Der Bestand hat sich seit dem Öffnen geändert (jetzt ${current?.quantity ?? 0} statt ${
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean), inventoryPlan.kind === "set" ? inventoryPlan.expected : 0
intensityMild: numbers.intensityMild, }), vermutlich durch eine Bestellung. Nichts wurde gespeichert — bitte die Seite neu laden und erneut eintragen.`
intensityFloral: numbers.intensityFloral, );
intensityKraeftig: numbers.intensityKraeftig, }
consistency: str(formData, "consistency") || "feincremig",
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
vatRatePercent: numbers.vatRatePercent,
images: strArray(formData, "images").filter(isValidUploadPath),
isActive,
inventory: {
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
},
},
});
await logAdminAction(admin, { await logAdminAction(admin, {
action: "product.update", action: "product.update",
@@ -218,7 +267,8 @@ export async function updateProductAction(formData: FormData): Promise<void> {
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und // Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden. // ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) { // Bei "set" ist expected garantiert der Stand direkt vor dem Schreiben.
if (inventoryPlan.kind === "set" && inventoryPlan.expected === 0 && inventoryPlan.quantity > 0) {
await notifyStockAvailable(id); await notifyStockAvailable(id);
} }
+2
View File
@@ -43,6 +43,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
</p> </p>
<AdminFormPanel action={updateProductAction} actions={<Button type="submit">{te.save}</Button>}> <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} />
{/* Bestand beim Oeffnen — siehe planInventoryEdit in lib/inventory.ts */}
<input type="hidden" name="quantityLoaded" value={String(product.inventory?.quantity ?? 0)} />
<AdminFieldset legend={te.sectionText}> <AdminFieldset legend={te.sectionText}>
<AdminField name="name" label={te.nameLabel} defaultValue={product.name} required /> <AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
<AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required /> <AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required />
+9 -1
View File
@@ -87,8 +87,16 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl, // Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut // wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
// versucht werden kann. // versucht werden kann.
//
// Eine stornierte Bestellung ist ausgeschlossen: updateOrderStatusAction
// hat beim Stornieren die Ware schon zurueckgebucht und die Zahlung
// erstattet — hier noch einmal gutzuschreiben erhoehte den Bestand um
// Glaeser, die es nicht gibt (Gegenstueck zu needsRestockOnCancel).
if (returnRequest.order.status === "STORNIERT") {
return fail("/admin/retouren", "Die Bestellung wurde bereits storniert — Ware und Zahlung sind schon zurückgebucht.");
}
const claimed = await prisma.returnRequest.updateMany({ const claimed = await prisma.returnRequest.updateMany({
where: { id, status: "ANGENOMMEN" }, where: { id, status: "ANGENOMMEN", order: { status: { not: "STORNIERT" } } },
data: { status: "ERSTATTET" }, data: { status: "ERSTATTET" },
}); });
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet."); if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
+43
View File
@@ -305,6 +305,49 @@ textarea::placeholder {
} }
} }
/* Biene (components/bee.tsx): Fluegelschlag, leichtes Schweben im Flug und
die Runde um das Imker-Portraet auf der Startseite. */
@keyframes wh-flutter {
from {
transform: scaleY(1);
}
to {
transform: scaleY(0.35);
}
}
@keyframes wh-hover {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes wh-orbit {
to {
transform: rotate(360deg);
}
}
/* Punkte laufen entlang einer gestrichelten Linie (Wabenring um das Portraet). */
@keyframes wh-march {
to {
stroke-dashoffset: -35;
}
}
@media (prefers-reduced-motion: reduce) {
.motion-flutter,
.motion-hover,
.motion-orbit,
.motion-march {
animation: none !important;
}
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details
fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln. fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln.
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Hero } from "@/components/hero"; import { Hero } from "@/components/hero";
import { import {
BeekeeperNote,
CurrentHarvest, CurrentHarvest,
OriginStory, OriginStory,
Philosophy, Philosophy,
@@ -57,6 +58,7 @@ export default async function HomePage() {
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} /> <CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
<OriginStory /> <OriginStory />
<Philosophy /> <Philosophy />
<BeekeeperNote />
{spotlightProduct ? <ProductSpotlight product={spotlightProduct} /> : null} {spotlightProduct ? <ProductSpotlight product={spotlightProduct} /> : null}
<ProcessTimeline /> <ProcessTimeline />
<RegionalSection /> <RegionalSection />
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useEffect, useId, useRef } from "react";
import { motion, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from "motion/react";
/**
* Gezeichnete Biene (Blick nach rechts) im Strich der uebrigen Marken-Grafik.
* Die Fluegel schlagen per CSS (wh-flutter); bei reduzierter Bewegung stehen
* sie still (.motion-flutter, globals.css).
*/
export function BeeGlyph({ className = "h-8 w-10" }: { className?: string }) {
const clipId = `bee-body-${useId().replace(/:/g, "")}`;
return (
<svg viewBox="0 0 40 32" className={className} aria-hidden="true">
<defs>
<clipPath id={clipId}>
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" />
</clipPath>
</defs>
{/* Fluegel — kippen um ihre Unterkante */}
<g className="motion-flutter origin-bottom animate-[wh-flutter_0.11s_ease-in-out_infinite_alternate] [transform-box:fill-box]">
<ellipse cx="16.5" cy="9.5" rx="5.2" ry="8" transform="rotate(-22 16.5 9.5)" fill="#FAF8F3" fillOpacity="0.9" stroke="#24352B" strokeWidth="1.1" />
<ellipse cx="23.5" cy="10" rx="4.4" ry="6.8" transform="rotate(14 23.5 10)" fill="#FAF8F3" fillOpacity="0.9" stroke="#24352B" strokeWidth="1.1" />
</g>
{/* Stachel */}
<path d="M9.6 19.2 5.2 20l4.4.9z" fill="#24352B" />
{/* Koerper mit Streifen */}
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" fill="#C9A468" />
<g clipPath={`url(#${clipId})`} stroke="#24352B" strokeWidth="2.4">
<path d="M15.5 12v16M20.5 12v16M25.5 12v16" />
</g>
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" fill="none" stroke="#24352B" strokeWidth="1.2" />
{/* Kopf und Fuehler */}
<circle cx="32" cy="18.8" r="4.3" fill="#24352B" />
<path d="M33.5 15c1-2.6 2.6-3.9 4.8-4.1M31.4 14.8c-.1-2.8.8-4.6 2.6-5.6" fill="none" stroke="#24352B" strokeWidth="1" strokeLinecap="round" />
</svg>
);
}
// Flugbahn im Koordinatenraum 1000 x 200: kommt links herein, schlaegt zwei
// Boegen und verlaesst den Abschnitt rechts oben.
const FLIGHT_PATH = "M -40 150 C 120 40, 260 190, 430 100 S 700 10, 820 120 S 980 90, 1060 30";
const VIEW_W = 1000;
const VIEW_H = 200;
// Wo die Biene bei reduzierter Bewegung ruhig sitzt (Anteil der Bahn).
const RESTING_POINT = 0.62;
/**
* Eine Biene fliegt beim Scrollen eine geschwungene Bahn ueber den Abschnitt
* und zieht eine gepunktete Honigspur hinter sich her. Rein dekorativ
* (aria-hidden, pointer-events-none). Bei reduzierter Bewegung sitzt sie
* still an einer Stelle, die Spur ist bis dorthin sichtbar.
*/
export function BeeFlight({ className = "" }: { className?: string }) {
const boxRef = useRef<HTMLDivElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const beeRef = useRef<HTMLDivElement>(null);
const maskId = `bee-trail-${useId().replace(/:/g, "")}`;
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({ target: boxRef, offset: ["start end", "end start"] });
// Gefedert: die Biene folgt dem Scrollen mit etwas Traegheit, statt ruckartig zu springen.
const progress = useSpring(scrollYProgress, { stiffness: 55, damping: 18, mass: 0.7 });
const placeBee = useCallback((p: number) => {
const path = pathRef.current;
const bee = beeRef.current;
const box = boxRef.current;
if (!path || !bee || !box) return;
const length = path.getTotalLength();
const at = Math.min(1, Math.max(0, p)) * length;
const point = path.getPointAtLength(at);
const ahead = path.getPointAtLength(Math.min(length, at + 2));
const sx = box.clientWidth / VIEW_W;
const sy = box.clientHeight / VIEW_H;
const angle = (Math.atan2((ahead.y - point.y) * sy, (ahead.x - point.x) * sx) * 180) / Math.PI;
bee.style.transform = `translate(${point.x * sx}px, ${point.y * sy}px) translate(-50%, -50%) rotate(${angle}deg)`;
}, []);
useMotionValueEvent(progress, "change", (p) => {
if (!reduceMotion) placeBee(p);
});
useEffect(() => {
const place = () => placeBee(reduceMotion ? RESTING_POINT : progress.get());
place();
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [placeBee, progress, reduceMotion]);
return (
<div ref={boxRef} aria-hidden="true" className={`pointer-events-none relative ${className}`}>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="none"
className="absolute inset-0 h-full w-full overflow-visible"
>
<defs>
<mask id={maskId} maskUnits="userSpaceOnUse" x="-100" y="-100" width="1300" height="400">
<motion.path
d={FLIGHT_PATH}
fill="none"
stroke="white"
strokeWidth="8"
style={{ pathLength: reduceMotion ? RESTING_POINT : progress }}
/>
</mask>
</defs>
<path
ref={pathRef}
d={FLIGHT_PATH}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeDasharray="1 7"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
mask={`url(#${maskId})`}
className="text-honey"
/>
</svg>
<div ref={beeRef} className="absolute left-0 top-0 will-change-transform">
<div className="motion-hover animate-[wh-hover_1.4s_ease-in-out_infinite]">
<BeeGlyph className="h-11 w-14" />
</div>
</div>
</div>
);
}
+94 -10
View File
@@ -2,6 +2,9 @@ import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { HoneycombPattern, HexMark } from "@/components/honeycomb"; import { HoneycombPattern, HexMark } from "@/components/honeycomb";
import { ScrollReveal } from "@/components/scroll-reveal"; import { ScrollReveal } from "@/components/scroll-reveal";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { BeeFlight, BeeGlyph } from "@/components/bee";
import { ParallaxMedia } from "@/components/parallax-media";
import { getCompanyInfo } from "@/lib/company-info";
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card"; import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -22,7 +25,11 @@ export async function CurrentHarvest({
// Dreierraster. // Dreierraster.
const isFew = products.length <= 2; const isFew = products.length <= 2;
return ( return (
<section className="py-24"> // overflow-x-clip statt overflow-hidden: die Biene verlaesst den Abschnitt
// rechts, ohne eine Scrollleiste zu erzeugen — und die klebende
// Ueberschrift (sticky) funktioniert weiter.
<section className="relative overflow-x-clip py-24">
<BeeFlight className="!absolute inset-x-0 top-0 h-28 md:h-36" />
<Container className={isFew ? "grid gap-12 lg:grid-cols-12 lg:gap-16" : ""}> <Container className={isFew ? "grid gap-12 lg:grid-cols-12 lg:gap-16" : ""}>
<ScrollReveal className={isFew ? "lg:col-span-4" : ""}> <ScrollReveal className={isFew ? "lg:col-span-4" : ""}>
<div className={isFew ? "lg:sticky lg:top-32" : ""}> <div className={isFew ? "lg:sticky lg:top-32" : ""}>
@@ -68,15 +75,17 @@ export async function OriginStory() {
// Fensterrand, der Text steht daneben — kein Rahmen, keine Karte. // Fensterrand, der Text steht daneben — kein Rahmen, keine Karte.
<section className="grid bg-cream/60 md:grid-cols-2"> <section className="grid bg-cream/60 md:grid-cols-2">
<ScrollReveal className="h-full"> <ScrollReveal className="h-full">
<ContentImage <ParallaxMedia className="h-full min-h-[24rem] md:min-h-[40rem]">
src={content["home.originStory.image"] || null} <ContentImage
alt="" src={content["home.originStory.image"] || null}
tone="forest" alt=""
rounded={false} tone="forest"
className="h-full min-h-[24rem] w-full md:min-h-[40rem]" rounded={false}
label={t.home.originStory.eyebrow} className="h-full w-full"
sizes="(min-width: 768px) 50vw, 100vw" label={t.home.originStory.eyebrow}
/> sizes="(min-width: 768px) 50vw, 100vw"
/>
</ParallaxMedia>
</ScrollReveal> </ScrollReveal>
<div className="flex items-center px-6 py-20 sm:px-10 lg:px-20 xl:px-28"> <div className="flex items-center px-6 py-20 sm:px-10 lg:px-20 xl:px-28">
<ScrollReveal delay={0.1} className="max-w-xl"> <ScrollReveal delay={0.1} className="max-w-xl">
@@ -130,6 +139,81 @@ export async function Philosophy() {
); );
} }
/**
* Das Gesicht hinter dem Honig: Portraet des Imkers in Wabenform, ein
* langsam drehender Wabenring und eine Biene, die das Bild umkreist.
* Daneben sein eigener Satz von der Imkerei-Seite — kein neuer Text, nur
* hierher geholt, damit die Leidenschaft schon auf der Startseite spricht.
*/
export async function BeekeeperNote() {
const locale = await getLocale();
const t = getDictionary(locale);
const [content, company] = await Promise.all([getContentValues(["imkerei.image"], locale), getCompanyInfo()]);
const quote = t.imkereiPage.paragraphs[0];
return (
<section className="relative overflow-hidden bg-cream/60 py-24 md:py-32">
<HoneycombPattern className="text-honey/[0.08]" size={26} fade="right" />
<Container className="relative grid items-center gap-16 md:grid-cols-12 md:gap-12">
<ScrollReveal className="mx-auto w-64 sm:w-72 md:col-span-5 md:w-full md:max-w-sm">
<div className="relative aspect-square">
{/* Wabenring deckungsgleich zum Portraet; die Punkte wandern
langsam an der Kante entlang, wie Bienen auf ihrem Weg. */}
<svg
viewBox="0 0 100 100"
aria-hidden="true"
className="absolute -inset-5 h-[calc(100%+2.5rem)] w-[calc(100%+2.5rem)] text-honey"
>
<polygon
points="50,2 91.6,26 91.6,74 50,98 8.4,74 8.4,26"
fill="none"
stroke="currentColor"
strokeWidth="0.7"
strokeDasharray="0.5 3"
strokeLinecap="round"
className="motion-march animate-[wh-march_6s_linear_infinite]"
/>
</svg>
<div className="clip-hex absolute inset-0 bg-honey-pale">
<ContentImage
src={content["imkerei.image"] || null}
alt={t.imkereiPage.imageLabel}
tone="honey"
rounded={false}
className="h-full w-full"
sizes="(min-width: 768px) 24rem, 18rem"
/>
</div>
{/* Die Biene umkreist das Portraet */}
<div aria-hidden="true" className="motion-orbit absolute -inset-8 animate-[wh-orbit_16s_linear_infinite]">
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div className="motion-hover animate-[wh-hover_1.4s_ease-in-out_infinite]">
<BeeGlyph className="h-10 w-12" />
</div>
</div>
</div>
</div>
</ScrollReveal>
<ScrollReveal delay={0.1} className="md:col-span-7">
<blockquote>
<p className="font-serif text-3xl leading-snug text-forest text-balance md:text-4xl lg:text-[2.75rem]">
<span aria-hidden="true" className="mr-1 text-honey">„</span>
{quote}
<span aria-hidden="true" className="ml-0.5 text-honey">“</span>
</p>
<footer className="mt-8 flex items-center gap-3 text-sm text-ink/70">
<HexMark className="h-3 w-3 text-honey" />
{company.name} · {company.location}
</footer>
</blockquote>
<LinkButton href="/imkerei" variant="secondary" className="mt-10">
{t.home.regionalSection.ctaAbout}
</LinkButton>
</ScrollReveal>
</Container>
</section>
);
}
type HoneySpotlightProduct = ProductWithRelations & { type HoneySpotlightProduct = ProductWithRelations & {
harvest: NonNullable<ProductWithRelations["harvest"]>; harvest: NonNullable<ProductWithRelations["harvest"]>;
batch: NonNullable<ProductWithRelations["batch"]>; batch: NonNullable<ProductWithRelations["batch"]>;
+22 -17
View File
@@ -3,6 +3,7 @@ import { ProcessPath } from "@/components/process-path";
import { HoneycombPattern, HexMark } from "@/components/honeycomb"; import { HoneycombPattern, HexMark } from "@/components/honeycomb";
import { ScrollReveal } from "@/components/scroll-reveal"; import { ScrollReveal } from "@/components/scroll-reveal";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ParallaxMedia } from "@/components/parallax-media";
import { StarRating } from "@/components/star-rating"; import { StarRating } from "@/components/star-rating";
import { fetchGoogleReviews } from "@/lib/google-reviews"; import { fetchGoogleReviews } from "@/lib/google-reviews";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -47,14 +48,16 @@ export async function RegionalSection() {
{/* Breites Bild, der Text legt sich als Tafel darueber — Tiefe statt {/* Breites Bild, der Text legt sich als Tafel darueber — Tiefe statt
eines weiteren Bild-neben-Text-Wechsels. */} eines weiteren Bild-neben-Text-Wechsels. */}
<ScrollReveal> <ScrollReveal>
<ContentImage <ParallaxMedia className="rounded-lg">
src={content["home.regionalSection.image"] || null} <ContentImage
alt="" src={content["home.regionalSection.image"] || null}
tone="forest" alt=""
className="aspect-[4/3] w-full sm:aspect-[16/9]" tone="forest"
label={t.home.regionalSection.eyebrow} className="aspect-[4/3] w-full sm:aspect-[16/9]"
sizes="(min-width: 1280px) 1200px, 100vw" label={t.home.regionalSection.eyebrow}
/> sizes="(min-width: 1280px) 1200px, 100vw"
/>
</ParallaxMedia>
</ScrollReveal> </ScrollReveal>
<ScrollReveal <ScrollReveal
delay={0.1} delay={0.1}
@@ -132,15 +135,17 @@ export async function BusinessTeaser() {
{t.home.businessTeaser.cta} {t.home.businessTeaser.cta}
</LinkButton> </LinkButton>
</div> </div>
<ContentImage <ParallaxMedia className="md:col-span-2">
src={content["home.businessTeaser.image"] || null} <ContentImage
alt="" src={content["home.businessTeaser.image"] || null}
tone="honey" alt=""
rounded={false} tone="honey"
className="aspect-[4/3] w-full md:col-span-2 md:aspect-auto md:h-full md:min-h-[22rem]" rounded={false}
label={t.home.businessTeaser.eyebrow} className="aspect-[4/3] h-full w-full md:aspect-auto md:min-h-[22rem]"
sizes="(min-width: 768px) 40vw, 100vw" label={t.home.businessTeaser.eyebrow}
/> sizes="(min-width: 768px) 40vw, 100vw"
/>
</ParallaxMedia>
</ScrollReveal> </ScrollReveal>
</Container> </Container>
</section> </section>
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useRef, type ReactNode } from "react";
import { motion, useReducedMotion, useScroll, useTransform } from "motion/react";
/**
* Foto, das sich beim Scrollen leicht mitbewegt: es kommt etwas vergroessert
* herein, setzt sich beim Durchscrollen und wandert dabei ein wenig gegen die
* Scrollrichtung — die Aufnahme wirkt wie ein Blick durch ein Fenster statt
* wie ein aufgeklebtes Bild. Nur transform (scale/translate), kein Layout.
* Bei reduzierter Bewegung steht das Bild still.
*/
export function ParallaxMedia({ children, className = "" }: { children: ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [1.14, 1.05, 1]);
const y = useTransform(scrollYProgress, [0, 1], ["-4%", "4%"]);
return (
<div ref={ref} className={`overflow-hidden ${className}`}>
<motion.div className="h-full w-full will-change-transform" style={reduceMotion ? undefined : { scale, y }}>
{children}
</motion.div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, test, expect } from "vitest";
import { needsRestockOnCancel, planInventoryEdit } from "./inventory";
describe("planInventoryEdit", () => {
test("laesst den Bestand unberuehrt, wenn das Feld nicht geaendert wurde", () => {
// Zwischen Oeffnen und Speichern verkaufte Glaeser duerfen nicht
// mit dem alten Formularwert ueberschrieben werden.
expect(planInventoryEdit({ loaded: "105", submitted: 105 })).toEqual({ kind: "keep" });
});
test("setzt den Bestand nur unter der Bedingung, dass er noch dem geladenen Wert entspricht", () => {
expect(planInventoryEdit({ loaded: "105", submitted: 120 })).toEqual({
kind: "set",
expected: 105,
quantity: 120,
});
});
test("verlangt ein Neuladen, wenn der geladene Wert fehlt", () => {
// Formular aus einem Tab, der vor dem Deployment geoeffnet wurde.
expect(planInventoryEdit({ loaded: "", submitted: 105 })).toEqual({ kind: "reload" });
});
test("verlangt ein Neuladen bei einem manipulierten geladenen Wert", () => {
expect(planInventoryEdit({ loaded: "-1", submitted: 105 })).toEqual({ kind: "reload" });
expect(planInventoryEdit({ loaded: "1.5", submitted: 105 })).toEqual({ kind: "reload" });
expect(planInventoryEdit({ loaded: "abc", submitted: 105 })).toEqual({ kind: "reload" });
});
});
describe("needsRestockOnCancel", () => {
test("bucht zurueck, wenn es keine Retoure gibt", () => {
expect(needsRestockOnCancel(null)).toBe(true);
});
test("bucht zurueck, solange die Retoure noch nicht erstattet ist", () => {
expect(needsRestockOnCancel("ANGEFRAGT")).toBe(true);
expect(needsRestockOnCancel("ANGENOMMEN")).toBe(true);
expect(needsRestockOnCancel("ABGELEHNT")).toBe(true);
});
test("bucht nicht noch einmal zurueck, wenn die Retoure die Ware schon zurueckgebucht hat", () => {
expect(needsRestockOnCancel("ERSTATTET")).toBe(false);
});
});
+43 -1
View File
@@ -1,4 +1,46 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma, ReturnStatus } from "@/generated/prisma/client";
/**
* Muss beim Stornieren einer Bestellung die Ware zurueckgebucht werden?
*
* Nein, wenn eine Retoure zu dieser Bestellung bereits erstattet wurde:
* refundReturnRequestAction (admin/retouren/actions.ts) hat die Ware dann
* schon zurueckgebucht. Ein zweites Gutschreiben wuerde den Bestand um Glaeser
* erhoehen, die es nicht gibt — genau das ist bei einer Testbestellung
* passiert (Retoure erstattet, danach "STORNIERT" gesetzt: +1 Glas zu viel).
* Gegenstueck zu needsRestockOnDelete in order-delete.ts.
*/
export type InventoryEditPlan =
| { kind: "keep" }
| { kind: "set"; expected: number; quantity: number }
| { kind: "reload" };
/**
* Was soll das Speichern des Produkt-Formulars mit dem Lagerbestand tun?
*
* Das Formular schickt neben dem eingegebenen Bestand ("submitted") den
* Bestand mit, der beim Oeffnen angezeigt wurde ("loaded", verstecktes Feld).
* Frueher wurde der Formularwert immer fest geschrieben: wurde zwischen
* Oeffnen und Speichern ein Glas verkauft, kam es durch das Speichern
* wieder in den Bestand, ohne dass es je zurueckkam.
*
* - Feld unveraendert: Bestand nicht anfassen ("keep").
* - Feld geaendert: nur schreiben, solange der Bestand noch "loaded" ist
* ("set"; die Bedingung prueft updateProductAction atomar im UPDATE).
* - "loaded" fehlt oder ist ungueltig: neu laden lassen ("reload"), statt
* zu raten, welchen Stand die Imkerei vor Augen hatte.
*/
export function planInventoryEdit(input: { loaded: string; submitted: number }): InventoryEditPlan {
if (!/^\d+$/.test(input.loaded)) return { kind: "reload" };
const loaded = Number(input.loaded);
if (!Number.isSafeInteger(loaded)) return { kind: "reload" };
if (loaded === input.submitted) return { kind: "keep" };
return { kind: "set", expected: loaded, quantity: input.submitted };
}
export function needsRestockOnCancel(returnStatus: ReturnStatus | null): boolean {
return returnStatus !== "ERSTATTET";
}
/** /**
* Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren * Bucht reservierten/verkauften Bestand zurueck — genutzt beim Stornieren