Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b77516b9f5 | ||
|
|
d52a762e8a | ||
|
|
5848eb6ad5 |
No files matched your search
@@ -6,7 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||
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 { getStripeClient } from "@/lib/stripe";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
@@ -139,6 +139,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
stripeCheckoutSessionId: true,
|
||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
||||
items: { select: { productId: true, quantity: true } },
|
||||
returnRequest: { select: { status: true } },
|
||||
},
|
||||
});
|
||||
if (!previous) return null;
|
||||
@@ -164,7 +165,9 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
});
|
||||
if (claim.count === 1) {
|
||||
cancelledNow = true;
|
||||
await restockOrderItems(tx, previous.items);
|
||||
if (needsRestockOnCancel(previous.returnRequest?.status ?? null)) {
|
||||
await restockOrderItems(tx, previous.items);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await tx.order.update({ where: { id: orderId }, data: { status } });
|
||||
|
||||
@@ -6,6 +6,8 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||
import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation";
|
||||
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 { succeed } from "@/lib/flash";
|
||||
|
||||
@@ -160,6 +162,35 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
||||
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> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
@@ -182,32 +213,50 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
const isActive = formData.get("isActive") === "on";
|
||||
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||
|
||||
const previousInventory = await prisma.inventory.findUnique({
|
||||
where: { productId: id },
|
||||
select: { quantity: true },
|
||||
const inventoryPlan = planInventoryEdit({ loaded: str(formData, "quantityLoaded"), submitted: numbers.quantity });
|
||||
if (inventoryPlan.kind === "reload") {
|
||||
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({
|
||||
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,
|
||||
inventory: {
|
||||
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!product) {
|
||||
const current = await prisma.inventory.findUnique({ where: { productId: id }, select: { quantity: true } });
|
||||
return fail(
|
||||
`/admin/produkte/${id}`,
|
||||
`Der Bestand hat sich seit dem Öffnen geändert (jetzt ${current?.quantity ?? 0} statt ${
|
||||
inventoryPlan.kind === "set" ? inventoryPlan.expected : 0
|
||||
}), vermutlich durch eine Bestellung. Nichts wurde gespeichert — bitte die Seite neu laden und erneut eintragen.`
|
||||
);
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "product.update",
|
||||
@@ -218,7 +267,8 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
|
||||
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
||||
</p>
|
||||
<AdminFormPanel action={updateProductAction} actions={<Button type="submit">{te.save}</Button>}>
|
||||
<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}>
|
||||
<AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
|
||||
<AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required />
|
||||
|
||||
@@ -87,8 +87,16 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
// Stripe-Erstattungen ausloesen. Schlaegt der Stripe-Aufruf unten fehl,
|
||||
// wird der Status wieder zurueckgesetzt, damit die Erstattung erneut
|
||||
// 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({
|
||||
where: { id, status: "ANGENOMMEN" },
|
||||
where: { id, status: "ANGENOMMEN", order: { status: { not: "STORNIERT" } } },
|
||||
data: { status: "ERSTATTET" },
|
||||
});
|
||||
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
|
||||
|
||||
@@ -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
|
||||
fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Hero } from "@/components/hero";
|
||||
import {
|
||||
BeekeeperNote,
|
||||
CurrentHarvest,
|
||||
OriginStory,
|
||||
Philosophy,
|
||||
@@ -57,6 +58,7 @@ export default async function HomePage() {
|
||||
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
||||
<OriginStory />
|
||||
<Philosophy />
|
||||
<BeekeeperNote />
|
||||
{spotlightProduct ? <ProductSpotlight product={spotlightProduct} /> : null}
|
||||
<ProcessTimeline />
|
||||
<RegionalSection />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
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 { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -22,7 +25,11 @@ export async function CurrentHarvest({
|
||||
// Dreierraster.
|
||||
const isFew = products.length <= 2;
|
||||
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" : ""}>
|
||||
<ScrollReveal className={isFew ? "lg:col-span-4" : ""}>
|
||||
<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.
|
||||
<section className="grid bg-cream/60 md:grid-cols-2">
|
||||
<ScrollReveal className="h-full">
|
||||
<ContentImage
|
||||
src={content["home.originStory.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
rounded={false}
|
||||
className="h-full min-h-[24rem] w-full md:min-h-[40rem]"
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
<ParallaxMedia className="h-full min-h-[24rem] md:min-h-[40rem]">
|
||||
<ContentImage
|
||||
src={content["home.originStory.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
rounded={false}
|
||||
className="h-full w-full"
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</ScrollReveal>
|
||||
<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">
|
||||
@@ -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 & {
|
||||
harvest: NonNullable<ProductWithRelations["harvest"]>;
|
||||
batch: NonNullable<ProductWithRelations["batch"]>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ProcessPath } from "@/components/process-path";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ParallaxMedia } from "@/components/parallax-media";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -47,14 +48,16 @@ export async function RegionalSection() {
|
||||
{/* Breites Bild, der Text legt sich als Tafel darueber — Tiefe statt
|
||||
eines weiteren Bild-neben-Text-Wechsels. */}
|
||||
<ScrollReveal>
|
||||
<ContentImage
|
||||
src={content["home.regionalSection.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full sm:aspect-[16/9]"
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 1280px) 1200px, 100vw"
|
||||
/>
|
||||
<ParallaxMedia className="rounded-lg">
|
||||
<ContentImage
|
||||
src={content["home.regionalSection.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full sm:aspect-[16/9]"
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 1280px) 1200px, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal
|
||||
delay={0.1}
|
||||
@@ -132,15 +135,17 @@ export async function BusinessTeaser() {
|
||||
{t.home.businessTeaser.cta}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
tone="honey"
|
||||
rounded={false}
|
||||
className="aspect-[4/3] w-full md:col-span-2 md:aspect-auto md:h-full md:min-h-[22rem]"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 40vw, 100vw"
|
||||
/>
|
||||
<ParallaxMedia className="md:col-span-2">
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
tone="honey"
|
||||
rounded={false}
|
||||
className="aspect-[4/3] h-full w-full md:aspect-auto md:min-h-[22rem]"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 40vw, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user