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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<!-- SEC-RULES v1 START -->
**Security gate.** Every change made in this workspace must comply with the Secure Development Rules Reference: `~/aios/security/rules/secure-development-rules-reference.md` (index: `~/aios/security/rules/RULES-INDEX.md`). Before completing any task, check the work against those rules and flag every breach with its SEC ID and severity. CRITICAL breaches block completion -- fix or escalate before proceeding. For security-relevant changes (auth, secrets, input handling, dependencies, agent/MCP/hook/skill config), read the matching rule section first.
<!-- SEC-RULES v1 END -->

# termo-site

[faraapacalda.ro](https://faraapacalda.ro) — Next 16 App Router, TypeScript strict,
Expand All @@ -17,6 +21,12 @@ npx playwright test # needs .data/ populated AND a production build
local bundle instead of the published release. That is the way to develop against a
bundle the nightly has not published yet.

**A server already on :3000 makes Playwright test stale code.** `reuseExistingServer` is
on outside CI, so if anything is serving :3000 the webServer step is skipped and no
rebuild happens — the suite silently runs against whatever was built last, including a
different `NEXT_PUBLIC_*` inlining. A suite that finishes suspiciously fast (no build) is
the tell. Free the port first: `kill $(lsof -ti:3000)`.

## Pure SSG is a constraint, not a preference

Pages use `export const dynamic = 'error'` plus `generateStaticParams`. Data is read at
Expand Down
71 changes: 71 additions & 0 deletions app/api/app-poll/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createHash } from "node:crypto";

import { validateAppPoll } from "@/lib/app-poll";

// Anonymous "would you want an app?" poll. Same posture as /api/feedback: raw
// IPs never touch storage, only a salted hash used for best-effort rate
// limiting. No GET - unlike the feedback tally there is nothing here the site
// should expose publicly.
//
// The poll is answered in two steps and both write the SAME row, upserted on
// the client's nonce. That way someone who taps "Da" and closes the dialog
// without picking a platform still counts as a "Da" rather than vanishing.
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 6; // two writes per answer, plus slack
const recent = new Map<string, number[]>();

function rateLimited(ipHash: string): boolean {
const now = Date.now();
const hits = (recent.get(ipHash) ?? []).filter((t) => now - t < WINDOW_MS);
if (hits.length >= MAX_PER_WINDOW) return true;
hits.push(now);
recent.set(ipHash, hits);
if (recent.size > 5000) recent.clear(); // crude memory bound
return false;
}

export async function POST(req: Request) {
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
const salt = process.env.FEEDBACK_IP_SALT;
if (!url || !key || !salt) {
return new Response(null, { status: 503 });
}

let body: unknown;
try {
body = await req.json();
} catch {
return new Response(null, { status: 400 });
}
const result = validateAppPoll(body as Record<string, unknown>);
if (!result.ok) {
// Honeypot hits get a clean 204 so bots learn nothing.
return new Response(null, { status: result.reason === "honeypot" ? 204 : 400 });
}

const ip = (req.headers.get("x-forwarded-for") ?? "unknown").split(",")[0].trim();
const ipHash = createHash("sha256").update(salt + ip).digest("hex");
if (rateLimited(ipHash)) {
return new Response(null, { status: 429 });
}

// Upsert on the unique nonce: step 1 inserts the interest, step 2 merges the
// platform onto that same row.
const res = await fetch(`${url}/rest/v1/fac_app_poll?on_conflict=nonce`, {
method: "POST",
headers: {
apikey: key,
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
Prefer: "return=minimal,resolution=merge-duplicates",
},
body: JSON.stringify({
...result.record,
updated_at: new Date().toISOString(),
ip_hash: ipHash,
ua: (req.headers.get("user-agent") ?? "").slice(0, 300),
}),
});
return new Response(null, { status: res.ok ? 204 : 502 });
}
4 changes: 4 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
/* utilities */
.tnum { font-variant-numeric: tabular-nums; }
.hairline-b { border-bottom: 1px solid var(--color-hairline); }

/* Only one ask on screen: while the app poll holds the view, the floating
feedback pill steps aside. AppPollModal sets the attribute. */
body[data-app-poll="open"] [aria-label="Feedback despre site"] { display: none; }
.display-num { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.01em; }

/* ===== verdict band =====
Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { Inter, Source_Serif_4 } from 'next/font/google';
import Script from 'next/script';
import './globals.css';
import AppPollModal from '@/components/AppPollModal';
import FeedbackWidget from '@/components/FeedbackWidget';
import SiteNav from '@/components/SiteNav';
import SourceFooter from '@/components/SourceFooter';
Expand Down Expand Up @@ -38,6 +39,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
<SourceFooter />
<FeedbackWidget />
<AppPollModal />
<JsonLd data={webSiteJsonLd()} />
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
Expand Down
248 changes: 248 additions & 0 deletions components/AppPollModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"use client";

import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";

// One-question poll: "would you want an app?", then Android or iOS.
//
// It only fires on the pages where a visitor has just been given their answer
// (a street or a punct termic), and only after a delay. The delay is not just
// politeness: a dialog that covers content the moment someone arrives from
// search is what Google treats as an intrusive interstitial, and this site
// lives on organic search. After real engagement it is not that.
//
// Answers are written in two steps against one nonce-keyed row, so tapping
// "Da" and then closing still counts as a "Da".
const STORAGE_KEY = "fac-app-poll";
const SUPPRESS_ANSWERED_MS = 365 * 24 * 3600 * 1000;
const SUPPRESS_DISMISSED_MS = 90 * 24 * 3600 * 1000;
// Overridable so the e2e build can push the modal out of reach of specs that
// merely happen to visit a street page (see playwright.config.ts).
const DELAY_MS = Number(process.env.NEXT_PUBLIC_APP_POLL_DELAY_MS) || 10_000;
const ANSWER_PAGES = ["/strada/", "/punct-termic/"];

// Kill switch: set NEXT_PUBLIC_APP_POLL=0 and redeploy (or let the nightly
// rebuild pick it up) to take the poll down without reverting code.
const ENABLED = process.env.NEXT_PUBLIC_APP_POLL !== "0";

type Stage = "interest" | "platform" | "done";

function suppressed(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return false;
const { kind, t } = JSON.parse(raw) as { kind: string; t: number };
const ttl = kind === "dismissed" ? SUPPRESS_DISMISSED_MS : SUPPRESS_ANSWERED_MS;
return Date.now() - t < ttl;
} catch {
return false;
}
}

function remember(kind: "answered" | "dismissed") {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ kind, t: Date.now() }));
} catch {
/* private mode etc. - fine */
}
}

// crypto.randomUUID() needs a secure context; getRandomValues does not.
function uuid4(): string {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map((n) => n.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}

export default function AppPollModal() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [stage, setStage] = useState<Stage>("interest");
const nonceRef = useRef<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const firstButtonRef = useRef<HTMLButtonElement>(null);

const onAnswerPage = ANSWER_PAGES.some((p) => pathname.startsWith(p));

useEffect(() => {
if (!ENABLED || !onAnswerPage || suppressed()) return;
const id = setTimeout(() => setOpen(true), DELAY_MS);
return () => clearTimeout(id);
}, [onAnswerPage, pathname]);

// While the dialog holds the screen: lock scrolling, and hide the feedback
// pill so there is only ever one ask on screen (see app/globals.css).
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
document.body.dataset.appPoll = "open";
return () => {
document.body.style.overflow = prev;
delete document.body.dataset.appPoll;
};
}, [open]);

useEffect(() => {
if (open) firstButtonRef.current?.focus();
}, [open, stage]);

const close = useCallback(
(kind: "answered" | "dismissed") => {
remember(kind);
setOpen(false);
},
[],
);

function send(interested: boolean, platform?: "android" | "ios") {
if (nonceRef.current === null) nonceRef.current = uuid4();
// Best-effort: a poll answer is not worth blocking or scolding anyone over.
void fetch("/api/app-poll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nonce: nonceRef.current,
interested,
platform,
page: pathname,
website: "",
}),
}).catch(() => {});
}

function answerInterest(interested: boolean) {
send(interested);
if (interested) {
setStage("platform");
} else {
setStage("done");
setTimeout(() => close("answered"), 2500);
}
}

function answerPlatform(platform: "android" | "ios") {
send(true, platform);
setStage("done");
setTimeout(() => close("answered"), 2500);
}

// Tab cycles inside the dialog; Escape is equivalent to the × button.
//
// Both listen on the DOCUMENT, not on the backdrop element: clicking the
// backdrop moves focus to <body>, and a handler bound to the backdrop subtree
// stops firing the moment focus leaves it - which killed Escape exactly when
// it was most needed, since a backdrop click is the first thing people try.
useEffect(() => {
if (!open) return;
function onKey(ev: KeyboardEvent) {
if (ev.key === "Escape") {
ev.preventDefault();
close(stage === "interest" ? "dismissed" : "answered");
return;
}
if (ev.key !== "Tab" || !dialogRef.current) return;
const focusable = dialogRef.current.querySelectorAll<HTMLElement>("button");
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (!dialogRef.current.contains(active)) {
// Focus escaped the dialog - pull it back rather than tabbing the page behind.
ev.preventDefault();
(ev.shiftKey ? last : first).focus();
return;
}
if (ev.shiftKey && active === first) {
ev.preventDefault();
last.focus();
} else if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, stage, close]);

if (!open) return null;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(33, 28, 23, 0.45)" }}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Întrebare despre o aplicație"
className="relative flex min-h-[9rem] w-full max-w-sm flex-col justify-center border border-hairline bg-paper p-5 text-ink"
>
<button
type="button"
aria-label="Închide"
onClick={() => close(stage === "interest" ? "dismissed" : "answered")}
// 40x40 hit area (WCAG 2.5.8 wants >=24x24); the glyph stays small.
className="absolute right-1 top-1 flex h-10 w-10 items-center justify-center text-lg text-ink-soft hover:text-ink"
>
×
</button>

{stage === "done" ? (
<p className="pr-6 font-medium">
Mulțumesc mult! La cât mai puține zile fără apă caldă!
</p>
) : stage === "platform" ? (
<>
<p className="pr-6 text-ink-soft">Preferi pe:</p>
<div className="mt-3 flex gap-2">
<button
type="button"
ref={firstButtonRef}
onClick={() => answerPlatform("android")}
className="flex-1 border border-ink px-3 py-2 font-medium hover:bg-ok"
>
Android
</button>
<button
type="button"
onClick={() => answerPlatform("ios")}
className="flex-1 border border-ink px-3 py-2 font-medium hover:bg-ok"
>
iOS
</button>
</div>
</>
) : (
<>
<p className="pr-6 text-ink-soft">Hei, te deranjez o secundă cu o întrebare:</p>
<p className="mt-2 pr-6 font-medium">
Te-ar interesa o aplicație pentru faraapacalda.ro?
</p>
<div className="mt-4 flex gap-2">
<button
type="button"
ref={firstButtonRef}
onClick={() => answerInterest(true)}
className="flex-1 border border-ink px-3 py-2 font-medium hover:bg-ok"
>
Da
</button>
<button
type="button"
onClick={() => answerInterest(false)}
className="flex-1 border border-hairline px-3 py-2 hover:bg-ok"
>
Nu
</button>
</div>
</>
)}
</div>
</div>
);
}
Loading
Loading