Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 80 additions & 78 deletions apps/web/src/app/(site)/[locale]/apps/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { notFound } from "next/navigation";
import { CATALOG, getEntry } from "@/lib/catalog";
import { DELIVERY_DAYS_HI, DELIVERY_DAYS_LO, HOSTING_MONTHLY } from "@ai-factory/pricing";
import { LOCALES, eur, getDict, isLocale, t, type Locale } from "@/lib/i18n";
import { IDEA_ICONS } from "@/components/AppIdeas";
import { Icon } from "@/components/LineIcon";
import "../../../../prototype.css";
import "../../../../detail.css";

export function generateStaticParams() {
return LOCALES.flatMap((locale) =>
Expand All @@ -29,108 +33,106 @@ export default async function AppDetail({
const hosting = HOSTING_MONTHLY[app.appType ?? "B"];

return (
<main className="wrap" style={{ padding: "40px 24px 72px" }}>
<p>
<Link href={`/${locale}#apps`} className="muted small" style={{ textDecoration: "none" }}>
{d.detail.back}
</Link>
</p>

<div className="detail-head">
<span className="detail-icon" aria-hidden>
{app.icon}
</span>
<h1 style={{ marginBottom: 0 }}>{app.name}</h1>
<span className="badge">{t(app.cat, locale)}</span>
<span className="badge badge-sample">{d.detail.sample}</span>
<main className="ad">
<div className="pp-band ad-band">
<div className="wrap">
<Link href={`/${locale}#apps`} className="ad-back">
{d.detail.back}
</Link>
<div className="ad-head">
<span className="ad-ico" aria-hidden>
<Icon name={IDEA_ICONS[app.slug] ?? "app"} />
</span>
<div>
<h1>{app.name}</h1>
<p className="ad-badges">
<span className="ad-badge">{t(app.cat, locale)}</span>
<span className="ad-badge ad-badge-sample">{d.detail.sample}</span>
</p>
</div>
</div>
{app.lede && <p className="ad-lede">{t(app.lede, locale)}</p>}
</div>
</div>

<div className="detail-cols" style={{ marginTop: 24 }}>
<div>
{app.lede && <p style={{ fontSize: 17 }}>{t(app.lede, locale)}</p>}

<div className="wrap ad-cols">
<div className="ad-main">
{app.why && (
<>
<h2 style={{ marginTop: 36 }}>{d.detail.why}</h2>
<div className="grid">
{app.why.map((w) => (
<div className="card" key={w.h.en}>
<section className="ad-sec">
<h2>{d.detail.why}</h2>
<div className="ad-why">
{app.why.map((w, i) => (
<div className="ad-card" key={w.h.en}>
<span className="ad-num">{i + 1}</span>
<h3>{t(w.h, locale)}</h3>
<p className="muted" style={{ fontSize: 14.5 }}>
{t(w.p, locale)}
</p>
<p>{t(w.p, locale)}</p>
</div>
))}
</div>
</>
</section>
)}

{/* Guarded — the appwerk prototype crashed on entries without market
{/* Guarded: the appwerk prototype crashed on entries without market
data (rechni bug); here the section simply doesn't render. */}
{app.market && app.market.length > 0 && (
<>
<h2 style={{ marginTop: 36 }}>{d.detail.market}</h2>
<div className="tbl-wrap">
<table>
<tbody>
{app.market.map((row) => (
<tr key={row[0].en}>
<td>{t(row[0], locale)}</td>
<td className="muted">{t(row[1], locale)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
<section className="ad-sec">
<h2>{d.detail.market}</h2>
<dl className="ad-market">
{app.market.map((row) => (
<div key={row[0].en}>
<dt>{t(row[0], locale)}</dt>
<dd>{t(row[1], locale)}</dd>
</div>
))}
</dl>
</section>
)}

{app.aud && (
<>
<h2 style={{ marginTop: 36 }}>{d.detail.aud}</h2>
<ul className="aud-list">
<section className="ad-sec">
<h2>{d.detail.aud}</h2>
<ul className="ad-aud">
{app.aud.map((a) => (
<li key={a.en}>
<span aria-hidden>{a.i}</span> {locale === "de" ? a.de : a.en}
<Icon name="check" className="pp-tick" />
{locale === "de" ? a.de : a.en}
</li>
))}
</ul>
</>
</section>
)}

<p className="note" style={{ marginTop: 28 }}>
{d.detail.estimateNote}
</p>
<p className="ad-note">{d.detail.estimateNote}</p>
</div>

<aside className="pricebox">
<div className="row">
<span className="muted">{d.detail.price}</span>
<strong>{eur(app.price!, locale)}</strong>
</div>
<div className="row">
<span className="muted">{d.detail.delivery}</span>
<strong>
{DELIVERY_DAYS_LO}–{DELIVERY_DAYS_HI} {d.detail.daysUnit}
</strong>
<aside className="ad-price">
{/* eslint-disable-next-line @next/next/no-img-element -- the home page's fixed idea pictures */}
<img src={`/home/idea-${app.slug}.webp`} alt="" width={360} height={200} />
<div className="ad-price-body">
<div className="ad-price-fig">
<span>{d.detail.price}</span>
<strong>{eur(app.price!, locale)}</strong>
</div>
<div className="ad-row">
<span>{d.detail.delivery}</span>
<strong>
{DELIVERY_DAYS_LO}–{DELIVERY_DAYS_HI} {d.detail.daysUnit}
</strong>
</div>
<div className="ad-row">
<span>{d.detail.hosting}</span>
<strong>
{app.appType === "A" ? d.detail.hostingA : `${eur(hosting, locale)}/${locale === "de" ? "Monat" : "month"}`}
</strong>
</div>
<div className="ad-row">
<span className="badge badge-type">{typeLabel}</span>
</div>
<Link className="btn btn-primary ad-cta" href={`/${locale}/checkout?app=${app.slug}`}>
{d.detail.cta}
<Icon name="arrow" className="pp-btn-ico" />
</Link>
</div>
<hr />
<div className="row">
<span className="badge badge-type">{typeLabel}</span>
</div>
<div className="row">
<span className="muted">{d.detail.hosting}</span>
<strong>
{app.appType === "A" ? d.detail.hostingA : `${eur(hosting, locale)}/${locale === "de" ? "Monat" : "month"}`}
</strong>
</div>
<hr />
<Link
className="btn btn-primary btn-block"
href={`/${locale}/checkout?app=${app.slug}`}
>
{d.detail.cta}
</Link>
</aside>
</div>
</main>
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/app/detail.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* An app idea (/[locale]/apps/[slug]) in the look of the home page: head band with the idea's
icon, the reasons and the market in cards, the price card with the idea's picture beside them.
Scoped under `.ad`; tokens come from globals.css. */

.ad { padding-bottom: 80px; }
.ad p { margin: 0; }
.ad .wrap { max-width: 1200px; }

/* ---------- Head ---------- */
.ad-band { padding: 28px 0 40px; }
.ad-back { display: inline-block; font-size: 0.88rem; font-weight: 600; color: var(--ink-soft); text-decoration: none; margin-bottom: 18px; }
.ad-back:hover { color: var(--accent); }
.ad-head { display: flex; align-items: center; gap: 18px; }
.ad-head h1 { font-size: clamp(2.1rem, 4vw, 3rem); letter-spacing: -0.04em; margin: 0 0 8px; }
.ad-ico { flex: none; width: 64px; height: 64px; border-radius: 18px; display: grid; place-items: center; background: #fff; color: var(--accent); box-shadow: 0 10px 26px rgba(15, 23, 42, 0.1); }
.ad-ico .ico { width: 30px; height: 30px; }
.ad-badges { display: flex; flex-wrap: wrap; gap: 8px; }
.ad-badge { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); background: #fff; border: 1px solid var(--border); border-radius: 99px; padding: 4px 11px; }
.ad-badge-sample { color: var(--warn-ink); background: var(--warn-bg); border-color: transparent; }
.ad .ad-lede { font-size: 1.12rem; color: var(--ink-soft); line-height: 1.6; max-width: 46em; margin-top: 22px; }

/* ---------- Body ---------- */
.ad-cols { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 32px; align-items: start; }
.ad-main { min-width: 0; }
.ad .ad-sec { margin-top: 40px; padding: 0; }
.ad .ad-sec:first-child { margin-top: 8px; }
.ad-sec h2 { font-size: 1.5rem; margin: 0 0 16px; }

.ad-why { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.ad-card { background: #fff; border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: var(--shadow); }
.ad-card h3 { font-size: 1rem; line-height: 1.3; margin: 12px 0 8px; }
.ad-card p { font-size: 0.88rem; color: var(--ink-soft); line-height: 1.55; }
.ad-num { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; background: linear-gradient(135deg, var(--accent), var(--violet)); color: #fff; font-size: 0.8rem; font-weight: 800; }

.ad-market { margin: 0; background: #fff; border: 1px solid var(--border); border-radius: 16px; box-shadow: var(--shadow); overflow: hidden; }
.ad-market div { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 16px; padding: 14px 20px; border-top: 1px solid var(--border); }
.ad-market div:first-child { border-top: 0; }
.ad-market dt { font-weight: 600; font-size: 0.92rem; }
.ad-market dd { margin: 0; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.55; }

.ad-aud { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.ad-aud li { display: flex; align-items: center; gap: 10px; font-size: 0.98rem; }
.ad .ad-note { margin-top: 32px; background: var(--tint); border-radius: 12px; padding: 12px 16px; font-size: 0.84rem; color: var(--ink-soft); line-height: 1.5; }

/* ---------- Price card ---------- */
.ad-price { position: sticky; top: 92px; margin-top: 8px; background: #fff; border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 30px 70px rgba(15, 23, 42, 0.1); }
.ad-price img { display: block; width: 100%; height: 180px; object-fit: cover; }
.ad-price-body { padding: 20px 22px 22px; display: grid; gap: 12px; }
.ad-price-fig { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; background: var(--tint); border-radius: 12px; padding: 14px 16px; }
.ad-price-fig span { font-size: 0.88rem; color: var(--ink-soft); }
.ad-price-fig strong { white-space: nowrap; font-family: var(--font-display); font-size: 1.7rem; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.ad-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 0.92rem; }
.ad-row span { color: var(--ink-soft); }
.ad-row strong { font-variant-numeric: tabular-nums; }
.ad-cta { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 4px; padding: 14px 20px; }

@media (max-width: 900px) {
.ad-cols { grid-template-columns: minmax(0, 1fr); }
.ad-price { position: static; order: -1; }
}
@media (max-width: 640px) {
.ad-head { align-items: flex-start; }
.ad-ico { width: 52px; height: 52px; border-radius: 14px; }
.ad-market div { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.ad-badge { border-radius: 8px; font-size: 0.68rem; }
}
2 changes: 1 addition & 1 deletion apps/web/src/components/AppIdeas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { eur, t, type Dict, type Locale } from "@/lib/i18n";
import { Icon } from "@/components/LineIcon";

/** One icon per app idea, matching what the app does. */
const IDEA_ICONS: Record<string, string> = {
export const IDEA_ICONS: Record<string, string> = {
formpilot: "doc",
mealgrid: "fork",
countbee: "box",
Expand Down
Loading