Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt offen, die am meisten hergeben: - Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten bzw. des sechsstelligen Codes. - Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein Weg herauszufinden, welche Adressen registriert sind. - Checkout — automatisierte Bestellungen blockieren Lagerbestand und eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests brauchbar macht. - Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter Versand an sie aus. Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld — er schickt kein Formular ab. Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine Seite den Feldnamen anders geschrieben, waere das Token leer angekommen — und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere der Schutz dort lautlos ausgefallen. Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein, waehrend jedes Formular offensteht. Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse. Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches Raetsel kostete nur Bedienbarkeit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
460 lines
20 KiB
TypeScript
460 lines
20 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { Fragment } from "react";
|
|
import { notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Container, Button } from "@/components/ui";
|
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
|
import { ProductGallery } from "@/components/product-gallery";
|
|
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
|
|
import { RecordProductView } from "@/components/record-product-view";
|
|
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
|
import { StarRating } from "@/components/star-rating";
|
|
import { TrustBadges } from "@/components/trust-badges";
|
|
import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar";
|
|
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
|
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
|
import { getSiteUrl } from "@/lib/url";
|
|
import { getCompanyInfo } from "@/lib/company-info";
|
|
import { getCurrentCustomer } from "@/lib/auth";
|
|
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
|
import { isProductWishlisted } from "@/lib/wishlist";
|
|
import { getShippingTiers } from "@/lib/shipping";
|
|
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
|
import { TurnstileField } from "@/components/turnstile-widget";
|
|
import { getLocale } from "@/i18n/locale";
|
|
import { getDictionary } from "@/i18n/get-dictionary";
|
|
import type { WissenSlug } from "@/i18n/dictionary-type";
|
|
|
|
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
|
|
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
|
|
// von einer zur Build-Zeit erreichbaren Datenbank.
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export async function generateMetadata(
|
|
props: PageProps<"/honig/[slug]">
|
|
): Promise<Metadata> {
|
|
const { slug } = await props.params;
|
|
const product = await getProductBySlug(slug);
|
|
if (!product) return {};
|
|
|
|
return {
|
|
title: product.name,
|
|
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
|
|
alternates: { canonical: `/honig/${slug}` },
|
|
// Deaktivierte Produkte bleiben erreichbar (die Bestellhistorie und die
|
|
// Sendungsverfolgung verlinken hierher zum Bewerten), gehoeren aber nicht
|
|
// in den Suchindex: wer ueber Google kaeme, faende ein Produkt, das nicht
|
|
// mehr bestellbar ist.
|
|
...(product.isActive ? {} : { robots: { index: false, follow: true } }),
|
|
};
|
|
}
|
|
|
|
export default async function ProductDetailPage(
|
|
props: PageProps<"/honig/[slug]">
|
|
) {
|
|
const locale = await getLocale();
|
|
const t = getDictionary(locale);
|
|
const { slug } = await props.params;
|
|
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
|
const product = await getProductBySlug(slug);
|
|
if (!product) notFound();
|
|
|
|
const isHoney = product.category === "HONIG" && product.harvest && product.batch;
|
|
// Verlinkt die Ernteart auf den passenden Honigwissen-Artikel, sofern der
|
|
// Ernte-Typ (freier Admin-Text, siehe Harvest.type) eindeutig als Frueh-
|
|
// oder Sommertracht erkennbar ist — sonst einfach kein zusaetzlicher Link,
|
|
// statt eine falsche Zuordnung zu raten.
|
|
const harvestTypeLower = product.harvest?.type.toLowerCase() ?? "";
|
|
const harvestWissenSlug: WissenSlug | null = harvestTypeLower.includes("früh")
|
|
? "was-bedeutet-fruehtracht"
|
|
: harvestTypeLower.includes("sommer")
|
|
? "was-bedeutet-sommertracht"
|
|
: null;
|
|
const stock = product.inventory?.quantity ?? 0;
|
|
// Ein im Admin deaktiviertes Produkt ist nicht bestellbar, auch wenn noch
|
|
// Bestand gebucht ist. Es verschwindet bereits aus Shop-Uebersicht und
|
|
// Sitemap, seine Detailseite blieb aber voll bestellbar — erreichbar ueber
|
|
// Direktlink, Lesezeichen, alte Newsletter und "Zuletzt angesehen".
|
|
const isPurchasable = product.isActive && stock > 0;
|
|
const lowStock = isPurchasable && stock <= 5;
|
|
|
|
const customer = await getCurrentCustomer();
|
|
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
|
|
await Promise.all([
|
|
getApprovedReviews(product.id),
|
|
getReviewSummary(product.id),
|
|
getRelatedProducts(product.id, product.category),
|
|
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
|
|
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
|
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
|
getCompanyInfo(),
|
|
getShippingTiers(),
|
|
]);
|
|
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
|
|
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
|
|
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
|
|
// haengt bei einem kleinen Versandhandel oft direkt daran).
|
|
const baseShippingCents = resolveShippingCostCents(shippingTiers, 1);
|
|
|
|
const base = getSiteUrl();
|
|
const jsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Product",
|
|
name: product.name,
|
|
description: product.description,
|
|
sku: product.batch?.code ?? product.slug,
|
|
brand: { "@type": "Brand", name: company.name },
|
|
category: t.common.categories[product.category],
|
|
// Nur bei echter Produktfotografie gesetzt (siehe ProductGallery) — ohne
|
|
// hochgeladene Bilder faellt die Galerie auf reine CSS-Platzhalterkacheln
|
|
// zurueck, die als "image" im strukturierten Datensatz falsche Tatsachen
|
|
// vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute
|
|
// URL zu einem echten Produktfoto, keine Platzhaltergrafik).
|
|
...(product.images.length > 0
|
|
? { image: product.images.map((src) => `${base}${src}`) }
|
|
: {}),
|
|
offers: {
|
|
"@type": "Offer",
|
|
priceCurrency: product.currency,
|
|
price: (product.priceCents / 100).toFixed(2),
|
|
availability: isPurchasable ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
|
url: `${base}/honig/${product.slug}`,
|
|
},
|
|
...(reviewSummary.count > 0
|
|
? {
|
|
aggregateRating: {
|
|
"@type": "AggregateRating",
|
|
ratingValue: reviewSummary.average?.toFixed(1),
|
|
reviewCount: reviewSummary.count,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
const breadcrumbJsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: [
|
|
{ "@type": "ListItem", position: 1, name: "Shop", item: `${base}/shop` },
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: t.common.categories[product.category],
|
|
item: `${base}/shop?kategorie=${product.category}`,
|
|
},
|
|
{
|
|
"@type": "ListItem",
|
|
position: 3,
|
|
name: product.name,
|
|
item: `${base}/honig/${product.slug}`,
|
|
},
|
|
],
|
|
};
|
|
|
|
return (
|
|
<div className="py-16 pb-28 md:pb-16">
|
|
<RecordProductView productId={product.id} />
|
|
<StickyAddToCartBar product={product} inStock={isPurchasable} />
|
|
<script
|
|
type="application/ld+json"
|
|
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
|
// </script> einschleusen koennen (XSS-Schutz fuer JSON-LD).
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
|
|
/>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
|
/>
|
|
<Container className="grid md:grid-cols-2 gap-14">
|
|
<ProductGallery
|
|
images={product.images}
|
|
productName={product.name}
|
|
description={product.tasteTags.join(", ")}
|
|
/>
|
|
|
|
<div>
|
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
|
{company.name} · {t.common.categories[product.category]}
|
|
</p>
|
|
<h1 className="text-4xl text-forest">{product.name}</h1>
|
|
<p className="mt-2 text-ink/70">{product.tasteTags.join(" · ")}</p>
|
|
{reviewSummary.count > 0 ? (
|
|
<Link href="#bewertungen" className="mt-4 flex items-center gap-2 text-sm">
|
|
<StarRating value={reviewSummary.average ?? 0} size={16} />
|
|
<span className="text-ink/65">
|
|
{reviewSummary.average?.toFixed(1)} ({t.productPage.reviewsLink(reviewSummary.count)})
|
|
</span>
|
|
</Link>
|
|
) : (
|
|
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviews}</p>
|
|
)}
|
|
|
|
<div className="mt-6 flex items-baseline gap-3">
|
|
<span className="text-2xl text-forest">
|
|
{formatPrice(product.priceCents, product.currency, locale)}
|
|
</span>
|
|
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
|
</div>
|
|
<p className="text-xs text-ink/65">
|
|
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
|
</p>
|
|
<Link
|
|
href="/versand-zahlung"
|
|
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
|
|
>
|
|
{t.productPage.shippingHint(
|
|
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
|
|
)}
|
|
</Link>
|
|
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
|
|
|
|
{isHoney ? (
|
|
<div className="mt-8 space-y-3">
|
|
<TasteScale label={t.product.tasteMild} value={product.intensityMild} />
|
|
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
|
|
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
|
|
</div>
|
|
) : null}
|
|
|
|
{isPurchasable ? (
|
|
<>
|
|
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
|
|
{lowStock ? (
|
|
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button disabled className="mt-8 max-w-xs w-full">
|
|
{t.productPage.soldOut}
|
|
</Button>
|
|
<p className="mt-2 text-xs text-ink/65">
|
|
{t.productPage.currentlyUnavailable}
|
|
</p>
|
|
{/* Nur bei aktivem, aber ausverkauftem Produkt: die
|
|
Benachrichtigung haengt am Lagerbestand (lib/stock-
|
|
notifications.ts). Bei einem dauerhaft deaktivierten Produkt
|
|
waere sie ein Versprechen, das nie eingeloest wird. */}
|
|
{product.isActive ? (
|
|
<>
|
|
<form action={subscribeStockNotificationAction} className="mt-4 max-w-xs">
|
|
<input type="hidden" name="productId" value={product.id} />
|
|
<label htmlFor="notify-email" className="text-xs text-ink/65">
|
|
{t.productPage.notifyMeLabel}
|
|
</label>
|
|
<div className="mt-2 flex gap-2">
|
|
<input
|
|
id="notify-email"
|
|
name="email"
|
|
type="email"
|
|
required
|
|
placeholder={t.productPage.emailPlaceholder}
|
|
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest"
|
|
>
|
|
{t.productPage.notifySubmit}
|
|
</button>
|
|
</div>
|
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
|
<div className="mt-2">
|
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
|
</div>
|
|
) : null}
|
|
</form>
|
|
{notifyError ? (
|
|
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
|
) : null}
|
|
{notifyMessage ? (
|
|
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
<TrustBadges className="mt-6" />
|
|
|
|
{customer ? (
|
|
<form action={toggleWishlistAction} className="mt-3">
|
|
<input type="hidden" name="productId" value={product.id} />
|
|
<input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} />
|
|
<button type="submit" className="text-sm text-ink/70 hover:text-forest underline">
|
|
{isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist}
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<p className="mt-3 text-sm text-ink/65">
|
|
<Link href="/konto" className="text-forest underline">
|
|
{t.productPage.loginLinkLabel}
|
|
</Link>
|
|
, {t.productPage.loginToSave}
|
|
</p>
|
|
)}
|
|
|
|
<ul className="mt-8 space-y-2 text-sm text-ink/70">
|
|
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
|
|
<li key={item} className="flex items-center gap-2">
|
|
<span className="text-honey-dark">✓</span> {item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className="mt-12 border-t border-ink/10 divide-y divide-ink/10 text-sm">
|
|
<DetailRow label={t.productPage.detailLabels.description} value={product.description} />
|
|
{isHoney && product.harvest && product.batch ? (
|
|
<>
|
|
<DetailRow
|
|
label={t.productPage.detailLabels.origin}
|
|
value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`}
|
|
/>
|
|
<DetailRow
|
|
label={t.productPage.detailLabels.harvest}
|
|
value={formatDate(product.harvest.harvestDate, locale)}
|
|
/>
|
|
<DetailRow label={t.productPage.detailLabels.batch} value={product.batch.code} />
|
|
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
|
|
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
|
|
</>
|
|
) : (
|
|
<DetailRow label={t.productPage.detailLabels.origin} value={t.productPage.nonHoneyOriginText} />
|
|
)}
|
|
</div>
|
|
|
|
{isHoney ? (
|
|
<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>
|
|
<Link href="/wissen/warum-kristallisiert-honig" className="text-forest underline">
|
|
{t.wissenArticles["warum-kristallisiert-honig"].title}
|
|
</Link>
|
|
{harvestWissenSlug ? (
|
|
<Link href={`/wissen/${harvestWissenSlug}`} className="text-forest underline">
|
|
{t.wissenArticles[harvestWissenSlug].title}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{isHoney && product.batch ? (
|
|
<Link
|
|
href={`/charge/${product.batch.code}`}
|
|
className="mt-3 inline-block text-sm text-forest underline"
|
|
>
|
|
{t.productPage.viewBatchOrigin}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</Container>
|
|
|
|
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
|
<ScrollReveal>
|
|
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
|
</ScrollReveal>
|
|
|
|
{error ? (
|
|
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
|
) : null}
|
|
{message ? (
|
|
<p className="mt-4 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
|
) : null}
|
|
|
|
{reviews.length === 0 ? (
|
|
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
|
|
) : (
|
|
<ul className="mt-6 space-y-6 divide-y divide-ink/10">
|
|
{reviews.map((review) => (
|
|
<li key={review.id} className="pt-6 first:pt-0">
|
|
<StarRating value={review.rating} />
|
|
<span className="sr-only">{t.productPage.starsOutOf5(review.rating)}</span>
|
|
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
|
|
<p className="mt-2 text-xs text-ink/65">
|
|
{review.customer.firstName} · {formatDate(review.createdAt, locale)}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="mt-10 border-t border-ink/10 pt-8">
|
|
{!customer ? (
|
|
<p className="text-sm text-ink/70">
|
|
<Link href="/konto" className="text-forest underline">
|
|
{t.productPage.loginLinkLabel}
|
|
</Link>
|
|
, {t.productPage.loginToReview}
|
|
</p>
|
|
) : ownReview ? (
|
|
<p className="text-sm text-ink/70">
|
|
{!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed}
|
|
</p>
|
|
) : !canReview ? (
|
|
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
|
|
) : (
|
|
<form action={submitReviewAction} className="space-y-4 max-w-sm">
|
|
<input type="hidden" name="productId" value={product.id} />
|
|
<h3 className="text-sm text-ink/70">{t.productPage.yourRating}</h3>
|
|
<div className="flex flex-row-reverse gap-2" role="radiogroup" aria-label="Sternebewertung">
|
|
{[5, 4, 3, 2, 1].map((value) => (
|
|
<Fragment key={value}>
|
|
<input
|
|
type="radio"
|
|
id={`rating-${value}`}
|
|
name="rating"
|
|
value={value}
|
|
required
|
|
defaultChecked={value === 5}
|
|
aria-label={t.productPage.starLabel(value)}
|
|
className="peer sr-only"
|
|
/>
|
|
<label
|
|
htmlFor={`rating-${value}`}
|
|
className="cursor-pointer text-2xl text-honey-dark/30 peer-checked:text-honey-dark"
|
|
>
|
|
★
|
|
</label>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
<textarea
|
|
name="comment"
|
|
rows={3}
|
|
placeholder={t.productPage.commentPlaceholder}
|
|
className="w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
|
/>
|
|
<Button type="submit">{t.productPage.submitReview}</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</Container>
|
|
|
|
{relatedProducts.length > 0 ? (
|
|
<Container className="mt-20">
|
|
<ScrollReveal>
|
|
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
|
</ScrollReveal>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
|
{relatedProducts.map((related) => (
|
|
<ProductCard key={related.id} product={related} />
|
|
))}
|
|
</div>
|
|
</Container>
|
|
) : null}
|
|
|
|
<Container>
|
|
<RecentlyViewedSection excludeProductId={product.id} />
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="py-4">
|
|
<p className="text-ink/65 text-xs tracking-wide-caps mb-1">{label}</p>
|
|
<p className="text-ink/80 leading-relaxed">{value}</p>
|
|
</div>
|
|
);
|
|
}
|