

## ▼▼▼ THE PROMPT — copy everything in this block ▼▼▼

```text
# ROLE
You are a senior full-stack engineer. Build a complete, production-ready web app called "Offer Showcase" for an affiliate publisher of the PDL Profit (PDLP) microloan CPI network. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui + TanStack Query on the front end, and this project's Supabase for the database, edge functions, and the admin password gate.

# STEP 0 — INTERVIEW ME FIRST (DO THIS BEFORE WRITING ANY CODE)
Before generating anything, ask me ALL of the questions below in a single grouped message. RULES for how to ask:
- EVERY question must present explicit, lettered answer choices (a), (b), (c)… that I can pick by letter. Never ask an open question with no options.
- Mark the recommended choice with [default], so I can just reply "all defaults" and you proceed.
- Only allow a free-text / "your own" answer where a value genuinely can't be a fixed list (brand title, a country code not listed). In those cases still put a concrete EXAMPLE in parentheses so I know the expected format.
- TWO DIFFERENT LANGUAGES are involved, keep them separate (see LANGUAGES section): (1) the SHOWCASE CONTENT language is derived AUTOMATICALLY from the chosen GEO and is never asked; (2) the ADMIN INTERFACE language is asked, chosen from the languages PDL Profit itself uses (EN/ES/UA/RU).
- Do NOT ask anything about the call-to-action text — it is chosen automatically by offer position + the showcase language (see CTA TEXT below).
- Do NOT ask anything about login/authentication — you will auto-create a password-protected admin and tell me the URL + password at the end (see ADMIN ACCESS below).
Wait for my answers, then build the ENTIRE app end-to-end and configure everything yourself, so I do NOT perform any manual setup afterwards. Never stop to ask me to configure something — if a table or function is needed, create it.

Keep the interview SHORT — only the questions below. Do NOT ask about lead type, color theme, or branding/header: those are configured later inside the admin with sensible defaults (lead type = Both; a clean default theme; clean header, no logo).

Questions to ask me (present exactly like this, with these choices):
1. MARKET / GEO — Which market is this showcase for? (FIRST question. This sets the offers' geo AND the showcase content language automatically.) Choose from PDL Profit's top geos:
   (a) Ukraine — UA [default]  (b) Mexico — MX  (c) India — IN  (d) Kazakhstan — KZ  (e) Poland — PL  (f) Romania — RO  (g) Philippines — PH  (h) Vietnam — VN  (i) Spain — ES  (j) another PDLP geo (type its 2-letter code, e.g. "CO", "NG")
   (Do NOT ask for an API key — it is entered later inside the admin via an "API key" block; see ADMIN PANEL. Do NOT ask about sorting — it is built into the admin with a working default; see SORTING & ORDER MODE.)
2. CARD CUSTOMIZATION LEVEL — How editable are the showcase offer cards? (the CTA text is NOT editable — it's automatic; see CTA TEXT)
   (a) Fixed clean template  (b) Choose which fields show, per offer [default]  (c) Fully editable per offer: custom title, the single badge (selling chip), accent color, promo line
3. ADMIN INTERFACE LANGUAGE — Language for the ADMIN panel only (chosen from PDL Profit's platform languages; this does NOT change the showcase, whose language follows the geo):
   (a) English [default]  (b) Spanish  (c) Ukrainian  (d) Russian
4. CTA TARGET — Offer links open in:
   (a) Same tab [default]  (b) New tab
   (Do NOT ask about the CTA button TEXT — it is generated automatically from offer position + the showcase language; see CTA TEXT below.)
After I answer (or say "all defaults"), proceed to build everything below without further setup requests. The admin must open already pre-filled (default sort + my geo, lead type = Both, default theme, clean header), with an empty "API key" block at the top; the moment I paste my key and click Apply, offers load — with zero other configuration.

# LANGUAGES (two separate languages — do not mix them up)
- SHOWCASE CONTENT language = the local language of the chosen GEO, applied AUTOMATICALLY to everything on the public showcase: the single selling badge, CTA text, footer, legal pages, consent popup, contact form, and all generated UI strings. (Offer titles/legal text already arrive from the API in the local language.) Geo→language map: UA→Ukrainian, MX→Spanish, IN→English, KZ→Russian, PL→Polish, RO→Romanian, PH→English, VN→Vietnamese, ES→Spanish; for any other geo use that country's primary local language, fallback English. Store as settings.showcase_lang (derived from geo, but overridable in code).
- ADMIN INTERFACE language = the language picked in Q5, from PDL Profit's own platform languages (English / Russian / Ukrainian). It controls ONLY the admin chrome, labels, explanations and "ask your AI builder" notes — never the showcase. Store as settings.admin_lang.

# PRODUCT OVERVIEW
Two surfaces:
1. PUBLIC SHOWCASE (/) — landing page listing loan offers as cards. Webmaster traffic arrives here with tracking params in the URL; the visitor picks an offer and clicks through to the lender via a PDLP tracking link. Must be extremely fast and conversion-focused, using one fixed layout (see ONE FIXED LAYOUT — no mobile/desktop split).
2. ADMIN PANEL (/admin) — protected by an auto-generated password (reported to me at the end). Two tabs only — "Showcase" (load offers, mouse-drag reorder, hide, mark hero, theme/branding/cards, live preview — all on one screen) and "Statistics".

# DESIGN LANGUAGE
Clean, modern, premium "fintech" feel that signals trust: generous whitespace, rounded-2xl cards, soft shadows, clear hierarchy, large tappable CTA buttons.
COMPLETE SEMANTIC PALETTE (this is mandatory — a recurring bug is dark text left on a dark background). Drive EVERY color from a full set of CSS-variable / Tailwind tokens, and define ALL of them for each theme so nothing is ever hardcoded:
  background, foreground (body text), card, card-foreground, popover, popover-foreground, muted, muted-foreground (secondary text), primary, primary-foreground (BUTTON TEXT), secondary, secondary-foreground, accent, accent-foreground, border, input, ring, success, success-foreground, plus the badge/chip colors and their foregrounds.
- EVERY piece of text — body, headings, card text, secondary/muted text, the rate value, the selling chip, the legal-info toggle, AND the text ON buttons — must use its matching *-foreground token, never a fixed color like text-black/#111. So when the theme is dark, foreground/card-foreground/muted-foreground automatically become light and remain readable; button text uses primary-foreground chosen to contrast with the button color.
- Guarantee WCAG-AA contrast (≥4.5:1 for normal text) in BOTH light and dark for every token pair (foreground/background, card-foreground/card, primary-foreground/primary, etc.). Pick each theme's tokens so this holds; the low-rate "success" highlight and chips must stay readable on their backgrounds.
- Dark/light is a property of the selected theme (background-mode); switching it must flip the whole token set together (no half-dark states).
IMPORTANT — color THEMES recolor the PUBLIC SHOWCASE only, never the admin. The admin keeps a neutral, stable chrome at all times; switching themes changes the showcase (and its preview), not the admin UI.
DETERMINISTIC, ROBUST LAYOUT (this prompt must produce the same solid result every time):
- Use a strict, explicit layout system: CSS grid for the offer grid, flexbox inside cards, a COMPACT spacing scale (roughly 30% tighter than typical defaults — e.g. 3/6/8/12/16px instead of 4/8/12/16/24px).
- COMPACT SNIPPETS: cards, fonts, buttons, paddings and gaps are about 30% more compact than a typical default — smaller card padding, tighter line-height, slightly smaller font sizes (e.g. name ~text-sm/base, facts ~text-xs/sm), and shorter buttons (e.g. h-9/h-10 instead of h-12). Keep it dense but still readable and tappable (min 40px touch target on the CTA). This applies to standard cards; hero cards are proportionally a bit larger but still on the compact scale.
- Never let content overflow: long names/titles truncate or wrap cleanly; images use fixed aspect ratios with object-fit; buttons and chips never push the card wider; min-width:0 on flex children to avoid blowout.
- Cards in a row must be EQUAL HEIGHT (align the grid; CTA pinned to the bottom of each card).
ONE FIXED LAYOUT — NO phone/desktop/mobile/tablet split, NO breakpoints, NO collapsing to a single column:
- The offer grid is ALWAYS a 2-column grid: standard cards occupy 1 column (two per row), hero cards span BOTH columns (full width, one per row). Never 3+, never collapses to 1.
- Each standard card column has a MIN and a MAX width (e.g. min ~280px, max ~360px — tuned for the compact scale). So the grid has a min total width (≈ 2×min + gap) and a max total width.
- When the screen is WIDER than the grid's max: the grid stays at max width and is CENTERED (margins auto); it does not stretch endlessly.
- When the screen is NARROWER than the grid's min: do NOT reflow to one column — keep the 2-column min width and let the page SCROLL HORIZONTALLY (content "rides" across the screen). Wrap the grid in a container that allows horizontal overflow so this is smooth.
- The exact same single layout is used everywhere — the live preview shows precisely what visitors get; there is no separate mobile rendering and no device toggle.
- Build it so a vibe-coder can later tweak freely, but out of the box it must be clean and unbroken on all sizes.

NON-NEGOTIABLE ANTI-BREAKAGE RULES (apply best practices everywhere; these specific defects must NOT appear):
- EQUAL-HEIGHT, CONSISTENT BUTTONS: every CTA button has the SAME fixed height across ALL cards regardless of label length (e.g. min-h-12, flex items-center justify-center, text centered, text-balance). A 2-line label must not make one button taller than a 1-line one — reserve the height. Make the card a flex column with the CTA in an `mt-auto` footer so all CTAs align on the same baseline in a row. Prefer concise labels; allow at most 2 lines, centered.
- KEY-FACTS / MINI-STATS BLOCK MUST NEVER OVERLAP (this is the bug in the reference screenshot where "Сумма/Срок/Ставка" labels collide with their values): build it as a CSS grid `grid-cols-3` with a real column `gap` (≥8px), each stat in its OWN cell as a vertical stack (small muted label on top, bold value below), `min-w-0` on every cell, values `truncate` or wrap cleanly, units (e.g. "дней"/"days") kept with their number and allowed to wrap WITHIN the cell only. No absolute/negative positioning, no fixed pixel widths that can collide, no overlapping text — ever. On very narrow widths reduce to 2 columns or stack; never let cells overlap.
- Use consistent vertical rhythm between blocks (fixed gap), so the badge, logo, name, stats, legal toggle and CTA never crowd or overlap each other.
- Text never overflows its container: apply `min-w-0`, `break-words`/`truncate` as appropriate; numbers and currency formatted (e.g. "40 000 ₴", "0,01%") so they fit.
- Use the shadcn/ui components (Button, Card, Badge) consistently; do not hand-roll inconsistent variants that drift in size.

# DATA SOURCE — PDLP PARTNER API (authoritative contract)
All offer data comes from one GET endpoint, called SERVER-SIDE only, through a Supabase Edge Function (never from the browser).

Endpoint: https://pdl-profit.com/partnerapi/offers/data
Auth: query param `api_key` = the publisher's PDLP key, which is entered in the admin "API key" block (not in the interview, not a secret) and stored in the `settings` table. Until a key is applied, the admin shows an "enter your API key to load offers" empty state and the public showcase shows nothing/placeholder. This key is public and read-only (it only fetches the offer list, scoped to one affiliate id). The edge function exists only to avoid browser CORS, not to hide the key.

Query params the edge function supports (merged from my saved admin settings):
- api_key (required, from the settings table — public key, not a secret)
- country (default "UA")
- mode: "CPS" | "CPL" | "CPL, CPS"
- orderBy: "ecpc" | "cr"
- orderDir: "ASC" | "DESC"
- smart: "true" to enable visitor smart-filter
- filtered_by_param: sub_id|subid2|subid3|utm_source|utm_medium|utm_campaign|utm_term|utm_adgroup|utm_adposition|utm_creative|utm_device|gclid
- filtered_by_value
- date_from, date_to (YYYY-MM-DD, max 70 days apart — used only for stats windows)
- pass-through click params: subid, subid2, subid3, utm_source, utm_medium, utm_campaign, utm_term, utm_adgroup, utm_adposition, utm_creative, utm_device, gclid

Response JSON:
{
  "status": "success", "count": 18, "pages": 1, "currentPage": 1,
  "data": [{
    "id": 1147, "name": "Credit7", "country_code": "UA",
    "image": "https://.../logo.png",
    "credit": "15000", "credit_repeat": "35000", "days": "61",
    "first_credit": "20000", "second_credit": "40000", "term": "365",
    "first_credit_percent": "0.01",            // the hero "0.01%" rate
    "first_credit_percent_standard": "2",
    "title": "Перший кредит від 0,01%",         // short marketing headline (may be localized; may be "")
    "legal_info": "<div>...</div>",             // HTML — sanitize, collapsed by default
    "url": "http://tds.pdl-profit.com?offerid=1147&affid=20835", // READY tracking link, affid already included
    "epc_general": 1.19, "cr_general": "2.74",  // network metrics (INTERNAL — admin only)
    "ecpc": null, "cr": null,                   // this publisher's metrics for the window (often null)
    "prices": { "CPS1": {"price":"1190.00","currency":"UAH"}, "CPS2": {"price":"158.00","currency":"UAH"} } // keys vary CPS1..CPS4
  }]
}

Field-handling rules:
- Strings may use comma decimals ("0,01"): normalize comma→dot, parse, format with thousands separators. Treat "" as missing and hide that line — never render empty rows (e.g. "Izi bank" has empty title/term).
- Hero rate badge = first_credit_percent; highlight in success color when very low.
- CTA base = offer.url AS-IS (already has offerid + affid). See ATTRIBUTION.
- legal_info is HTML: sanitize and show in a collapsible "Legal info" disclosure, collapsed.
- Top payout (admin-only) = highest price in prices with its currency; iterate the object, don't assume fixed keys. Keys vary by geo: CPS1..CPS4, and also "CPL". Currencies vary even within one geo (e.g. PLN, EUR, USD) — always show each price with ITS OWN currency.
- `price` may be a NON-NUMERIC string like "revshare" — do not parse it as a number; display it as a label (e.g. "RevShare") instead.
- Some offers have empty credit/term/title (e.g. PL "Zaimoo"/"Creditio" are CPL-only with blank amounts) — hide those lines/blocks gracefully; never show a label with no value.
- Payout / EPC / CR are INTERNAL: show in admin only, NEVER on the public page.

# SORTING & ORDER MODE (built into the admin, NOT asked in the interview)
All sort options are known from the API and must be PRE-BUILT into the admin as a dropdown, pre-filled with a working default so offers load on first open. The admin chooses ONE order mode (saved in settings.order_mode):
- DYNAMIC [default]: the showcase order is whatever the PDLP API returns for the chosen preset — the order updates itself as the API data changes. Presets map directly to API params:
  * "Top conversion (CR↓)" [default] → orderBy=cr&orderDir=DESC
  * "Top earnings (EPC↓)"          → orderBy=ecpc&orderDir=DESC
  * "Lowest conversion (CR↑)"      → orderBy=cr&orderDir=ASC
  * "Lowest earnings (EPC↑)"       → orderBy=ecpc&orderDir=DESC→ASC
  In dynamic mode the publisher does NOT drag rows; order follows the preset live.
- MANUAL: the publisher drags offers into a fixed order and clicks Save; that exact order is pinned and used on the showcase regardless of API order. The initial manual list is seeded from the current default preset so it's never empty.
Either mode also respects per-offer is_hero and is_hidden. Separate controls (Country, Lead type, Smart filter) further shape what the API returns. Default state on first launch: order_mode=DYNAMIC, preset=CR↓, country = my chosen geo, lead type = Both (changeable in the admin) — everything pre-filled, no empty fields.

# LIVE OFFER LIST + THROTTLED REFRESH (REQUIRED — do NOT hit PDLP on every visit)
The showcase must reflect the CURRENT live offer set (offers get disabled/added in PDLP, and some may be off at the moment a given visitor lands), but WITHOUT calling the PDLP API on every page view — that would hammer their server at high traffic. Use a cached snapshot refreshed on a visitor-count schedule:
- Keep a singleton `offers_cache` row: data(jsonb = last fetched offers), fetched_at, visits_since_fetch(int).
- The public showcase always renders from `offers_cache.data` (instant, no per-visit API call), applying admin overrides (order/hero/hidden) and dynamic-mode ordering.
- On each public visit: atomically increment visits_since_fetch. When it reaches the refresh threshold (see below), the next request triggers get-offers to refetch from PDLP, replaces data, sets fetched_at=now, resets the counter, and reconciles overrides. Also force a refresh if the cache is empty or older than a safety max-age (e.g. 6h) even at low traffic.
- Refresh threshold = settings.refresh_every_n_visits, DEFAULT 500, MINIMUM 100 (enforce the lower bound — reject/clamp anything below 100 in both the UI and the backend, to avoid hammering PDLP). No adaptive mode; it's just this single fixed number.
- Reconciliation on refresh: keep order/hero/hidden for offers still present; auto-drop offers no longer returned (so a disabled offer disappears from the showcase — no blank slots, list reflows); add newly active offers automatically (in dynamic mode they slot into the preset order; in manual mode they append at the end and are flagged NEW for the admin), never hero by default.
- If a refresh fetch fails, keep serving the last good cache so traffic never sees a blank page.
In ADMIN, a manual "Load / Refresh offers" button always forces an immediate refetch + reconcile (ignores the counter): it keeps existing overrides, badges disappeared offers (with a "remove from layout" action), and surfaces brand-new offers as "NEW".

# HERO SNIPPETS (REQUIRED)
The Hero flag ONLY changes how an offer is RENDERED — it makes that snippet span the FULL WIDTH (one per row) and look a bit more prominent. It does NOT pin, lock, or move the offer: the offer stays exactly in its current order position; in Dynamic mode the order still follows the preset, in Manual mode it follows my drag order. Non-hero offers always render TWO per row (never collapses to one — see ONE FIXED LAYOUT); a hero offer simply breaks out to full width (both columns) in its place.
- Default: the FIRST offer in the order is hero if I haven't marked any.
- I can mark ANY offers as hero in admin (multiple allowed) — e.g. the first AND the last. A per-row "Hero" toggle controls this (is_hero in DB). Order is never changed by the hero flag.
- HERO HIGHLIGHT ANIMATION: give hero cards a subtle, tasteful animated emphasis — a gently animated/gradient border, a soft pulsing or glowing shadow, and a slight shimmer/scale-on-hover on the CTA button. Keep it smooth and low-key (slow easing, small amplitude, respect prefers-reduced-motion); it should feel premium, never flashy or distracting.

# CTA TEXT (automatic — never chosen by the publisher, never asked in the interview)
The call-to-action button text is generated automatically, depending on the offer's POSITION/role on the showcase and the SHOWCASE language (the geo's language). There is no CTA field in the interview, settings, or branding. Build a fixed copy map keyed by language and slot, and assign each offer's button by its slot:
- HERO slot (top, full-width) — strongest: EN "Get money now" / UA "Отримати гроші зараз" / RU "Получить деньги сейчас"
- FIRST standard card: EN "Get the loan" / UA "Оформити позику" / RU "Оформить займ"
- OTHER standard cards: cycle through 2 phrasings so buttons aren't identical down the grid — EN "Apply now" / "Get approved" · UA "Подати заявку" / "Отримати схвалення" · RU "Подать заявку" / "Получить одобрение"
Rules: pick the language from the SHOWCASE language (geo), provide copy for all supported showcase languages (UA/RU/PL/ES/RO/VN/EN…); pick the variant purely from the offer's rendered slot (recomputed live as order/hero/visibility change); if a language string is missing, fall back to English. Keep these strings in one place in code so they're easy to extend, but expose NO UI to edit them.

# OFFER FEATURE BADGE / SELLING BULLET (REQUIRED — exactly ONE per snippet)
Each card shows exactly ONE small, COLORFUL "feature" chip near the TOP of the snippet (above or right under the logo) that warms up the offer with a single microloan-domain selling point — e.g. "0.01% first loan", "No credit check", "Fast approval", "Money in 15 min", "First loan free", "24/7". It MUST be in the showcase UI LANGUAGE (e.g. RU "Без проверок", "Кредит за 15 минут"; UA "Без перевірок", "Гроші за 15 хв"; EN "Fast approval") and stay within the microloan/PDL domain. Render it as a single rounded pill in an accent color, visually distinct from the rate badge; never overflow the card.
How to pick the ONE bullet, in priority order:
1. AUTO-DERIVED from offer fields (small rules engine): very low first_credit_percent (≈0.01) → "0.01% first loan"; short term/days → "Money fast"; otherwise a domain-appropriate default.
2. A sensible DEFAULT (e.g. "Fast approval") when nothing specific is derivable.
3. If card customization level allows per-offer editing, the publisher can override this single chip per offer in the admin (stored in offer_overrides.card_overrides). Otherwise it's fully automatic.
Keep it short (1–3 words).

# ATTRIBUTION & TRACKING (critical — revenue depends on it)
Traffic lands with params, e.g. /?subid=abc&utm_source=fb&gclid=...
On load:
1. Read ALL of: subid, subid2, subid3, utm_source, utm_medium, utm_campaign, utm_term, utm_adgroup, utm_adposition, utm_creative, utm_device, gclid (if present). Persist in sessionStorage.
2. Build each offer's outgoing link = offer.url + appended captured params. offer.url already has a query string (?offerid=...&affid=...), so append with &key=value (URL-encoded, skip empty). Example: http://tds.pdl-profit.com?offerid=1147&affid=20835&subid=abc&utm_source=fb
3. CTA click: log the click event first, then navigate (same/new tab per my choice) via a small redirect helper so the event records reliably.
NEVER strip or alter offerid/affid; NEVER drop incoming params.

# ADMIN ACCESS (auto-generated — do NOT ask me about login)
Create a password-protected admin yourself; I should not have to choose or set up any auth:
- Protect /admin behind a single password gate. GENERATE a strong RANDOM password at build time (e.g. 16+ chars). Store it server-side (Supabase) and verify via an edge/DB function so the password isn't shipped in client code; keep a session in the browser after a correct entry. The public showcase stays open; /admin (and the hidden 5×-logo entry) shows the password prompt.
- The admin path can be /admin (you may use a less-guessable path if you prefer; whatever you choose, report it).

SESSION HANDLING — GET THIS RIGHT (a known recurring failure: after entering the correct password the app bounces straight back to the login screen). Implement the session so this CANNOT happen:
- On correct password, create a PERSISTENT session and store it durably (a token in localStorage AND/OR a cookie with correct attributes: `path=/`, `SameSite=Lax`, `Max-Age` set; do NOT mark it `Secure` in a way that breaks http previews; never `HttpOnly` if the client must read it). Persist it BEFORE navigating to the admin.
- The route guard MUST be driven by an auth state with THREE states: `loading` (session being restored), `authenticated`, `unauthenticated`. While `loading`, render a spinner — do NOT redirect. Only redirect to the login screen when state is definitively `unauthenticated`. This avoids the race condition where the guard runs before the stored session is read and wrongly bounces back to login.
- Rehydrate the session synchronously on app start (read storage/cookie before the first guard evaluation), so a full page reload on /admin stays logged in.
- After a successful login, update the in-memory auth state AND navigate in the right order (set state → then navigate), so the destination guard already sees `authenticated`. No redirect loops, no flicker back to the password page.
- If you use Supabase Auth instead of a custom gate, enable `persistSession: true` + `autoRefreshToken: true`, and `await` session restoration before evaluating the guard (gate on `loading`).
- Provide a Logout action in the admin that clears the session and storage.
- WHEN EVERYTHING IS DONE, output to me clearly, e.g.:
  "Your admin is ready: open <full /admin URL> and log in with password: <the generated password>. You can change this password later by asking your AI builder."
  Make this the final thing you tell me, alongside the self-test checklist.

# SUPABASE BACKEND (build and configure it yourself)
Auth: the auto-generated password gate above protects /admin; the public showcase is open.
Tables (with RLS):
- settings (singleton): api_key(text — public PDLP key, entered in the admin, empty until applied), country, mode, order_mode('dynamic'|'manual', default 'dynamic'), order_by(default 'cr'), order_dir(default 'DESC'), smart_enabled, filtered_by_param, filtered_by_value, refresh_every_n_visits(int, default 500, min 100 — enforce), cta_new_tab(bool), new_offer_placement('end'|'api_sort', default 'end'), theme(jsonb: the FULL token palette — background, foreground, card, card_foreground, muted, muted_foreground, primary, primary_foreground, accent, accent_foreground, border, success, success_foreground, chip, chip_foreground — plus background_mode 'light'|'dark', radius, button_style; preset name; with a sensible default that has correct contrast), branding(jsonb: logo_url, title, tagline), card_customization_level, showcase_lang (derived from geo), admin_lang (from the admin-language question). (No CTA text stored anywhere — it's automatic. Lead type defaults to Both and is changeable in the admin.) Admin write; the api_key column is admin-read only (the public showcase reads offers via the edge function, never the key directly). Seed this row at build time with the interview answers + defaults and an EMPTY api_key, so the admin opens pre-filled except for the key.
- offers_cache (singleton): data(jsonb), fetched_at(timestamptz), visits_since_fetch(int default 0). Public read; written only by the get-offers edge function. This is what the showcase renders from.
- offer_overrides: offer_id(int pk), position(int), is_hero(bool), is_hidden(bool), card_overrides(jsonb), cached_snapshot(jsonb). Public read; admin write.
- events: id, type('visit'|'click'), offer_id(nullable), params(jsonb), referrer, user_agent, created_at(default now()). Anyone (anon) can INSERT; only admin can SELECT. No PII beyond standard params.
Edge function `get-offers` (Deno): reads api_key + settings, merges them into the query, fetches PDLP, writes the result into offers_cache (data + fetched_at, reset visits_since_fetch=0), and returns the data array; permissive CORS (Access-Control-Allow-Origin:*, handle OPTIONS); graceful error handling (on failure keep the previous cache). It runs only when a refresh is due (counter threshold / safety max-age) or when the admin forces it — NOT on every visit. Its purpose is to avoid browser CORS and to throttle load on PDLP; the key is public, so no secret is involved.
Edge function (or DB function) `track-visit`: atomically increments offers_cache.visits_since_fetch, decides if a refresh is due (>= refresh_every_n_visits, or cache empty/older than max-age), and if so invokes get-offers. Always returns fast; the showcase renders from the cache regardless. Log clicks/visits via direct anon insert into events (low latency).

# ADMIN PANEL (/admin) — exactly TWO tabs: "Showcase" and "Statistics"
Global rules for the admin:
- The admin chrome is always neutral/stable. Theme & color choices here recolor the PUBLIC SHOWCASE and its preview ONLY, never the admin itself.
- EVERY block/section on every screen has (1) a short inline explanation of what it does, and (2) a small muted note, localized, like "Want this different? Just ask your AI builder in chat." — so a non-technical publisher understands each control and knows it can be changed by a normal conversation with their development service.
- The whole showcase is configured on ONE screen (Tab 1): controls on the left, a live preview on the right that updates instantly.

TAB 1 — SHOWCASE (split layout: settings column on the left, live preview pane on the right):
- API KEY BLOCK (top): input "PDLP API key" + "Apply" (hint: "public read-only key, not a secret"). On Apply, save the key and immediately load offers for the geo (country + lead type). Empty state before a key ("Paste your PDLP API key and click Apply to load your offers"). Changeable anytime. + explanation + "ask your AI builder" note.
- CONTENT & SORT block: Order-mode switch (Dynamic / Manual), Sort-preset dropdown (CR↓ default, EPC↓, CR↑, EPC↑), Country (pre-filled), Lead type, Smart-filter toggle (+ its two fields), Refresh-frequency ("every N visitors", default 500, minimum 100 — enforce it), "Refresh offers" button (forces immediate refetch). Any change re-pulls and updates the list + preview live.
  PER-PARAMETER HELP (required): EVERY single control in this block (each of Order-mode, Sort preset, Country, Lead type, Smart filter + its fields, Refresh frequency, Refresh button) has its OWN small info "(i)" icon next to its label. Clicking the (i) opens a short plain-language explanation of what that exact parameter does — implemented as a small popover/tooltip (a light card, e.g. yellow note style) or a tiny modal, with a close "✕" AND auto-close on any click outside it (and on Esc). Each explanation is one or two sentences in the admin-interface language. Examples: Sort preset → "Orders offers by network conversion (CR) or earnings (EPC); choose ascending or descending."; Smart filter → "Shows offers tailored to a visitor who came through the PDLP tracker with a given subid/utm value."; Refresh frequency → "How often the live offer list is re-fetched — once every N visitors (default 500, minimum 100) — to avoid overloading the API." Keep these (i) explanations consistent with the network's API meaning.
- OFFER LIST with MOUSE DRAG-AND-DROP reordering (Manual mode):
  * INFO BLOCK above the list (a small (i)/note): "This offer list and its order are saved in your database. Offer availability — e.g. when a lender turns an offer off — refreshes automatically based on the settings above (every N visitors)." Keep it short, in the admin language.
  * SEARCH box above the list: a quick FRONT-END filter over the CURRENTLY LOADED offers (not a database/API query) — typing filters the visible rows by offer name in real time so you can find one fast and act on it (drag, hero, hide). Clearing the search restores the FULL list. Search never changes what's saved or the order; it only filters the view.
  * Reordering is by MOUSE DRAG ONLY — no up/down buttons. The whole row is grabbable (dnd-kit / pointer sensors). While dragging, show a clear INSERTION INDICATOR (a highlighted gap/line) at the exact spot where the offer will land, and the dragged row lifts (shadow). On drop, the offer is placed immediately, the list and the live preview RE-RENDER at once, and a "Save" button (sticky) is the only thing left to click to persist. (Reordering by drag works on the full list; if a search filter is active, clear it before dragging so positions are unambiguous.)
  * Rows are COMPACT: small logo, name, hero rate (first_credit_percent), payouts WITH correct per-tier currency from `prices` (e.g. "CPS1 1190 UAH / CPS2 158 UAH"), CR (cr_general %), EPC (epc_general), own CR/ECPC if present, Hero toggle, Show/Hide toggle, NEW/REMOVED badges. Numbers normalized (comma→dot).
  * In Dynamic mode dragging is disabled (order follows the preset live); switch to Manual to drag-pin.
- DESIGN block — a SIMPLE THEME CUSTOMIZER editing the full base palette (this is how the look is set; there is no up-front theme question). Expose easy color pickers for the WHOLE palette so every element is covered: background, body text (foreground), card, secondary/muted text, primary + its button-text (primary-foreground), accent, border, success, and the chip color — plus background mode (Light/Dark), corner radius, and button style. A live contrast hint should warn if a chosen pair is too low-contrast. Provide one-click PRESETS, each defining the complete token set for correct light/dark contrast — at least 6: Light, Dark, Deep-blue, Emerald, Royal-purple, Sunset (add a couple more if easy). Every control applies INSTANTLY to the preview and recolors the SHOWCASE only (never the admin). Also here: branding (logo upload, title, tagline) and card-customization controls per my chosen level, and feature-badge curation per offer if my level allows. + explanations + "ask your AI builder" notes.
- LIVE PREVIEW PANE (right): the REAL public showcase using current order/hero/hidden/sort AND current theme/branding/cards/badges — updates instantly on any content or design change. It uses the exact same single layout as the live site (no device toggle, no separate mobile view).
- SAVE: one button persists api_key + order_mode/preset + hero + visibility + refresh + theme + branding + card overrides, and caches each offer snapshot. Smooth for 10–30 offers.

TAB 2 — STATISTICS: date-range picker (enforce ≤70 days). Summary cards: total visits, total clicks, overall CTR. Per-offer table: visits, clicks, CTR (sorted by clicks desc). A recharts clicks-over-time chart. Light and instant. + explanation on each block + "ask your AI builder" note.

No CTA-text control anywhere — CTA copy is automatic (see CTA TEXT).

# LEGAL & COMPLIANCE (REQUIRED — all in the showcase language, appropriate to the chosen country/geo)
Generate these so the showcase looks compliant out of the box. Everything here is localized to the SHOWCASE language (the geo's language) and written to fit the selected GEO's typical conventions (e.g. UA → Ukrainian-style consumer-finance/personal-data wording). Add a clear placeholder line in each page like "Template — review with your legal advisor before going live", and where a company name/contact is needed use editable placeholders (pull branding.title if set).
- PRIVACY POLICY page (route /privacy): standard personal-data policy text — what data is collected, why, retention, user rights, contacts — geo-appropriate.
- COOKIE POLICY page (route /cookies): what cookies/trackers are used (incl. the click params subid/utm/gclid passed through), purposes, how to opt out.
- TERMS & CONDITIONS page (route /terms): general terms for using the showcase / disclaimer that this is an information aggregator linking to third-party lenders, no lending decisions made here.
- All three are linked from the footer on every page, in the showcase language.
- DATA-CONSENT POPUP: on first visit show a consent banner/modal (typical wording: "We collect and process data, including cookies, to operate this site and pass referral parameters…") with an Accept button and links to the Privacy & Cookie policies. Remember the choice (localStorage) so it doesn't reappear; do not block offer rendering.
- CONTACT FORM (route /contact or a footer "Contact" section): a STUB with all the usual fields — name, email, phone, message, and a consent checkbox — plus a submit button. It does NOT send anywhere yet (no backend wiring): on submit show a localized "Thanks, we'll get back to you" confirmation and clear the form. Keep the fields and validation in place so it's ready to wire up later.

# PUBLIC SHOWCASE (/) — layout
- NO admin links/buttons anywhere on the public page by default — visitors must never see a way into the admin. Admin is reachable only via the /admin URL directly, plus a HIDDEN entry behind the logo: clicking the header logo area 5× (or long-press on mobile) navigates to /admin. Nothing about this is visible or labeled.
- Slim header: the publisher logo is HIDDEN by default (shown only if a branding logo is set), plus optional title/tagline and a short trust line. The (possibly invisible) logo area still serves as the hidden admin trigger.
- Standard offers always render TWO per row (the fixed 2-column layout — never collapses to one; below min width the page scrolls horizontally). A hero offer simply spans FULL WIDTH (both columns) in its place — the hero flag changes width/prominence only and never reorders or pins the offer; everything stays in the saved/preset order, only for currently-live offers.
- Card (standard) — COMPACT and clearly STRUCTURED IN BLOCKS, top to bottom:
  1. ONE colorful selling chip (microloan-domain, in the showcase language) at the very top.
  2. LOGO on top, centered.
  3. Name/title block: lender name + short marketing title.
  4. Key-facts block: loan amount, term (days), hero-rate badge (0.01%) — laid out as neat mini-stats.
  5. Collapsible legal info (collapsed).
  6. Primary CTA pinned to the bottom; its TEXT is auto-set by slot + language (see CTA TEXT — never user-chosen).
  Keep snippets compact (tight spacing, equal-height cards, CTA pinned bottom). Hero cards use the same block order but larger and more prominent (bigger logo/headline, "ТОП/Recommended"-style badge). NO payout/EPC/CR shown publicly.
- On load: render instantly from offers_cache (no direct PDLP call); capture params; call track-visit (increments the counter and refreshes from PDLP only when due); log one "visit" event per session.
- On CTA: log "click" with offer_id + params, then redirect to the built tracking link.
- Footer: a short disclaimer slot PLUS links to the legal pages — Privacy Policy, Cookie Policy, Terms & Conditions — and a Contact link/section (see LEGAL & COMPLIANCE). Fully responsive, fast (lazy-load logos), accessible.
- ENTIRE showcase (cards, the single badge, CTAs, footer, legal pages, consent popup, contact form) is rendered in the SHOWCASE language (geo's language); the ADMIN is rendered in the chosen admin-interface language. The two are independent.

# BUILD ORDER
1. Ask the STEP 0 questions; wait for answers.
2. Front end with the example offer JSON as mock data: both surfaces, themes, hero + standard cards with the single localized badge, drag/reorder/hero/hide, preview, attribution link-builder, stats UI, the legal pages (privacy/cookies/terms), the data-consent popup, and the contact-form stub — showcase content in the geo's language, admin in the chosen admin language.
3. Supabase: tables + RLS + the auto-generated admin password gate. No api_key anywhere yet — it's entered later in the admin "API key" block (empty until then).
4. Edge functions get-offers + track-visit + offers_cache; replace mock with cached live data, visitor-count-throttled refresh, and reconciliation.
5. Wire events logging (visit + click) and stats queries.
6. SELF-TEST everything (below) and fix any failures BEFORE telling me it's done.

# SELF-TEST BEFORE FINISHING (mandatory — do NOT report "ready" until all pass)
Before you finish and tell me the app is ready, exercise EVERY admin mode and the public flows end to end (use temporary mock/seed data where a live key isn't available, then remove it), and fix anything that fails. Run and confirm each of these, then show me a short pass/fail checklist:
1. API KEY BLOCK: empty state shows before a key; entering a key + Apply saves it and loads offers; clearing/changing the key works.
2. OFFER LOADING: offers load for the interview geo; the table renders logo, name, rate, amount, term, payouts WITH correct per-tier currency, CR, EPC; comma-decimals normalized; empty fields hidden (no blank rows).
3. SORT PRESETS: switching CR↓ / EPC↓ / CR↑ / EPC↑ re-pulls and reorders the table and preview correctly.
4. ORDER MODE & MOUSE DRAG: Dynamic = drag disabled, order follows preset; Manual = MOUSE drag-and-drop reorders (no buttons), an insertion indicator highlights the drop spot while dragging, on drop the offer is placed and the preview re-renders immediately, and clicking Save pins the order so the public showcase reflects it.
4b. LIST INFO & SEARCH: an info note explains the list is saved in the DB and availability refreshes per the settings above; the search box filters the currently-loaded list by name on the front end (not a DB query) in real time, lets you act on a found offer, and clearing it restores the full list without changing saved order.
5. HERO: first offer is hero by default; toggling hero on others (incl. the last) makes them span full-width WITHOUT changing their order/position; non-hero render exactly 2-per-row; hero cards show the subtle animated border/shadow + CTA effect, smooth and respecting prefers-reduced-motion. Snippets (fonts/paddings/buttons/gaps) are visibly compact (~30% tighter than default) yet readable and tappable.
6. HIDE: hidden offers disappear from the preview and public page; remaining cards reflow.
7. FEATURE BADGE: each card shows exactly ONE colorful selling chip at the top, in the showcase language, on-domain for microloans; auto-derived/default appears and (if customization allows) per-offer edit works; never overflows the card.
8. REFRESH THROTTLE: with refresh_every_n_visits (default 500), confirm the cache refreshes on threshold (simulate the counter) and not on every visit; values below the 100 minimum are rejected/clamped in both UI and backend; "Refresh offers" forces an immediate refetch; reconciliation drops missing offers and flags NEW ones.
9. THEMES & DESIGN: there are exactly TWO admin tabs (Showcase, Statistics); the whole showcase is configured on one screen; the theme customizer edits the FULL palette (background/foreground/card/muted/primary/primary-foreground/accent/border/success/chip + light-dark/radius/button) and ≥6 presets, live-recoloring the SHOWCASE/preview but NOT the admin chrome; editing branding/card-customization updates the live preview AND the public page.
9b. CONTRAST / DARK MODE: switch to a DARK theme/preset and confirm ALL text becomes light and readable — body, headings, card text, muted/secondary text, the rate value, the selling chip, the legal toggle, AND button text — with no dark-on-dark anywhere; repeat for each preset and for light mode; every foreground/background token pair meets WCAG-AA (≥4.5:1).
10. ATTRIBUTION: visiting /?subid=test&utm_source=fb builds CTA links ending with &subid=test&utm_source=fb while keeping offerid+affid; empty params skipped; offer.url's existing query string preserved.
11. STATS: a visit and a click each insert an events row and show up in Stats with correct CTR; date range enforces ≤70 days.
12. ADMIN ACCESS & SESSION: public page has NO visible admin link; /admin requires the auto-generated password; the hidden logo-click entry opens /admin; the final message reports the admin URL + password. CRITICAL session test: entering the CORRECT password lands inside the admin and STAYS there (it must NOT bounce back to the password screen); reloading /admin while logged in keeps you in (no redirect loop, no flicker); a wrong password is rejected; Logout returns to the password prompt and a protected route is then blocked. Verify the guard shows a loading state during session restore rather than redirecting.
13. CTA TEXT: button copy is automatic and correct for the SHOWCASE language; hero buttons use the hero phrasing, the first standard card uses its phrasing, and the rest cycle variants; changing order/hero updates the buttons; there is NO UI to edit CTA text anywhere.
14. FIXED LAYOUT / NO BROKEN LAYOUTS: verify the single fixed layout at several widths. WIDE screen → the 2-column grid stays at its MAX width and is CENTERED (does not stretch). MID screen → standard cards stay exactly 2 per row, hero full-width. NARROW screen (below the grid's min, e.g. ~360px) → the layout does NOT collapse to one column; instead the page scrolls HORIZONTALLY while keeping 2 columns. At every width: no overflow inside cards, no overlapping/clipped elements, equal-height cards, hero spans both columns. SPECIFICALLY check the two known defects: (a) the key-facts mini-stats (amount/term/rate) must NEVER overlap — labels and values stay in their own grid cells with gaps at all widths; (b) ALL CTA buttons in a row must be the SAME height even when one label is 1 line and another wraps to 2 (test a long label like "Получить одобрение" next to a short one). Verify with both long and short offer names/labels and in every showcase language. There must be NO separate phone/tablet/desktop rendering and no device toggle. If ANY breakage is detected, report exactly which width/element is broken and fix it before finishing.
15. EXPLANATIONS & PER-PARAMETER HELP: every admin block has a short explanation and the "ask your AI builder" note; in the Content & Sort block EVERY control has its own (i) icon whose popover/tooltip opens on click, closes via the ✕, on outside-click, and on Esc, and shows a correct one–two sentence description in the admin language.
16. LEGAL & CONSENT: Privacy, Cookie and Terms pages exist, are linked in the footer, and are written in the showcase language for the chosen geo; the data-consent popup shows on first visit, accepts and is remembered, and links to the policies.
17. CONTACT FORM: the stub renders name/email/phone/message + consent checkbox, validates, shows a localized confirmation on submit, and sends nowhere (no errors).
18. LANGUAGES: the ENTIRE showcase (cards, single badge, CTAs, footer, legal pages, consent popup, contact form) is in the GEO's language; the ADMIN panel is in the chosen admin-interface language; the two don't bleed into each other; geo→language mapping is correct for the chosen market.
Only after all checks (1–18, incl. 4b and 9b) pass do you announce completion, include the checklist with results, AND output the admin URL + generated password.

# ACCEPTANCE CRITERIA (must all hold, with no manual setup left for me)
- After I answer the questions, the app is fully built end-to-end; I don't have to create tables, manage any secret, or run anything by hand. The only thing I do in the running app is paste my API key into the admin "API key" block and click Apply, after which offers load.
- /?subid=test&utm_source=fb shows offers and every CTA ends with &subid=test&utm_source=fb while keeping offerid+affid intact.
- The showcase renders from the cache (no PDLP call per visit); the offer list refreshes every N visitors (default 500, minimum 100 enforced) so a disabled offer disappears automatically and a newly active one appears, without hammering PDLP. The admin "Refresh" button forces an immediate update.
- The admin opens pre-filled (default dynamic CR↓ sort, my chosen geo, lead type = Both) with an empty API-key block; pasting the key + Apply loads offers into the table immediately, with a live preview beside it; switching to Manual enables drag-to-pin order.
- The offer table shows CR, EPC and payouts in the correct per-tier currencies; the adjacent preview reflects both content and design (theme/branding/card) changes live.
- The first offer is hero by default; I can mark additional offers (e.g. the last) as hero. Hero cards span full width (both columns); standard offers render COMPACT, block-structured snippets (badge→logo→name→facts→CTA) in the fixed 2-column layout, with equal heights.
- Each card shows exactly ONE colorful selling chip at the top, in the showcase language and on-domain for microloans (e.g. "Без проверок", "Money in 15 min").
- Footer has localized Privacy Policy, Cookie Policy and Terms pages (geo-appropriate), a first-visit data-consent popup, and a contact-form stub (all fields + consent, sends nowhere). The whole showcase is in the chosen language.
- Reordering is by MOUSE drag-and-drop with a drop-position highlight and instant preview re-render; only Save remains to persist.
- The admin has exactly TWO tabs (Showcase, Statistics); the whole showcase is configured on one screen; color themes recolor the SHOWCASE only, not the admin; every block has an explanation + an "ask your AI builder" note.
- The public page shows NO admin link by default; admin opens only via the /admin URL or the hidden 5×-logo-click and is protected by an AUTO-GENERATED random password; the final message reports the admin URL + that password.
- The CTA button text is automatic (by offer slot + language), never asked or editable; hero/first/other slots use distinct phrasings.
- The build is self-tested: all self-test checks (1–18 + the dark-mode contrast check 9b) pass (incl. multi-device visual checks that explicitly flag any broken layout) and a pass/fail checklist is shown before completion is announced.
- No Supabase secret is created for the api_key; it's stored in the settings table and used only server-side by get-offers (it's a public read-only key, so this is fine).
- A visit and a click create rows in events and appear in Stats.
- Clean and unbroken at any width: one fixed layout, centered at max width, horizontal scroll below min width, always 1-or-2 cards per row (never collapses to one), no device toggle.
```

## ▲▲▲ THE PROMPT — copy to here ▲▲▲

---

### That's it
After you send the prompt, Lovable asks you the short list of questions, you answer, and it builds the whole thing — including a password-protected admin. When it finishes, it gives you the admin URL and an auto-generated password, and runs a self-test checklist. Open the admin, log in, paste your PDLP API key into the "API key" block, click Apply — your offers load on one screen with a live preview beside them. No login setup, secrets, or wiring needed. Publish in Lovable when you're happy, and optionally point your own domain at it later.
