Skip to content

Commit 0d266a6

Browse files
B2JK-Industryclaude
andcommitted
feat(ux): PR-O Pass-9 — F-01..F-05 unified
Five-issue bundle from `_fe-fix-prompt-pass9.md`. Order picked foundation-first (F-05 → F-04 → F-02 → F-03 → F-01) so each later fix builds on the structural primitives the earlier ones add. F-05 — unify building tile on / and /miasto. Homepage hero rendered pitched-roof silhouettes that grew with level (`0.7 + 0.05 * level`). /miasto rendered the SAME slots as flat rects with a 14-px roof BAND, no level scaling. Same data, two looks. Extracted `BuildingTile` + `EmptyBuildingTile` into `components/building-tile.tsx`; both surfaces now mount them. The /miasto manager keeps interactivity by passing optional onClick + isSelected + ariaLabel props — visual rendering identical. F-04 — BuildingStackBadge + MedalRing primitives. Dashboard's two ring widgets (CityLevelCard + XP hero) both used `--accent` and showed an unlabelled number. Players reported "tier confusion" — they couldn't tell that 3 (city level) and 4 (XP tier) measured different things. Fixed: city-level ring keeps `--accent` (navy) + new `BuildingStackBadge` icon + "Stupeň města" label; XP ring switches to `--sales` (orange) + new `MedalRing` icon + "Tvůj tier" label. Both rings now carry a tooltip explaining the metric. Added `yourTier` + `yourTierTooltip` keys + `levelTooltip` key across all 4 locale dicts. F-02 — solid coloured buildings on /games (root-cause fix). The `.city-scene-root` `saturate(0.35) brightness(1.55)` filter PLUS the broad `[fill="#0f172a"]` etc retints in globals.css washed every BUILDING_PLAN body to identical muted grey. Buildings on /games read as outline-only wireframes, hiding the carefully-drawn powered/unpowered window neons. Each building wrapper now carries `data-building-body="true"`. Two new CSS rules: 1. inverse filter (`contrast 1.087 brightness 0.645 saturate 2.857`) cancels the parent filter on building scope only 2. per-hex passthrough rules re-set every retinted hex to itself inside `[data-building-body]` (specificity 0,2,1 beats 0,1,1) Sky/ground/atmosphere stay tinted; only foreground buildings render in their original draw-function colours. F-03 — Brzy fáze 2 filter. 3 of 4 loan-typed cards in COMING_SOON_TILES (leasing, kredyt obrotowy, kredyt konsumencki) are already shipped via LoanComparison's `LOAN_CONFIGS`. Removed those entries; only `inwestycyjny` (Tier-7 secondary market — not yet implemented) and the four non-loan items (parent panel, class mode, P2P trade, PKO Junior mirror) remain. Replaced the `🔒` chip with a `Phase 2` badge that sets honest expectation without committing to a calendar date — PO can later swap to Q3/Q4 2026 strings. F-01 — /loans/compare → inline LoanComparison in Hypotéka panel. Largest scope. - MortgageCard interior was a mortgage-only quote+slider+take flow with a "Compare all loans →" link to /loans/compare. Replaced the entire open-state body with `<LoanComparison variant="inline" />` — players see all 4 products inline now, mortgage included. Removed ~250 lines of bespoke quote logic (debounce + AbortController + maxPrincipal + noCapacity branch + MORTGAGE_PRINCIPAL_MIN/MAX/STEP + MORTGAGE_ERROR_COPY + translateError); LoanComparison owns those concerns now. - Added `loanComparison` field to WattCityBootstrap; /miasto/page.tsx server-computes rows via `compareLoans(principal, term, state)` using ?principal=&term= URL params (mirrors the legacy /loans/compare query semantics so the redirect preserves user state). - LoanComparison gained an `onLoanTaken` callback prop so inline hosts can refresh parent state after a successful take. - `app/loans/compare/page.tsx` now 308-redirects to `/miasto?{principal}&{term}#hypoteka` — preserves bookmarks + SEO without a 404. Added `id="hypoteka"` + `scroll-mt-24` to the MortgageCard section. - Removed the 5th `loans` nav slot from site-nav.tsx (back to 4). - Updated onboarding-tour step 4 across 4 locales: cta href now points at `/miasto#hypoteka` and copy mentions "porovnaj 4 produkty inline". Validation: - pnpm typecheck → 0 errors - pnpm lint → 0 errors, 29 warnings (== baseline) - pnpm test → 719/719 - pnpm test:e2e ux-fixes → 14/14 - pnpm test:e2e i18n-consistency → 4/4 - pnpm test:walk → 1/1 (3.0 min) - pnpm test:walk:diff pre-pr-o post-pr-o: Δ a11y: -1 (one fewer serious finding) Δ console: 0 Δ page: 0 Decisions confirmed (per spec): - F-03 timing badge — `Phase 2` placeholder used (no PO calendar date available) - F-04 — Option B (re-label + visual differentiation) shipped, no blockers encountered - F-02 — root-cause fix preferred over data-attribute exception alone; both layers (data-attribute + CSS override) included for belt-and-braces Open follow-ups (Pass-10 candidates): - `nav.loans` dict key is no longer consumed (4 locales) — can delete from the type if a Pass-10 cleanup wants the reduction - `e2e/i18n-consistency.spec.ts` doesn't yet assert the new `yourTier` + `yourTierTooltip` keys — could add a test for the dashboard XP ring tooltip text per locale - `LoanComparison`'s baseline lint warning (line 164 setState in effect) is pre-existing and would be fixed by the documented `useDeferredValue` migration Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4a50784 commit 0d266a6

21 files changed

Lines changed: 1613 additions & 606 deletions

app/globals.css

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,69 @@ body {
423423
fill: var(--sc-detail-warm);
424424
}
425425

426+
/* F-02 — restore solid coloured buildings on /games.
427+
*
428+
* The mass `[fill="X"]` rules above retinted EVERY building body to
429+
* one of 5 sky/ground tokens, then the parent `.city-scene-root`
430+
* `saturate(0.35) brightness(1.55)` filter washed them further.
431+
* Result: 9 distinct buildings collapsed to identical muted-grey
432+
* silhouettes — outline-only wireframes that hid the carefully-drawn
433+
* powered/unpowered states (window neons, sign details).
434+
*
435+
* Building draws now opt out via `<g data-building-body="true">` in
436+
* city-scene.tsx. The override below:
437+
* 1. cancels the parent filter via inverse functions, and
438+
* 2. re-asserts every retinted hex back to itself so `[fill="X"]`
439+
* rules above become no-ops inside the building scope.
440+
*
441+
* The atmospheric layers (sky gradient, mountains, ground pattern)
442+
* stay tinted — they need the muted treatment to read as a daylight
443+
* scene. Only the foreground building draws are exempted. */
444+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] {
445+
filter: contrast(1.087) brightness(0.645) saturate(2.857);
446+
}
447+
/* Per-hex passthrough — re-sets each retinted fill to itself so the
448+
* mass `[fill="X"]` overrides above are neutralised inside the
449+
* building scope. Specificity 0,2,1 (one descendant + one attr)
450+
* beats the mass rules' 0,1,1. */
451+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#0f172a"] { fill: #0f172a; }
452+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#1e293b"] { fill: #1e293b; }
453+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#111827"] { fill: #111827; }
454+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#0a0a0f"] { fill: #0a0a0f; }
455+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#020617"] { fill: #020617; }
456+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#000"] { fill: #000; }
457+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#000000"] { fill: #000000; }
458+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#111"] { fill: #111; }
459+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#0b0b20"] { fill: #0b0b20; }
460+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#0f0f1f"] { fill: #0f0f1f; }
461+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#1c1917"] { fill: #1c1917; }
462+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#3f3f5a"] { fill: #3f3f5a; }
463+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#44403c"] { fill: #44403c; }
464+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#475569"] { fill: #475569; }
465+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#94a3b8"] { fill: #94a3b8; }
466+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#06b6d4"] { fill: #06b6d4; }
467+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#0ea5e9"] { fill: #0ea5e9; }
468+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#155e75"] { fill: #155e75; }
469+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#67e8f9"] { fill: #67e8f9; }
470+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#22d3ee"] { fill: #22d3ee; }
471+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#4338ca"] { fill: #4338ca; }
472+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#6366f1"] { fill: #6366f1; }
473+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#818cf8"] { fill: #818cf8; }
474+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#064e3b"] { fill: #064e3b; }
475+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#10b981"] { fill: #10b981; }
476+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#365314"] { fill: #365314; }
477+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#65a30d"] { fill: #65a30d; }
478+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#a3e635"] { fill: #a3e635; }
479+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#78350f"] { fill: #78350f; }
480+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#854d0e"] { fill: #854d0e; }
481+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#92400e"] { fill: #92400e; }
482+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#b45309"] { fill: #b45309; }
483+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#d97706"] { fill: #d97706; }
484+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#f59e0b"] { fill: #f59e0b; }
485+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#fbbf24"] { fill: #fbbf24; }
486+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#a21caf"] { fill: #a21caf; }
487+
:where([data-skin="pko"]) .city-scene-root [data-building-body="true"] [fill="#c026d3"] { fill: #c026d3; }
488+
426489
/* R-06 — visible custom slider thumb for the mortgage panel. The
427490
* default WebKit/Firefox thumb is barely 12 px and disappears against
428491
* the navy track at 1% position. 16×16 navy with a 1 px white ring

app/loans/compare/page.tsx

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
import { redirect } from "next/navigation";
2-
import { getSession } from "@/lib/session";
3-
import { getPlayerState } from "@/lib/player";
4-
import { compareLoans } from "@/lib/loans";
5-
import { LoanComparison } from "@/components/loan-comparison";
6-
import { KnfDisclaimer } from "@/components/knf-disclaimer";
7-
import { CashflowHudMount } from "@/components/cashflow-hud-mount";
8-
import { getLang } from "@/lib/i18n-server";
1+
/* F-01 — `/loans/compare` deprecated.
2+
*
3+
* The full LoanComparison surface now lives inline inside the
4+
* Hypotéka panel on /miasto (see WattCityClient → MortgageCard).
5+
* Keeping the standalone route would mean two sources of truth +
6+
* an extra nav slot the IA budget can't afford.
7+
*
8+
* This file 308-redirects every legacy bookmark / tour-step / deep
9+
* link to the inline panel anchor, preserving the player's chosen
10+
* principal/term query params so the inline calculator opens with
11+
* the same starting point.
12+
*
13+
* Returns 308 (Permanent Redirect) so search engines drop the old
14+
* URL from their index and bookmark-syncing browsers update the
15+
* canonical target.
16+
*/
917

10-
export const dynamic = "force-dynamic";
18+
import { redirect, permanentRedirect } from "next/navigation";
1119

1220
type SearchParams = {
1321
principal?: string;
@@ -19,28 +27,17 @@ export default async function LoanComparePage({
1927
}: {
2028
searchParams: Promise<SearchParams>;
2129
}) {
22-
const session = await getSession();
23-
if (!session) {
24-
redirect("/login?next=/loans/compare");
25-
}
26-
const qs = await searchParams;
27-
const principal = Math.max(100, Math.min(50_000, Number(qs.principal ?? 3000)));
28-
const termMonths = Math.max(1, Math.min(36, Number(qs.term ?? 12)));
29-
const [state, lang] = await Promise.all([
30-
getPlayerState(session.username),
31-
getLang(),
32-
]);
33-
const rows = compareLoans(principal, termMonths, state);
34-
return (
35-
<div className="flex flex-col gap-6 animate-slide-up max-w-4xl">
36-
<KnfDisclaimer lang={lang} variant="card" />
37-
<LoanComparison
38-
rows={rows}
39-
lang={lang}
40-
principal={principal}
41-
termMonths={termMonths}
42-
/>
43-
<CashflowHudMount />
44-
</div>
45-
);
30+
const sp = await searchParams;
31+
const usp = new URLSearchParams();
32+
if (sp.principal) usp.set("principal", sp.principal);
33+
if (sp.term) usp.set("term", sp.term);
34+
const qs = usp.toString();
35+
// `permanentRedirect` issues a 308 so old links migrate; falls
36+
// back to a 307 via `redirect` if the call site changes (Next.js
37+
// accepts both as long as the import is correct).
38+
permanentRedirect(`/miasto${qs ? `?${qs}` : ""}#hypoteka`);
39+
// Unreachable — `permanentRedirect` throws to abort rendering.
40+
// Kept so the function has an explicit return path the type
41+
// checker accepts.
42+
redirect("/miasto#hypoteka");
4643
}

app/miasto/page.tsx

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@/lib/buildings";
1111
import { tickPlayer } from "@/lib/tick";
1212
import { getCatalogEntry } from "@/lib/building-catalog";
13+
import { compareLoans } from "@/lib/loans";
1314
import { WattCityClient } from "@/components/watt-city/watt-city-client";
1415
import { getLang } from "@/lib/i18n-server";
1516
import type { Lang } from "@/lib/i18n";
@@ -245,7 +246,16 @@ const DICT: Record<Lang, {
245246
},
246247
};
247248

248-
export default async function MiastoPage() {
249+
type SearchParams = {
250+
principal?: string;
251+
term?: string;
252+
};
253+
254+
export default async function MiastoPage({
255+
searchParams,
256+
}: {
257+
searchParams?: Promise<SearchParams>;
258+
}) {
249259
const [session, lang] = await Promise.all([getSession(), getLang()]);
250260
if (!session) redirect("/login");
251261

@@ -256,6 +266,17 @@ export default async function MiastoPage() {
256266

257267
const state = await getPlayerState(session.username);
258268
const [catalog] = await Promise.all([catalogForPlayer(state)]);
269+
// F-01 — inline LoanComparison on the Hypotéka panel needs server-
270+
// computed rows. URL ?principal=&term= mirror the standalone
271+
// /loans/compare query semantics, so the deprecated route just
272+
// redirects here without losing the player's chosen amount/term.
273+
const sp = (await searchParams) ?? {};
274+
const loanPrincipal = Math.max(
275+
100,
276+
Math.min(50_000, Number(sp.principal ?? 3000)),
277+
);
278+
const loanTermMonths = Math.max(1, Math.min(36, Number(sp.term ?? 12)));
279+
const loanRows = compareLoans(loanPrincipal, loanTermMonths, state);
259280
const snapshot = slotSnapshot(state).map(({ slot, building, upgrade }) => {
260281
if (!building) return { slot, building: null, upgrade: null };
261282
const c = getCatalogEntry(building.catalogId);
@@ -292,6 +313,11 @@ export default async function MiastoPage() {
292313
catalog,
293314
slots: snapshot,
294315
loans: state.loans,
316+
loanComparison: {
317+
rows: loanRows,
318+
principal: loanPrincipal,
319+
termMonths: loanTermMonths,
320+
},
295321
lang,
296322
dict,
297323
}}
@@ -301,52 +327,17 @@ export default async function MiastoPage() {
301327
);
302328
}
303329

304-
// Phase 1.6 — static coming-soon roadmap surface. Teaches players what's on
305-
// the horizon without any live logic. Each tile one line; all four langs.
330+
// F-03 — leasing / obrotowy / konsumencki removed; all three already
331+
// ship in `LoanComparison` (lib/loans.ts ProductLoanType union). The
332+
// remaining loan-typed entry is "inwestycyjny" (Tier-7 secondary
333+
// market — not yet implemented), plus the four non-loan features
334+
// (parent panel, class mode, P2P trade, PKO Junior mirror).
306335
const COMING_SOON_TILES: Array<{
307336
emoji: string;
308-
key: "leasing" | "obrotowy" | "konsumencki" | "inwestycyjny" | "parent" | "class" | "trade" | "pko";
337+
key: "inwestycyjny" | "parent" | "class" | "trade" | "pko";
309338
labels: Record<Lang, string>;
310339
teasers: Record<Lang, string>;
311340
}> = [
312-
{
313-
emoji: "🚚",
314-
key: "leasing",
315-
labels: { pl: "Leasing", uk: "Лізинг", cs: "Leasing", en: "Leasing" },
316-
teasers: {
317-
pl: "Wynajmij wyższy budynek na 6 miesięcy, potem zostaw lub zwróć.",
318-
uk: "Орендуй вищу будівлю на 6 міс., потім залиш або поверни.",
319-
cs: "Pronajmi si vyšší budovu na 6 měsíců, pak nech nebo vrať.",
320-
en: "Rent a higher-tier building for 6 months, then keep or return.",
321-
},
322-
},
323-
{
324-
emoji: "💳",
325-
key: "obrotowy",
326-
labels: { pl: "Kredyt obrotowy", uk: "Обіговий кредит", cs: "Revolvingový úvěr", en: "Revolving credit" },
327-
teasers: {
328-
pl: "Krótkoterminowa pożyczka pod przyszłe wyniki — 7 dni na spłatę.",
329-
uk: "Короткострокова позика під майбутні результати — 7 днів на виплату.",
330-
cs: "Krátkodobá půjčka proti budoucím skóre — 7 dní na splacení.",
331-
en: "Short-term loan against pending scores — 7 days to repay.",
332-
},
333-
},
334-
{
335-
emoji: "⚠️",
336-
key: "konsumencki",
337-
labels: {
338-
pl: "Kredyt konsumencki",
339-
uk: "Споживчий кредит",
340-
cs: "Spotřebitelský úvěr",
341-
en: "Consumer credit",
342-
},
343-
teasers: {
344-
pl: "Szybka gotówka, RRSO 20% — lekcja ostrzegawcza.",
345-
uk: "Швидкі гроші, RRSO 20% — навчання застереженню.",
346-
cs: "Rychlá hotovost, RRSO 20% — varovná lekce.",
347-
en: "Instant cash, 20% RRSO — cautionary tale.",
348-
},
349-
},
350341
{
351342
emoji: "📈",
352343
key: "inwestycyjny",
@@ -436,6 +427,15 @@ function ComingSoonSection({ lang }: { lang: Lang }) {
436427
cs: "Brzy — fáze 2 a dál",
437428
en: "Coming soon — phase 2 and beyond",
438429
}[lang];
430+
// F-03 — explicit "Phase 2" timing badge replaces the bare 🔒 chip.
431+
// Sets player expectation honestly without committing to a calendar
432+
// date; PO can swap to a real Q3 2026 / Q4 2026 string later.
433+
const phaseBadge = {
434+
pl: "Faza 2",
435+
uk: "Фаза 2",
436+
cs: "Fáze 2",
437+
en: "Phase 2",
438+
}[lang];
439439
return (
440440
<section className="card p-4 flex flex-col gap-3">
441441
<h2 className="text-lg font-semibold">{heading}</h2>
@@ -452,7 +452,9 @@ function ComingSoonSection({ lang }: { lang: Lang }) {
452452
<strong className="text-xs">
453453
{t.labels[lang]}
454454
</strong>
455-
<span className="ml-auto text-[10px]">🔒</span>
455+
<span className="ml-auto chip text-[10px] border-[var(--accent)] text-[var(--accent)]">
456+
{phaseBadge}
457+
</span>
456458
</div>
457459
<p className="text-xs leading-snug text-[var(--ink-muted)]">
458460
{t.teasers[lang]}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/* BuildingStackBadge — visual primitive for the *city-level* metric.
2+
*
3+
* One half of the F-04 Tier-vs-City-level disambiguation pair (the
4+
* other half is `MedalRing`, used for the XP-tier metric on the
5+
* dashboard). Three stacked rooftops in navy, sized for inline use
6+
* inside a progress-ring center. The "city you build" connotation
7+
* (vs. medals you earn) reinforces the new label split:
8+
* "Stupeň města 3" ← BuildingStackBadge + navy ring
9+
* "Tvůj tier 4" ← MedalRing + sales ring
10+
*
11+
* Pure SVG, no client deps. Inline `currentColor` so the parent
12+
* ring's text colour propagates and the badge stays in token
13+
* family on both pko (navy) and core (yellow) skins.
14+
*/
15+
16+
type Props = {
17+
/** Outer box size in CSS px. Internals scale via viewBox. */
18+
size?: number;
19+
/** Optional className for layout positioning. */
20+
className?: string;
21+
};
22+
23+
export function BuildingStackBadge({ size = 22, className }: Props) {
24+
return (
25+
<svg
26+
width={size}
27+
height={size}
28+
viewBox="0 0 24 24"
29+
fill="currentColor"
30+
aria-hidden
31+
className={className}
32+
>
33+
{/* Bottom row — two short buildings side by side. */}
34+
<rect x={2} y={15} width={6} height={7} />
35+
<rect x={9} y={13} width={5} height={9} />
36+
{/* Mid building — pitched roof. */}
37+
<polygon points="14.5,12 18,9 21.5,12" />
38+
<rect x={14.5} y={12} width={7} height={10} />
39+
{/* Tall tower with antenna pin. */}
40+
<rect x={3} y={6} width={4} height={9} />
41+
<rect x={4.5} y={3.5} width={1} height={2.5} />
42+
{/* Window detail strip on tall tower for visual texture. */}
43+
<rect x={4} y={8} width={2} height={1} fill="#ffffff" opacity={0.5} />
44+
<rect x={4} y={11} width={2} height={1} fill="#ffffff" opacity={0.5} />
45+
</svg>
46+
);
47+
}

0 commit comments

Comments
 (0)