feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,192 @@
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValues, parseListTitled, parseListNumbered } from "@/lib/content";
|
||||
|
||||
export async function ProcessTimeline() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const content = await getContentValues(["home.processTimeline.steps"], locale);
|
||||
const steps = parseListNumbered(content["home.processTimeline.steps"]);
|
||||
return (
|
||||
<section className="py-24 bg-cream/60">
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
eyebrow={t.home.processTimeline.eyebrow}
|
||||
title={t.home.processTimeline.title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-16 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-8">
|
||||
{steps.map((step, i) => (
|
||||
<ScrollReveal key={step.number} delay={i * 0.06}>
|
||||
<div>
|
||||
<span className="text-honey-dark text-sm tracking-wide-caps">
|
||||
{step.number}
|
||||
</span>
|
||||
<h3 className="mt-2 text-lg text-forest">{step.title}</h3>
|
||||
<p className="mt-2 text-xs text-ink/70 leading-relaxed">
|
||||
{step.text}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export async function RegionalSection() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const content = await getContentValues(
|
||||
["home.regionalSection.paragraph1", "home.regionalSection.paragraph2"],
|
||||
locale
|
||||
);
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<ScrollReveal className="order-2 md:order-1">
|
||||
<SectionHeading
|
||||
eyebrow={t.home.regionalSection.eyebrow}
|
||||
title={t.home.regionalSection.title}
|
||||
/>
|
||||
<p className="mt-6 text-ink/75 leading-relaxed">{content["home.regionalSection.paragraph1"]}</p>
|
||||
<p className="mt-4 text-ink/75 leading-relaxed">{content["home.regionalSection.paragraph2"]}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
<LinkButton href="/imkerei" variant="secondary">
|
||||
{t.home.regionalSection.ctaAbout}
|
||||
</LinkButton>
|
||||
<LinkButton href="/kontakt" variant="ghost">
|
||||
{t.home.regionalSection.ctaPickup}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal className="order-1 md:order-2" delay={0.1}>
|
||||
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={t.home.regionalSection.eyebrow} />
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export async function Sustainability() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const content = await getContentValues(["home.sustainability.items"], locale);
|
||||
const items = parseListTitled(content["home.sustainability.items"]);
|
||||
return (
|
||||
<section className="py-24 bg-cream/60">
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
eyebrow={t.home.sustainability.eyebrow}
|
||||
title={t.home.sustainability.title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-14 grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{items.map((item, i) => (
|
||||
<ScrollReveal key={item.title} delay={i * 0.08}>
|
||||
<h3 className="text-lg text-forest">{item.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{item.text}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export async function BusinessTeaser() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const content = await getContentValues(["home.businessTeaser.paragraph"], locale);
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<ScrollReveal>
|
||||
<PlaceholderImage
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<SectionHeading
|
||||
eyebrow={t.home.businessTeaser.eyebrow}
|
||||
title={t.home.businessTeaser.title}
|
||||
/>
|
||||
<p className="mt-6 text-ink/75 leading-relaxed">{content["home.businessTeaser.paragraph"]}</p>
|
||||
<LinkButton href="/unternehmen" className="mt-8">
|
||||
{t.home.businessTeaser.cta}
|
||||
</LinkButton>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export async function Reviews() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const google = await fetchGoogleReviews();
|
||||
const placeId = process.env.GOOGLE_PLACE_ID;
|
||||
const mapsUrl = placeId ? `https://www.google.com/maps/place/?q=place_id:${placeId}` : null;
|
||||
|
||||
// Echte Google-Rezensionen sind Zitate realer Kund:innen und werden nie
|
||||
// uebersetzt oder umformuliert — nur die eigenen Fallback-Testimonials
|
||||
// (ohne konfigurierte Google-Anbindung) existieren in beiden Sprachen.
|
||||
const items = google && google.reviews.length > 0
|
||||
? google.reviews.map((r) => ({ quote: r.text, author: r.author, rating: r.rating }))
|
||||
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5 }));
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-forest text-cream">
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
eyebrow={t.home.reviews.eyebrow}
|
||||
title={t.home.reviews.title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
tone="inverted"
|
||||
/>
|
||||
{google?.rating ? (
|
||||
<p className="mt-4 text-center text-cream/60 text-sm">
|
||||
{t.home.reviews.googleReviewsCount(google.rating.toFixed(1), google.totalCount ?? 0)}
|
||||
</p>
|
||||
) : null}
|
||||
</ScrollReveal>
|
||||
<div className="mt-14 grid md:grid-cols-3 gap-10">
|
||||
{items.slice(0, 6).map((review, i) => (
|
||||
<ScrollReveal key={`${review.author}-${i}`} delay={i * 0.08}>
|
||||
<StarRating value={review.rating} />
|
||||
<span className="sr-only">{t.home.reviews.starsOutOf5(review.rating)}</span>
|
||||
<p className="mt-4 text-cream/85 leading-relaxed italic">
|
||||
„{review.quote}“
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-cream/70">{review.author}</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
{mapsUrl ? (
|
||||
<p className="mt-14 text-center">
|
||||
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className="text-sm text-cream/70 underline">
|
||||
{t.home.reviews.seeAllOnGoogle}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user