diff --git a/apps/web/src/app/(site)/[locale]/p/[id]/page.tsx b/apps/web/src/app/(site)/[locale]/p/[id]/page.tsx index 2a95bb9..5b88d29 100644 --- a/apps/web/src/app/(site)/[locale]/p/[id]/page.tsx +++ b/apps/web/src/app/(site)/[locale]/p/[id]/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { PrototypeView } from "@/components/PrototypeView"; import { getDict, isLocale, type Locale } from "@/lib/i18n"; +import "../../../../prototype.css"; +import "../../../../share.css"; // A shared preview, kept out of search. export const metadata: Metadata = { robots: { index: false, follow: false } }; @@ -17,9 +19,15 @@ export default async function PrototypeSharePage({ const d = getDict(locale); return ( -
-

{d.proto.shareTitle}

- +
+
+
+

{d.proto.shareTitle}

+
+
+
+ +
); } diff --git a/apps/web/src/app/share.css b/apps/web/src/app/share.css new file mode 100644 index 0000000..f5a3398 --- /dev/null +++ b/apps/web/src/app/share.css @@ -0,0 +1,82 @@ +/* The share page (/[locale]/p/[id]): the built prototype, or where its build is. + Builds on prototype.css (band, inputs, buttons); everything here is scoped under `.sh`. */ + +.sh { padding-bottom: 80px; } +.sh p { margin: 0; } +.sh .wrap { max-width: 1200px; } +.sh-band { padding: 36px 0 84px; } +.sh-band h1 { font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -0.035em; margin: 0; } +.sh-body { margin-top: -56px; } + +/* ---------- Status: waiting, building, failed, expired ---------- */ +.sh-status { max-width: 620px; margin: 0 auto; background: #fff; border: 1px solid var(--border); border-radius: 20px; padding: 32px; box-shadow: 0 30px 70px rgba(15, 23, 42, 0.1); display: grid; gap: 14px; justify-items: start; } +.sh-status h2 { font-size: 1.3rem; margin: 0; } +.sh-status > p { color: var(--ink-soft); font-size: 0.95rem; line-height: 1.55; } +.sh-status-ico { width: 48px; height: 48px; border-radius: 14px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); } +.sh-status-bad .sh-status-ico { background: #fef2f2; color: #b91c1c; } +.sh-status .btn { margin-top: 4px; } +.sh-meta { font-size: 0.85rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.sh-bar { width: 100%; height: 8px; border-radius: 99px; background: #e2e8f0; overflow: hidden; } +.sh-bar span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--violet)); transition: width 1s linear; } + +.sh-steps { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 12px; width: 100%; } +.sh-steps li { display: flex; gap: 12px; align-items: flex-start; font-size: 0.95rem; color: var(--ink-soft); } +.sh-steps small { display: block; font-size: 0.84rem; font-weight: 400; color: var(--ink-soft); margin-top: 2px; line-height: 1.45; } +.sh-dot { flex: none; width: 22px; height: 22px; border-radius: 50%; border: 2px solid #cbd5e1; display: grid; place-items: center; font-size: 0.7rem; font-weight: 800; color: #fff; margin-top: 1px; } +.sh-steps li[data-state="done"] { color: var(--ink); } +.sh-steps li[data-state="done"] .sh-dot { background: var(--valid); border-color: var(--valid); } +.sh-steps li[data-state="now"] { color: var(--ink); font-weight: 600; } +.sh-steps li[data-state="now"] .sh-dot { border-color: var(--accent); box-shadow: inset 0 0 0 4px #fff; background: var(--accent); animation: sh-pulse 1.4s ease-in-out infinite; } +.sh-steps li[data-state="failed"] .sh-dot { background: #dc2626; border-color: #dc2626; } +.sh-steps li[data-state="todo"] { opacity: 0.55; } +@keyframes sh-pulse { 50% { box-shadow: inset 0 0 0 4px #fff, 0 0 0 5px rgba(29, 78, 216, 0.15); } } +@media (prefers-reduced-motion: reduce) { .sh-steps li[data-state="now"] .sh-dot { animation: none; } } + +/* ---------- Ready: toolbar, then the prototype ---------- */ +.sh-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px 24px; background: #fff; border: 1px solid var(--border); border-radius: 18px; padding: 18px 22px; box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08); margin-bottom: 20px; } +.sh-toolbar > p { flex: 1 1 320px; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.5; max-width: 560px; } +.sh-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.sh .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; } +.sh-ghost { background: #fff; color: var(--ink); border-color: var(--border); } +.sh-ghost:hover { border-color: #c7d2fe; color: var(--accent-hover); } + +.sh-frame { border: 1px solid var(--border); border-radius: 18px; overflow: hidden; box-shadow: 0 30px 70px rgba(15, 23, 42, 0.12); background: #fff; } +.sh-frame iframe { display: block; width: 100%; height: 80vh; border: 0; } +.sh .sh-credit { font-size: 0.8rem; color: var(--ink-soft); margin-top: 10px; } +.sh-credit-center { text-align: center; } + +/* Campaign parts */ +.sh-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.sh-tab { padding: 9px 16px; border: 1px solid var(--border); border-radius: 99px; background: #fff; font: 600 0.9rem var(--font-body); color: var(--ink-soft); cursor: pointer; } +.sh-tab:hover { border-color: #c7d2fe; color: var(--ink); } +.sh-tab[aria-selected="true"] { background: var(--accent); border-color: var(--accent); color: #fff; } +.sh-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.sh-tabs + .sh-status { margin: 0; } + +/* ---------- The one free change ---------- */ +.sh-revise { max-width: 680px; margin-top: 24px; background: #fff; border: 1px solid var(--border); border-radius: 18px; padding: 22px 24px; box-shadow: var(--shadow); display: grid; gap: 14px; } +.sh-revise-head { display: flex; gap: 14px; align-items: flex-start; } +.sh-revise-head h2 { font-size: 1.1rem; margin: 0 0 4px; } +.sh-revise-head p, .sh-revise > p { font-size: 0.9rem; color: var(--ink-soft); line-height: 1.5; } +.sh-revise-ico { flex: none; width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); } +.sh-revise-ico .ico { width: 20px; height: 20px; } +.sh-revise-text { min-height: 96px; } +.sh-revise .pp-submit { justify-self: start; } +.sh-inline { display: flex; flex-wrap: wrap; gap: 10px; } +.sh-inline .pp-input { flex: 1 1 240px; width: auto; } +.sh-sent { background: #ecfdf3; color: #166534; border-radius: 12px; padding: 12px 14px; font-size: 0.92rem; } +.sh .sh-note { font-size: 0.88rem; color: var(--ink-soft); margin-top: 16px; } + +/* Owner panels under a campaign (landing page, report, ads): same card as the rest. */ +.sh .card { margin-top: 24px !important; border-radius: 18px; padding: 22px 24px; box-shadow: var(--shadow); } + +@media (max-width: 640px) { + .sh-band { padding: 26px 0 72px; } + .sh-status { padding: 24px 20px; } + .sh-toolbar { padding: 16px; } + .sh-actions, .sh-actions .btn { width: 100%; } + .sh-actions .btn { padding: 13px 14px; font-size: 0.95rem; } + .sh-frame iframe { height: 72vh; } + .sh-revise { padding: 18px; } + .sh-revise .pp-submit, .sh-inline .pp-submit { width: 100%; } +} diff --git a/apps/web/src/components/PrototypeView.tsx b/apps/web/src/components/PrototypeView.tsx index 134bedd..19c9906 100644 --- a/apps/web/src/components/PrototypeView.tsx +++ b/apps/web/src/components/PrototypeView.tsx @@ -7,6 +7,7 @@ import { setToken, useToken } from "@/lib/token"; import { trackOnce } from "@/lib/analytics"; import { forget, rename } from "@/lib/history"; import { eur, type Dict, type Locale } from "@/lib/i18n"; +import { Icon } from "./LineIcon"; interface Meta { kind?: string; @@ -100,6 +101,38 @@ function progress(steps: readonly Step[], stage: StepKey | null, inStage: number const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +/** One card for every state that is not the finished prototype: waiting, building, failed, expired. */ +function StatusCard({ icon, title, tone, children }: { icon: string; title: string; tone?: "bad"; children?: React.ReactNode }) { + return ( +
+ + + +

{title}

+ {children} +
+ ); +} + +/** The build steps as a list: done, the one it is on (with what it does), still to come. */ +function StepList({ rows }: { rows: { key: string; label: string; state: "done" | "now" | "todo" | "failed"; note?: string | null }[] }) { + return ( +
    + {rows.map((r) => ( +
  1. + + + {r.label} + {r.note && {r.note}} + +
  2. + ))} +
+ ); +} + export function PrototypeView({ id, locale, d, embedded = false }: { id: string; locale: Locale; d: Dict; embedded?: boolean }) { const p = d.proto; const [meta, setMeta] = useState(null); @@ -156,14 +189,13 @@ export function PrototypeView({ id, locale, d, embedded = false }: { id: string; return () => clearInterval(t); }, [building]); - if (!meta) return

{p.building}

; + if (!meta) return ; if (meta.status === "waiting") { return ( -
-

{p.email.waitingTitle}

-

{p.email.waiting}

-
+ +

{p.email.waiting}

+
); } @@ -175,10 +207,9 @@ export function PrototypeView({ id, locale, d, embedded = false }: { id: string; const since = Math.max(0, Math.floor((now - (stageSince?.at ?? now)) / 1000)); return ( -
-

{p.revise.working}

-

{p.revise.elapsed.replace("{t}", clock(since))}

-
+ +

{p.revise.elapsed.replace("{t}", clock(since))}

+
); } @@ -199,88 +230,47 @@ export function PrototypeView({ id, locale, d, embedded = false }: { id: string; const at = key ? steps.findIndex((s) => s.key === key) : -1; return ( -
-

{p.building}

-
-
+ +
+
-

+

{pct}% · {(codex ? p.codexElapsed : p.elapsed).replace("{t}", clock(elapsed))}

-
    - {steps.map((s, i) => { + { const state = at < 0 ? "todo" : i < at ? "done" : i === at ? "now" : "todo"; - - return ( -
  1. - - - {names[s.key as keyof typeof names]} - {state === "now" && stage && ( - {stage} - )} - -
  2. - ); + return { key: s.key, label: names[s.key as keyof typeof names], state, note: state === "now" ? stage : null }; })} -
-
+ /> + + ); + } + if (meta.status === "expired") { + return ( + + {p.another} + ); } - if (meta.status === "expired") return

{p.expired}

; if (meta.status === "failed") { return ( -
-

- {meta.error?.startsWith("site-unreadable: ") - ? p.siteUnreadable.replace("{domain}", meta.error.slice("site-unreadable: ".length)) - : p.failed} -

- {p.another} -
+ + {meta.error?.startsWith("site-unreadable: ") && ( +

{p.siteUnreadable.replace("{domain}", meta.error.slice("site-unreadable: ".length))}

+ )} + {p.another} +
); } return (
- {!embedded &&
-

+ {!embedded &&

+

{meta.bought ? p.boughtSite : meta.site_price_eur ? p.buySiteHint : p.shareHint}

-
+
{meta.bought && meta.live_url && ( {p.openLive} )} @@ -290,11 +280,11 @@ export function PrototypeView({ id, locale, d, embedded = false }: { id: string; )} {!meta.bought && ( - trackOnce(`make-real-${id}`, "cta_click", { cta: "prototype_make_real", kind: meta.kind ?? null })}> + trackOnce(`make-real-${id}`, "cta_click", { cta: "prototype_make_real", kind: meta.kind ?? null })}> {p.makeReal[(meta.kind ?? "site") as keyof typeof p.makeReal] ?? p.makeReal.site} )} - trackOnce(`another-${id}`, "cta_click", { cta: "prototype_another" })}> + trackOnce(`another-${id}`, "cta_click", { cta: "prototype_another" })}> {p.another}
@@ -313,7 +303,7 @@ export function PrototypeView({ id, locale, d, embedded = false }: { id: string;
{/* Under the phone, not inside it: a credit belongs to the page, not to the mockup. */} {meta.photo_credit && ( -

+

Foto: {meta.photo_credit} {meta.photo_credit_url && ( <> @@ -329,16 +319,17 @@ export function PrototypeView({ id, locale, d, embedded = false }: { id: string;

) : (
-