Produktseite ausgebaut: Unterseiten und Bildschirmfotos aus der App
Neue öffentliche Routen unter src/app/(marketing): Übersicht /funktionen, sechs Detailseiten (planung, baustelle, zeiterfassung, berichte, abrechnung, lotse), /sicherheit und /preise, gemeinsames Gerüst mit Kopf- und Fußzeile. scripts/marketing-shots.ts nimmt die Bilder direkt aus der laufenden App auf (Playwright über den installierten Chrome, Session wie im Smoke-Test, ohne Passworteingabe); 14 Aufnahmen in public/marketing ersetzen die gezeichneten Vorschauen. Plantafel: „Vorschläge" hängt jetzt als Fußzeile in der Auftragskarte statt frei zwischen zwei Karten zu stehen (neue Prop footer an OrderCard, Schlüssel unplanned.suggestionsFor). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CtaBand, PageHero, PointList, ShotFrame } from "@/components/marketing/blocks";
|
||||
import { FEATURE_PAGES, FEATURE_SLUGS, isFeatureSlug } from "@/lib/marketing/pages";
|
||||
|
||||
/** Detailseite eines Moduls — Inhalt aus `marketing.pages.<slug>`, Bilder aus public/marketing. */
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export function generateStaticParams() {
|
||||
return FEATURE_SLUGS.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
if (!isFeatureSlug(slug)) return {};
|
||||
const t = await getTranslations("marketing");
|
||||
return { title: t(`pages.${slug}.title`), description: t(`pages.${slug}.lead`) };
|
||||
}
|
||||
|
||||
export default async function FeatureDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
if (!isFeatureSlug(slug)) notFound();
|
||||
const page = FEATURE_PAGES[slug];
|
||||
const t = await getTranslations("marketing");
|
||||
const points = Array.from({ length: page.points }, (_, i) => t(`pages.${slug}.p${i + 1}`));
|
||||
const cards = Array.from({ length: page.cards }, (_, i) => ({
|
||||
title: t(`pages.${slug}.c${i + 1}title`),
|
||||
text: t(`pages.${slug}.c${i + 1}text`),
|
||||
}));
|
||||
const [first, second] = page.shots;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero eyebrow={t("nav.features")} title={t(`pages.${slug}.title`)} lead={t(`pages.${slug}.lead`)} />
|
||||
|
||||
<section aria-label={t(`pages.${slug}.title`)} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
|
||||
<div className={`grid gap-8 ${first.kind === "mobile" ? "lg:grid-cols-[1fr_0.6fr]" : "lg:grid-cols-[0.9fr_1.1fr]"} lg:items-center`}>
|
||||
<PointList points={points} />
|
||||
<ShotFrame shot={first} caption={t(`pages.${slug}.shot1`)} priority />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="detail-cards" className="border-y bg-card">
|
||||
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
|
||||
<h2 id="detail-cards" className="sr-only">
|
||||
{t(`pages.${slug}.title`)}
|
||||
</h2>
|
||||
<ul className="grid gap-4 md:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<li key={c.title} className="rounded-xl border bg-background p-5">
|
||||
<h3 className="font-heading text-[16px] font-semibold">{c.title}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{c.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{second && (
|
||||
<section aria-label={t(`pages.${slug}.shot2`)} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
<ShotFrame shot={second} caption={t(`pages.${slug}.shot2`)} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CtaBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowRight, Bot, CalendarRange, ClipboardList, FileInput, FileSignature, FolderOpen, MapPin, Package, Receipt, Siren, Timer, WifiOff, type LucideIcon } from "lucide-react";
|
||||
import { CtaBand, PageHero } from "@/components/marketing/blocks";
|
||||
import { FEATURE_SLUGS, type FeatureSlug } from "@/lib/marketing/pages";
|
||||
|
||||
/** Übersicht aller Funktionen mit Einstieg in die Detailseiten. */
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("marketing.overview");
|
||||
return { title: t("title"), description: t("lead") };
|
||||
}
|
||||
|
||||
/** Detailseite je Modul — die übrigen Module stehen als Kacheln ohne eigene Seite. */
|
||||
const DETAIL_ICON: Record<FeatureSlug, LucideIcon> = {
|
||||
planung: CalendarRange,
|
||||
baustelle: WifiOff,
|
||||
zeiterfassung: Timer,
|
||||
berichte: FileSignature,
|
||||
abrechnung: Receipt,
|
||||
lotse: Bot,
|
||||
};
|
||||
const DETAIL_FEATURE: Record<FeatureSlug, string> = {
|
||||
planung: "board",
|
||||
baustelle: "offline",
|
||||
zeiterfassung: "time",
|
||||
berichte: "reports",
|
||||
abrechnung: "billing",
|
||||
lotse: "lotse",
|
||||
};
|
||||
const MORE: { key: string; icon: LucideIcon }[] = [
|
||||
{ key: "orders", icon: ClipboardList },
|
||||
{ key: "live", icon: MapPin },
|
||||
{ key: "material", icon: Package },
|
||||
{ key: "emergency", icon: Siren },
|
||||
{ key: "documents", icon: FolderOpen },
|
||||
{ key: "imports", icon: FileInput },
|
||||
];
|
||||
|
||||
export default async function FeaturesOverviewPage() {
|
||||
const t = await getTranslations("marketing");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title={t("overview.title")} lead={t("overview.lead")} />
|
||||
|
||||
<section aria-label={t("overview.title")} className="mx-auto max-w-6xl px-4 pb-4 sm:px-6">
|
||||
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_SLUGS.map((slug) => {
|
||||
const Icon = DETAIL_ICON[slug];
|
||||
return (
|
||||
<li key={slug} className="shadow-card rounded-xl border bg-card p-5">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h2 className="mt-3 font-heading text-[17px] font-semibold">{t(`pages.${slug}.title`)}</h2>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`pages.${slug}.lead`)}</p>
|
||||
<Link href={`/funktionen/${slug}`} className="mt-3 inline-flex min-h-11 items-center gap-1.5 text-[14px] font-semibold text-[var(--ui-primary)]">
|
||||
{t("overview.more")}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="more-title" className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||
<h2 id="more-title" className="font-heading text-[22px] font-semibold">{t("features.title")}</h2>
|
||||
<ul className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{MORE.map(({ key, icon: Icon }) => (
|
||||
<li key={key} className="rounded-xl border bg-card p-5">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`features.${key}.title`)}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`features.${key}.text`)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-4 text-[13px] text-muted-foreground">
|
||||
{FEATURE_SLUGS.map((slug) => t(`features.${DETAIL_FEATURE[slug]}.title`)).join(" · ")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<CtaBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { MarketingShell } from "@/components/marketing/shell";
|
||||
|
||||
/** Öffentlicher Bereich (Produktseiten) — ohne Session, siehe PUBLIC_PATHS in src/proxy.ts. */
|
||||
export default function MarketingLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <MarketingShell>{children}</MarketingShell>;
|
||||
}
|
||||
@@ -11,8 +11,8 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Produktseite. Angemeldete Nutzer sehen sie nicht: Feldrollen → /m,
|
||||
* Backoffice → /dashboard (ARCHITEKTUR §5). `/` ist deshalb in PUBLIC_PATHS (src/proxy.ts).
|
||||
* Öffentliche Startseite. Angemeldete Nutzer sehen sie nicht: Feldrollen → /m,
|
||||
* Backoffice → /dashboard (ARCHITEKTUR §5). Kopf- und Fußzeile liefert `(marketing)/layout.tsx`.
|
||||
*/
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowRight, CheckCircle2, Info } from "lucide-react";
|
||||
import { PageHero } from "@/components/marketing/blocks";
|
||||
|
||||
/** Testphase und Preise — bewusst ohne erfundene Zahlen, solange das Preismodell offen ist. */
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("marketing.pricingPage");
|
||||
return { title: t("title"), description: t("lead") };
|
||||
}
|
||||
|
||||
export default async function PricingPage() {
|
||||
const t = await getTranslations("marketing");
|
||||
const trialPoints = (["t1", "t2", "t3"] as const).map((k) => t(`pricingPage.${k}`));
|
||||
const fullPoints = (["f1", "f2", "f3"] as const).map((k) => t(`pricingPage.${k}`));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero eyebrow={t("trial.eyebrow")} title={t("pricingPage.title")} lead={t("pricingPage.lead")} />
|
||||
|
||||
<section aria-label={t("pricingPage.title")} className="mx-auto max-w-5xl px-4 pb-10 sm:px-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<article className="shadow-card rounded-2xl border border-l-4 border-l-[var(--ui-accent)] bg-card p-6">
|
||||
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.trialTitle")}</h2>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.trialText")}</p>
|
||||
<ul className="mt-4 grid gap-2.5">
|
||||
{trialPoints.map((p) => (
|
||||
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ok)]" aria-hidden />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/testen"
|
||||
className="mt-5 inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95"
|
||||
>
|
||||
{t("trial.cta")}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</article>
|
||||
|
||||
<article className="shadow-card rounded-2xl border bg-card p-6">
|
||||
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.fullTitle")}</h2>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.fullText")}</p>
|
||||
<ul className="mt-4 grid gap-2.5">
|
||||
{fullPoints.map((p) => (
|
||||
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-4 py-3 text-[14px]">
|
||||
<Info className="mt-0.5 size-[18px] shrink-0 text-[var(--info)]" aria-hidden />
|
||||
{t("pricingPage.note")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="pricing-faq" className="border-t bg-card">
|
||||
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6">
|
||||
<h2 id="pricing-faq" className="font-heading text-[24px] font-semibold">{t("faq.title")}</h2>
|
||||
<div className="mt-5 divide-y rounded-xl border bg-background">
|
||||
{(["q1", "q4", "q5", "q7", "q8"] as const).map((key) => (
|
||||
<details key={key} className="group px-4">
|
||||
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-3 text-[15px] font-semibold">
|
||||
{t(`faq.${key}.q`)}
|
||||
<span aria-hidden className="text-[var(--ui-primary)] transition-transform group-open:rotate-45">+</span>
|
||||
</summary>
|
||||
<p className="pb-4 text-[14.5px] leading-relaxed text-muted-foreground">{t(`faq.${key}.a`)}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Download, EyeOff, KeyRound, Layers, ScrollText, UsersRound, type LucideIcon } from "lucide-react";
|
||||
import { CtaBand, PageHero, ShotFrame } from "@/components/marketing/blocks";
|
||||
|
||||
/** Sicherheit und Datenschutz — Mandantentrennung, Rollen, Protokoll, Export. */
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("marketing.securityPage");
|
||||
return { title: t("title"), description: t("lead") };
|
||||
}
|
||||
|
||||
const ITEMS: { key: string; icon: LucideIcon }[] = [
|
||||
{ key: "item1", icon: Layers },
|
||||
{ key: "item2", icon: UsersRound },
|
||||
{ key: "item3", icon: KeyRound },
|
||||
{ key: "item4", icon: ScrollText },
|
||||
{ key: "item5", icon: Download },
|
||||
{ key: "item6", icon: EyeOff },
|
||||
];
|
||||
|
||||
export default async function SecurityPage() {
|
||||
const t = await getTranslations("marketing");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero eyebrow={t("security.eyebrow")} title={t("securityPage.title")} lead={t("securityPage.lead")} />
|
||||
|
||||
<section aria-label={t("securityPage.title")} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{ITEMS.map(({ key, icon: Icon }) => (
|
||||
<li key={key} className="shadow-card rounded-xl border bg-card p-5">
|
||||
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h2 className="mt-3 font-heading text-[16px] font-semibold">{t(`security.${key}.title`)}</h2>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`security.${key}.text`)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="security-detail" className="border-y bg-card">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
|
||||
<div>
|
||||
<h2 id="security-detail" className="font-heading text-[24px] font-semibold">{t("securityPage.detailTitle")}</h2>
|
||||
<dl className="mt-5 grid gap-4">
|
||||
{(["d1", "d2", "d3"] as const).map((k) => (
|
||||
<div key={k}>
|
||||
<dt className="font-heading text-[16px] font-semibold">{t(`securityPage.${k}title`)}</dt>
|
||||
<dd className="mt-1 text-[14px] leading-relaxed text-muted-foreground">{t(`securityPage.${k}text`)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<ShotFrame shot={{ src: "audit", kind: "desktop" }} caption={t("securityPage.shot1")} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label={t("securityPage.shot2")} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
<ShotFrame shot={{ src: "teams", kind: "desktop" }} caption={t("securityPage.shot2")} />
|
||||
</section>
|
||||
|
||||
<CtaBand />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user