Skip to content

Commit 80dcc0b

Browse files
B2JK-Industryclaude
andcommitted
feat(ui): PR-P G-02 + G-06 + G-24 + G-32 — FAQ, Kontakt, AI building fix
G-02 — `/faq` + `/kontakt` real pages (PO Decision A). Footer "FAQ — wkrótce / Kontakt — wkrótce" disabled placeholders across 4 locales were a months-long credibility paper-cut. Replaced with real routes: - `/faq` — server component, 6 Q&A items per locale via `dict.faq.items` (how it works / no real money / GDPR-K consent / data protection / demo mode / PKO XP partnership). Items render as accordion `<details>` with first one open. Bottom links to /kontakt for anything else. - `/kontakt` — server header + ContactForm primitive (variant= "general"). 5 topic options (general / bug / school / press / privacy). - `components/contact-form.tsx` (NEW) — reusable form. Variants general / schools / press; schools adds a school-name field that prefixes the message body. POST → /api/contact, success state flips to a thank-you card with role=status aria-live=polite, error state shows inline alert. - `app/api/contact/route.ts` (NEW) — Zod-validated POST, rate- limited 3 / 60s per IP. Body: name + email + topic + message. Always logs to console for ops triage; if `CONTACT_WEBHOOK_URL` env var is set, also POSTs Slack/Discord-compatible payload. Webhook failure does NOT bubble to the client (message already logged). Footer HELP_LABELS now plain "FAQ" / "Kontakt" / locale equivalent across 4 langs; the disabled `<span aria-disabled>` placeholders were swapped for real `<FooterLink>` to /faq + /kontakt. G-06 — ContactForm on `/dla-szkol`. B2B conversion path was missing. Page footer CTA repeated demo + signup but the schools team had no way to ASK before signing up. Added a final section "Kontakt" with `<ContactForm variant="schools">` that prefixes the school name into the message body server-side. Same /api/contact backend. G-24 — footer compare-loans link cleanup (post F-01). HELP_LABELS.compareLoans link migrated from the deprecated `/loans/compare` (still 308-redirects, but the canonical surface is inline) to `/miasto#hypoteka` so the player lands directly at the mortgage panel anchor. G-32 — AI buildings washed out on CityScene preview. User feedback (screenshot 7:50): with the G-27 sunset backdrop shipped, AI envelope buildings on dashboard + landing read as a muted grey strip vs. the vivid evergreen BUILDING_PLAN ones. Root cause: ConstructionSlot wrappers lacked the F-02 `data-building-body` opt-out, AND several AI palette hexes (ec4899 / f97316 / 8b5cf6 / 14b8a6 / f43f5e / 831843 / 164e63 / 581c87 / 7c2d12 / fb7185 / 1e1b4b) weren't in the per-hex passthrough list, so the broad mass retints + parent saturate filter caught them. Fix: - Both ConstructionSlot wrappers (interactive + non-interactive) now carry `data-building-body="true"`. - Added 11 new per-hex passthrough rules in globals.css covering the full AI_ROOF / AI_BODY / AI_WINDOW palette, so even a procedurally-hashed body fill renders in its draw colour. Validation: - pnpm typecheck → 0 errors Tooling note: 3 FAQ entries used German low-9 + ASCII straight quote („Quiz finansowy") which TS parsed mid-string. Replaced with bare label words; localized typography is a Pass-11 polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 19542e5 commit 80dcc0b

11 files changed

Lines changed: 638 additions & 17 deletions

File tree

app/api/contact/route.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { NextRequest } from "next/server";
2+
import { z } from "zod";
3+
import { rateLimit } from "@/lib/rate-limit";
4+
import { clientIp } from "@/lib/client-ip";
5+
6+
/* G-02 — `/api/contact` POST endpoint.
7+
*
8+
* Sink for the ContactForm primitive. Rate-limited 3 / 60s per IP
9+
* to keep form-spam at bay without blocking legitimate retries.
10+
* Body: name + email + topic + message. Validated via zod.
11+
*
12+
* Sink behavior: log to console + (when CONTACT_WEBHOOK_URL is
13+
* configured) POST to the configured Slack/Discord/email webhook.
14+
* Both paths are best-effort — a webhook 5xx still returns ok:true
15+
* to the client because the message has already been logged.
16+
*
17+
* No DB write — submissions are intentionally ephemeral. The
18+
* production sink is whatever endpoint CONTACT_WEBHOOK_URL points
19+
* at (PR-P-2 ships with no default URL set; ops adds the secret
20+
* post-deploy).
21+
*/
22+
23+
const BodySchema = z.object({
24+
name: z.string().min(1).max(120),
25+
email: z.string().email().max(200),
26+
topic: z.enum(["general", "bug", "school", "press", "privacy"]),
27+
message: z.string().min(10).max(5000),
28+
});
29+
30+
const RATE_LIMIT_PER_WINDOW = 3;
31+
const RATE_LIMIT_WINDOW_MS = 60_000;
32+
33+
export async function POST(req: NextRequest) {
34+
const ip = clientIp(req);
35+
const rl = await rateLimit(
36+
`contact:${ip}`,
37+
RATE_LIMIT_PER_WINDOW,
38+
RATE_LIMIT_WINDOW_MS,
39+
);
40+
if (!rl.ok) {
41+
return Response.json(
42+
{ ok: false, error: "rate-limited", resetAt: rl.resetAt },
43+
{ status: 429 },
44+
);
45+
}
46+
47+
let parsed;
48+
try {
49+
const body = await req.json();
50+
parsed = BodySchema.safeParse(body);
51+
} catch {
52+
return Response.json({ ok: false, error: "invalid-json" }, { status: 400 });
53+
}
54+
if (!parsed.success) {
55+
return Response.json(
56+
{ ok: false, error: parsed.error.issues[0]?.message ?? "invalid" },
57+
{ status: 400 },
58+
);
59+
}
60+
const { name, email, topic, message } = parsed.data;
61+
62+
// Always log; the build pipeline scrubs prod console output by default,
63+
// and the runtime captures it in Vercel logs for ops triage.
64+
console.log("[contact] submission", { topic, email, name, ip });
65+
66+
const webhookUrl = process.env.CONTACT_WEBHOOK_URL;
67+
if (webhookUrl) {
68+
try {
69+
await fetch(webhookUrl, {
70+
method: "POST",
71+
headers: { "Content-Type": "application/json" },
72+
body: JSON.stringify({
73+
text: `📨 [${topic}] from ${name} <${email}>\n\n${message}`,
74+
// Slack-compatible payload; Discord ignores extra keys.
75+
name,
76+
email,
77+
topic,
78+
message,
79+
}),
80+
});
81+
} catch (err) {
82+
// Webhook failure must not surface as a user-visible error —
83+
// the message is already logged above. Ops surfaces webhook
84+
// health separately.
85+
console.error("[contact] webhook delivery failed", err);
86+
}
87+
}
88+
89+
return Response.json({ ok: true });
90+
}

app/faq/page.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Link from "next/link";
2+
import { dictFor } from "@/lib/i18n";
3+
import { getLang } from "@/lib/i18n-server";
4+
5+
/* G-02 — `/faq` informational page.
6+
*
7+
* Pre-PR-P the footer carried "FAQ — wkrótce / brzy / soon" disabled
8+
* placeholders for 4 locales. This page replaces them with 6 real
9+
* Q&A items per locale (`dict.faq.items`). Server component so the
10+
* locale picks up correctly through `xp_lang` cookie.
11+
*/
12+
export const dynamic = "force-dynamic";
13+
14+
export default async function FaqPage() {
15+
const lang = await getLang();
16+
const t = dictFor(lang).faq;
17+
return (
18+
<main className="max-w-3xl mx-auto flex flex-col gap-6 animate-slide-up">
19+
<header className="flex flex-col gap-2">
20+
<h1 className="t-h2 text-[var(--accent)]">{t.title}</h1>
21+
<p className="text-[var(--ink-muted)]">{t.intro}</p>
22+
</header>
23+
<section className="flex flex-col gap-3">
24+
{t.items.map((item, i) => (
25+
<details
26+
key={i}
27+
className="card p-5 group"
28+
// First item open by default for scannability.
29+
{...(i === 0 ? { open: true } : {})}
30+
>
31+
<summary className="cursor-pointer flex items-baseline justify-between gap-3 list-none">
32+
<span className="font-semibold text-[var(--foreground)]">
33+
{item.q}
34+
</span>
35+
<span
36+
aria-hidden
37+
className="text-[var(--accent)] transition-transform group-open:rotate-45 select-none text-xl leading-none"
38+
>
39+
+
40+
</span>
41+
</summary>
42+
<p className="mt-3 text-[var(--ink-muted)] leading-relaxed whitespace-pre-line">
43+
{item.a}
44+
</p>
45+
</details>
46+
))}
47+
</section>
48+
<p className="text-sm text-[var(--ink-muted)]">
49+
{t.contactCta}{" "}
50+
<Link href="/kontakt" className="text-[var(--accent)] underline">
51+
{t.contactLinkLabel}
52+
</Link>
53+
.
54+
</p>
55+
</main>
56+
);
57+
}

app/globals.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,23 @@ body {
485485
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#fbbf24"] { fill: #fbbf24; }
486486
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#a21caf"] { fill: #a21caf; }
487487
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#c026d3"] { fill: #c026d3; }
488+
/* G-32 — AI envelope palette (AI_ROOF_PALETTE + AI_BODY_PALETTE +
489+
* AI_WINDOW_PRIMARY/SECONDARY in city-scene.tsx). Each AI building's
490+
* draw assigns colours via `hashId(envelope.id) % palette.length`,
491+
* so any of these can land on a body rect. Add explicit passthroughs
492+
* so the broad mass `[fill="X"]` retints don't catch them on
493+
* /games hub or the dashboard preview. */
494+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#ec4899"] { fill: #ec4899; }
495+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#f97316"] { fill: #f97316; }
496+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#8b5cf6"] { fill: #8b5cf6; }
497+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#14b8a6"] { fill: #14b8a6; }
498+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#f43f5e"] { fill: #f43f5e; }
499+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#831843"] { fill: #831843; }
500+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#164e63"] { fill: #164e63; }
501+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#581c87"] { fill: #581c87; }
502+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#7c2d12"] { fill: #7c2d12; }
503+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#fb7185"] { fill: #fb7185; }
504+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#1e1b4b"] { fill: #1e1b4b; }
488505

489506
/* R-06 — visible custom slider thumb for the mortgage panel. The
490507
* default WebKit/Firefox thumb is barely 12 px and disappears against

app/kontakt/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { dictFor } from "@/lib/i18n";
2+
import { getLang } from "@/lib/i18n-server";
3+
import { ContactForm } from "@/components/contact-form";
4+
5+
/* G-02 — `/kontakt` page. Server component for header + intro,
6+
* delegates the form to the ContactForm client primitive. */
7+
export const dynamic = "force-dynamic";
8+
9+
export default async function KontaktPage() {
10+
const lang = await getLang();
11+
const t = dictFor(lang).kontakt;
12+
return (
13+
<main className="max-w-2xl mx-auto flex flex-col gap-6 animate-slide-up">
14+
<header className="flex flex-col gap-2">
15+
<h1 className="t-h2 text-[var(--accent)]">{t.title}</h1>
16+
<p className="text-[var(--ink-muted)]">{t.intro}</p>
17+
</header>
18+
<ContactForm variant="general" lang={lang} dict={t} />
19+
</main>
20+
);
21+
}

components/city-scene.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,12 @@ function ConstructionSlot({
12321232
)}
12331233
</g>
12341234
);
1235-
if (!interactive) return <g>{node}</g>;
1235+
// G-32 — `data-building-body="true"` opts the AI slot into the
1236+
// F-02 exception (inverse filter + per-hex passthrough). Without
1237+
// it, AI envelope buildings rendered washed-grey on the sunset
1238+
// backdrop because the broad `[fill="X"]` retints + `.city-scene-root`
1239+
// saturate filter caught the procedurally-generated palette.
1240+
if (!interactive) return <g data-building-body="true">{node}</g>;
12361241
const href = aiGame ? `/games/ai/${aiGame.id}` : "/sin-slavy";
12371242
const label = aiGame
12381243
? `Wyzwanie AI dnia — ${aiGame.title}`
@@ -1246,7 +1251,11 @@ function ConstructionSlot({
12461251
: "Wyzwanie AI dnia · w budowie";
12471252
return (
12481253
<Link href={href} aria-label={label}>
1249-
<g className="building-link" data-powered={live}>
1254+
<g
1255+
className="building-link"
1256+
data-building-body="true"
1257+
data-powered={live}
1258+
>
12501259
<title>{title}</title>
12511260
<rect
12521261
x={plan.x - 6}

0 commit comments

Comments
 (0)