Commit Graph

271 Commits

Author SHA1 Message Date
Deeman
80c2f111d2 feat(billing): B4-B5 — tests, lint fixes, CHANGELOG + PROJECT.md
- Fix unused imports in stripe.py (hashlib, hmac, time)
- Update test_billing_routes.py: insert into payment_products table,
  fix mock paths for extracted paddle.py, add Stripe webhook 404 test
- Update CHANGELOG.md with Stripe provider feature
- Update PROJECT.md Done section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:07:30 +01:00
Deeman
7ae8334d7a feat(billing): B3 — setup_stripe.py product/price creation script
Mirrors setup_paddle.py structure:
- Creates 17 products + prices in Stripe (same keys, same prices)
- Writes to payment_products table with provider='stripe'
- Registers webhook endpoint at /billing/webhook/stripe
- tax_behavior='exclusive' (price + VAT on top, EU standard)
- Supports --sync flag to re-populate from existing Stripe products

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:53:38 +01:00
Deeman
032fe8d86c feat(billing): B2 — Stripe payment provider implementation
billing/stripe.py exports the same interface as paddle.py:
- build_checkout_payload() → Stripe Checkout Session with automatic_tax
- build_multi_item_checkout_payload() → multi-line-item sessions
- cancel_subscription() → cancel_at_period_end=True
- get_management_url() → Stripe Billing Portal session
- verify_webhook() → Stripe-Signature header verification
- parse_webhook() → maps Stripe events to shared format:
  checkout.session.completed → subscription.activated / transaction.completed
  customer.subscription.updated → subscription.updated
  customer.subscription.deleted → subscription.canceled
  invoice.payment_failed → subscription.past_due

All API calls have 10s timeout and max 2 retries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:48:08 +01:00
Deeman
4907bc8b64 feat(billing): B1 — add stripe SDK dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:37:29 +01:00
Deeman
bf69270913 feat(billing): A6 — planner/supplier routes use get_price_id() + _provider()
- planner/routes.py: import get_price_id instead of get_paddle_price,
  export_checkout uses _provider().build_checkout_payload()
- suppliers/routes.py: all get_paddle_price → get_price_id,
  signup_checkout uses _provider().build_multi_item_checkout_payload(),
  dashboard boosts use get_all_price_ids() bulk load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:36:12 +01:00
Deeman
8f0a56079f feat(billing): A5 — dual-path JS templates for Paddle overlay / Stripe redirect
- New _payment_js.html: conditionally loads Paddle.js or nothing (Stripe
  uses server-side Checkout Session). Provides startCheckout() helper.
- All checkout templates use _payment_js.html instead of _paddle.html
- export.html, signup_step_4.html: Paddle.Checkout.open() → startCheckout()
- dashboard_boosts.html: inline onclick → buyItem() with server round-trip
- New /billing/checkout/item endpoint for single-item purchases (boosts, credits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:52 +01:00
Deeman
7af9b2c82c feat(billing): A2+A4 — extract paddle.py + dispatch layer in routes.py
- New billing/paddle.py: Paddle-specific functions (build_checkout_payload,
  cancel_subscription, get_management_url, verify_webhook, parse_webhook)
- routes.py: _provider() dispatch function selects paddle or stripe module
- Checkout/manage/cancel routes now delegate to _provider()
- /webhook/paddle always active (existing subscribers)
- /webhook/stripe endpoint added (returns 404 until Stripe configured)
- Shared _handle_webhook_event() processes normalized events from any provider
- _price_id_to_key() queries payment_products with paddle_products fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:26:47 +01:00
Deeman
276328af33 feat(billing): A1+A3 — payment_products table + provider-agnostic price lookups
- Migration 0028: create payment_products table, copy paddle_products rows
- Add STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRET config
- Make PAYMENT_PROVIDER read from env (was hardcoded "paddle")
- Add get_price_id() / get_all_price_ids() querying payment_products
- Keep get_paddle_price() as deprecated fallback alias

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:07:10 +01:00
Deeman
0fc0ca66b1 fix(i18n): replace smart quotes with straight quotes in sup_hero_sub
Some checks failed
CI / test (push) Failing after 29s
CI / tag (push) Has been skipped
Curly quotes (U+201C/U+201D) were used as JSON key/value delimiters
on line 894 of both locale files, making them invalid JSON.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:53:39 +01:00
Deeman
695e956501 feat(cro): German translations for all CRO copy changes
Native-quality DE translations for homepage + supplier page:
- Hero, ROI, features, FAQ, final CTA, meta/SEO
- Proof strip, struggling moments, "Why Padelnomics" comparison
- Supplier proof points, ROI line, struggling moments, pricing CTAs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:42:16 +01:00
Deeman
a862d21269 feat(cro): supplier page CRO — struggling moments, conditional stats, honest proof
Task 3: Add "Is this your sales team?" struggling-moments section.
Conditional stats display (hide if below thresholds). Replace anonymous
testimonials with data-backed proof points. Tier-specific pricing CTAs.
Tighter hero sub-headline. Move ROI callout above pricing grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:36:46 +01:00
Deeman
f4f8a45654 feat(cro): homepage structural overhaul — proof strip, struggling moments, comparison
Task 2: Remove journey timeline (3 "SOON" badges = incomplete signal).
Add proof strip below hero with live stats. Add "Sound familiar?"
section with 4 JTBD struggling-moment cards. Add "Why Padelnomics"
3-column comparison (DIY vs consultant vs us). Update hero secondary
CTA and supplier matching links to /quote. Route handler now passes
calc_requests and total_budget_millions to template.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:33:43 +01:00
Deeman
9e471f8960 feat(cro): rewrite homepage EN copy — outcome-focused JTBD framing
Task 1: Hero, features, FAQ, final CTA, supplier matching, meta/SEO
strings all rewritten. New keys added for proof strip, struggling-
moments section, and "Why Padelnomics" comparison section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:31:24 +01:00
Deeman
34f8e45204 merge(articles): iframe preview + collapsible meta + word count 2026-03-02 12:09:04 +01:00
Deeman
6b9187f420 fix(articles): iframe preview + collapsible meta + word count
Replace the auto-escaped `{{ body_html }}` div (showed raw HTML tags)
with a sandboxed `<iframe srcdoc>` pattern matching the email preview.
Both the initial page load and the HTMX live-update endpoint now build
a full `preview_doc` document embedding the public CSS and wrapping
content in `<div class="article-body">` — pixel-perfect against the
live article, admin styles fully isolated.

Also:
- Delete ~65 lines of redundant `.preview-body` custom CSS
- Add "Meta ▾" toolbar toggle to collapse/expand metadata strip
- Add word count footer in the editor pane (updates on input)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:01:16 +01:00
Deeman
94d92328b8 merge: fix article .md lookup + lighter editor
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 11:47:13 +01:00
Deeman
100e200c3b fix(articles): find .md by slug scan + lighter editor theme
Two fixes:
- _find_article_md() scans _ARTICLES_DIR for files whose frontmatter
  slug matches, so padel-halle-bauen-de.md is found for slug
  'padel-halle-bauen'. The previous exact-name lookup missed any file
  where the filename ≠ slug (e.g. {slug}-{lang}.md naming convention).
- Editor pane: replace dark navy background with warm off-white (#FEFDFB)
  and dark text so it reads like a document, not a code editor.
2026-03-02 11:43:26 +01:00
Deeman
70628ea881 merge(pipeline-transform-tab): split article editor + frontmatter fix + transform tab features
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
2026-03-02 11:34:13 +01:00
Deeman
d619f5e1ef feat(articles): split editor with live preview + fix frontmatter bug
Bug: article_edit GET was passing raw .md file content (including YAML
frontmatter) to the body textarea. Articles synced from disk via
_sync_static_articles() had their frontmatter bled into the editor,
making it look like content was missing or garbled.

Fix: strip frontmatter (using existing _FRONTMATTER_RE) before setting
body, consistent with how _rebuild_article() already does it.
Also switch to _ARTICLES_DIR (absolute) instead of relative path.

New: split editor layout — compact metadata strip at top, dark
monospace textarea on the left, live rendered preview on the right
(HTMX, 500ms debounce). Initial preview server-rendered on page load.
New POST /admin/articles/preview endpoint returns the preview partial.
2026-03-02 11:10:01 +01:00
Deeman
2a7eed1576 merge: test suite compression pass (-197 lines)
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 10:46:01 +01:00
Deeman
162e633c62 refactor(tests): compress admin_client + mock_send_email into conftest
Lift admin_client fixture from 7 duplicate definitions into conftest.py.
Add mock_send_email fixture, replacing 60 inline patch() blocks across
test_emails.py, test_waitlist.py, and test_businessplan.py. Net -197 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:40:52 +01:00
Deeman
31017457a6 merge: semantic-compression — add compression helpers, macros, and coding philosophy
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
Applies Casey Muratori's semantic compression across all three packages:
- count_where() helper: 30+ COUNT(*) call sites compressed
- _forward_lead(): deduplicates lead forward routes
- 5 SQLMesh macros for country code patterns (7 models)
- skip_if_current() + write_jsonl_atomic() extract helpers
Net: -118 lines (272 added, 390 removed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:00:15 +01:00
Deeman
b32b7cd748 merge: unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminates confirmAction() entirely. One code path: all confirmations
go through showConfirm() called by the htmx:confirm interceptor.
14 template files converted to hx-boost + hx-confirm pattern.
Pipeline endpoints updated to exclude HX-Boosted requests from the
HTMX partial path.

# Conflicts:
#	web/src/padelnomics/admin/templates/admin/affiliate_form.html
#	web/src/padelnomics/admin/templates/admin/affiliate_program_form.html
#	web/src/padelnomics/admin/templates/admin/base_admin.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_program_results.html
#	web/src/padelnomics/admin/templates/admin/partials/affiliate_row.html
2026-03-02 07:48:49 +01:00
Deeman
e87a7fc9d6 refactor(admin): extract _forward_lead() from duplicate lead forward routes
Task 3/6: lead_forward and lead_forward_htmx shared ~20 lines of
identical DB logic. Extracted into _forward_lead() that returns an
error string or None. Both routes now call the helper and differ
only in response format (redirect vs HTMX partial).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:43:50 +01:00
Deeman
3d7a72ba26 refactor: apply count_where() across remaining web blueprints
Task 2/6 continued: Compress 18 more COUNT(*) call sites across
suppliers, directory, dashboard, public, planner, pseo, and pipeline
routes. -24 lines net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:40:24 +01:00
Deeman
a55501f2ea feat(core): add count_where() helper, compress admin COUNT queries
Task 2/6: Adds count_where(table_where, params) to core.py that
compresses the fetch_one + null-check COUNT(*) pattern. Applied
across admin/routes.py — dashboard stats shrinks from ~75 to ~25
lines, plus 10 more call sites compressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:35:33 +01:00
Deeman
d3626193c5 refactor(admin): unify confirm dialog — pure hx-confirm + form[method=dialog]
Eliminate `confirmAction()` and the duplicate `cloneNode` hack entirely.
One code path: everything goes through `showConfirm()` called by the
`htmx:confirm` interceptor.

Dialog HTML:
- `<form method="dialog">` for native close semantics; button `value`
  becomes `dialog.returnValue` — no manual event listener reassignment.

JS:
- `showConfirm(message)` — Promise-based, listens for `close` once.
- `htmx:confirm` handler calls `showConfirm()` and calls `issueRequest`
  if confirmed. Replaces both the old HTMX handler and `confirmAction()`.

Templates (Padelnomics, 14 files):
- All `onclick=confirmAction(...)` and `onclick=confirm()` removed.
- Form-submit buttons: added `hx-boost="true"` to form + `hx-confirm`
  on the submit button.
- Pure HTMX buttons (pipeline_transform, pipeline_overview): `hx-confirm`
  replaces `onclick=if(!confirm(...))return false;`.

Pipeline routes (pipeline_trigger_extract, pipeline_trigger_transform):
- `is_htmx` now excludes `HX-Boosted: true` requests — boosted form
  POSTs get the normal redirect instead of the inline partial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 07:35:32 +01:00
Deeman
c1cf472caf fix(admin): guard htmx:confirm handler against empty question
The handler called evt.preventDefault() unconditionally, so auto-poll
requests (hx-trigger="every 5s", no hx-confirm) caused an empty dialog
to pop up every 5 seconds. Add an early return when evt.detail.question
is falsy so only actual hx-confirm interactions are intercepted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:39:38 +01:00
Deeman
f9e22a72dd merge: fix CI — update proxy tests for 2-tier design
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:36:35 +01:00
Deeman
ce466e3f7f test(proxy): update supervisor tests for 2-tier proxy (no Webshare)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:36:30 +01:00
Deeman
9608b7f601 feat(admin): replace all native confirm() with styled dialog + fix pipeline tabs scrollbar
Some checks failed
CI / tag (push) Has been cancelled
CI / test (push) Has been cancelled
- Add global htmx:confirm handler in base_admin.html that intercepts
  hx-confirm attributes and shows #confirm-dialog instead of window.confirm()
- Convert 4 pipeline HTMX buttons (Run Transform, Run Export, Run Full
  Pipeline, Run extractor) from onclick+confirm() to hx-confirm
- Convert 4 affiliate form/list delete buttons from onclick+confirm()
  to confirmAction() via event.preventDefault()
- Add scrollbar-width:none + ::-webkit-scrollbar{display:none} to
  .pipeline-tabs to suppress spurious horizontal scrollbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 19:47:34 +01:00
Deeman
8a28b94ec2 merge: visual upgrades for longform articles (timeline, callouts, cards, severity pills) 2026-03-01 14:28:57 +01:00
Deeman
bce6b2d340 feat(articles): visual upgrades — timeline, callouts, cards, severity pills
Add 4 reusable CSS article components and apply them across 6 cornerstone articles:

CSS (input.css):
- article-timeline: horizontal phase diagram with numbered cards, collapses to vertical on mobile
- article-callout (warning/tip/info): left-bordered callout boxes with icon and title
- article-cards: 2-col grid of accent-topped cards (success/failure/neutral/established/growth/emerging)
- severity: inline pill badges (high/medium-high/medium/low-medium/low) for risk tables

Articles updated:
- padel-hall-build-guide-en + padel-halle-bauen-de: ASCII code block → timeline HTML; 3 bold/blockquote warnings → callout boxes; success/failure patterns → 4 cards
- padel-hall-investment-risks-en + padel-halle-risiken-de: risk overview table severity → pills; personal guarantee section → callout; risk management section → 4 cards
- padel-hall-location-guide-en + padel-standort-analyse-de: market maturity paragraphs → 3 stage cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:24:11 +01:00
Deeman
e5cbcf462e feat(pipeline): live extraction status + Transform tab
- worker: add run_transform, run_export, run_pipeline task handlers
  - run_transform: sqlmesh plan prod --auto-apply, 2h timeout
  - run_export: export_serving.py, 10min timeout
  - run_pipeline: sequential extract → transform → export, stops on first failure

- pipeline_routes: refactor overview into _render_overview_partial() helper,
  make pipeline_trigger_extract() HTMX-aware (returns partial on HX-Request),
  add _fetch_pipeline_tasks(), _format_duration() helpers,
  add pipeline_transform() + pipeline_trigger_transform() with concurrency guard

- pipeline_overview.html: wrap in self-polling div (every 5s while any_running),
  convert Run buttons to hx-post targeting #pipeline-overview-content

- pipeline.html: add pulse animation for .status-dot.running, add Transform tab
  button, rewire header "Run Pipeline" button to enqueue run_pipeline task

- pipeline_transform.html: new partial — status cards for transform + export,
  "Run Full Pipeline" card, recent runs table with duration + error details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:46:11 +01:00
Deeman
169092c8ea fix(admin): make pipeline data view responsive on mobile
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
- Tab bar: add overflow-x:auto so 5 tabs scroll on narrow screens
- Overview grid: replace hardcoded 1fr 1fr with .pipeline-two-col (stacks below 640px)
- Overview tables: wrap Serving Tables + Landing Zone in overflow-x:auto divs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:16:58 +01:00
Deeman
6ae16f6c1f feat(proxy): per-proxy dead tracking in tiered cycler
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-01 12:37:00 +01:00
Deeman
8b33daa4f3 feat(content): remove artificial 500-article generation cap
- fetch_template_data: default limit=0 (all rows); skip LIMIT clause when 0
- generate_articles: default limit=0
- worker handle_generate_articles: default to 0 instead of 500
- Remove "limit": 500 from all 4 enqueue payloads
- template_generate GET handler: use count_template_data() instead of fetch(limit=501) probe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:33:58 +01:00
Deeman
a898a06575 feat(proxy): per-proxy dead tracking in tiered cycler
Add proxy_failure_limit param to make_tiered_cycler (default 3).
Individual proxies hitting the limit are marked dead and permanently
skipped. next_proxy() auto-escalates when all proxies in the active
tier are dead. Both mechanisms coexist: per-proxy dead tracking removes
broken individuals; tier-level threshold catches systemic failure.

- proxy.py: dead_proxies set + proxy_failure_counts dict in state;
  next_proxy skips dead proxies with bounded loop; record_failure/
  record_success accept optional proxy_url; dead_proxy_count() added
- playtomic_tenants.py: pass proxy_url to record_success/record_failure
- playtomic_availability.py: _worker returns (proxy_url, result);
  serial loops in extract + extract_recheck capture proxy_url
- test_supervisor.py: 11 new tests in TestTieredCyclerDeadProxyTracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 12:28:54 +01:00
Deeman
8f2ffd432b fix(admin): correct docker volume mount + pipeline_routes repo root
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 2s
- docker-compose.prod.yml: fix volume mount for all 6 web containers
  from /opt/padelnomics/data (stale) → /data/padelnomics (live supervisor output);
  add LANDING_DIR=/app/data/pipeline/landing so extraction/landing stats work
- pipeline_routes.py: fix _REPO_ROOT parents[5] → parents[4] so workflows.toml
  is found in dev and pipeline overview shows workflow schedules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 11:41:29 +01:00
Deeman
c9dec066f7 fix(admin): mobile UX fixes — contrast, scroll, responsive grids
- CSS: `.nav-mobile a` → `.nav-mobile a:not(.nav-auth-btn)` to fix Sign
  Out button showing slate text instead of white on mobile
- base_admin.html: add `overflow-y: hidden` + `scrollbar-width: none` to
  `.admin-subnav` to eliminate ghost 1px scrollbar on Content tab row
- routes.py: pass `outreach_email=EMAIL_ADDRESSES["outreach"]` to outreach
  template so sending domain is no longer hardcoded
- outreach.html: display dynamic `outreach_email`; replace inline
  `repeat(6,1fr)` grid with responsive `.pipeline-status-grid` (2→3→6 cols)
- index.html: replace inline `repeat(5,1fr)` Lead/Supplier Funnel grids
  with responsive `.funnel-grid` class (2 cols mobile, 5 cols md+)
- pipeline.html: replace inline `repeat(4,1fr)` stat grid with responsive
  `.pipeline-stat-grid` (2 cols mobile, 4 cols md+)
- 4 partials (lead/email/supplier/outreach results): wrap `<table>` in
  `<div style="overflow-x:auto">` so tables scroll on narrow screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 11:20:46 +01:00
Deeman
849dc8359c feat(affiliate): affiliate programs management + frontmatter bugfix
Centralises retailer config in affiliate_programs table (URL template,
tracking tag, commission %). Products now use program dropdown + product
identifier instead of manual URL baking. URL assembled at redirect time
via build_affiliate_url() — changing a tag propagates to all products
instantly. Backward compatible: legacy baked-URL products fall through
unchanged. Amazon OneLink (configured in Associates dashboard) handles
geo-redirect to local marketplaces with no additional programs needed.

Also fixes _rebuild_article() frontmatter rendering bug.

Commits: fix frontmatter, migration 0027, program CRUD functions,
redirect update, admin CRUD + templates, product form update, tests.
41 tests, all passing. Ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:54:28 +01:00
Deeman
ec839478c3 feat(affiliate): add program + URL assembly tests; update CHANGELOG + PROJECT.md
41 tests total (+15). New coverage: get_all_programs(), get_program(),
get_program_by_slug(), build_affiliate_url() (program path, legacy fallback,
no program_id, no program dict), program-based redirect, legacy redirect,
migration seed assertion, ASIN backfill assertion. All ruff checks pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:51:26 +01:00
Deeman
47acf4d3df feat(affiliate): product form uses program dropdown + product identifier
Replaces the manual affiliate URL field with a program selector and
product identifier input. JS toggles visibility between program mode and
manual (custom URL) mode. retailer field is auto-populated from the
program name on save. INSERT/UPDATE statements include new program_id
and product_identifier columns. Validation accepts program+ID or manual
URL as the URL source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:43:10 +01:00
Deeman
53117094ee feat(affiliate): admin CRUD for affiliate programs
Adds program list, create, edit, delete routes with appropriate guards
(delete blocked if products reference the program). Adds "Programs" tab
to the affiliate subnav. New templates: affiliate_programs.html,
affiliate_program_form.html, partials/affiliate_program_results.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:32:45 +01:00
Deeman
6076a0b30f feat(affiliate): use build_affiliate_url() in /go/<slug> redirect
Program-based products now get URLs assembled from the template at
redirect time. Changing a program's tracking_tag propagates instantly
to all its products without rebuilding. Legacy products (no program_id)
still use their baked affiliate_url via fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:27:57 +01:00
Deeman
8dbbd0df05 feat(affiliate): add program CRUD functions + build_affiliate_url()
Adds get_all_programs(), get_program(), get_program_by_slug() for admin
CRUD. Adds build_affiliate_url() that assembles URLs from program template
+ product identifier, with fallback to baked affiliate_url for legacy
products. Updates get_product() to JOIN affiliate_programs so _program
dict is available at redirect time. _parse_product() extracts program
fields into nested _program key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:23:53 +01:00
Deeman
b1eeb0a0ac feat(affiliate): add affiliate_programs table + migration 0027
Creates affiliate_programs for centralised retailer config (URL template,
tracking tag, commission %). Adds nullable program_id + product_identifier
to affiliate_products for backward compat. Seeds "Amazon" program with
oneLink template. Backfills existing products by extracting ASINs from
baked affiliate_url values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:23:00 +01:00
Deeman
6aae92fc58 fix(admin): strip YAML frontmatter before mistune in _rebuild_article()
Fixes a bug where manual article previews rendered raw frontmatter
(title:, slug:, etc.) as visible text. Now strips the --- block using
the existing _FRONTMATTER_RE before passing the body to mistune.html().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:17:44 +01:00
Deeman
5de0676f44 merge: editorial review pass — all 12 articles + 3 pSEO templates 2026-02-28 21:55:32 +01:00
Deeman
81ec8733c7 fix(admin): DE/EN chips in article list link to live article, not edit
Live chips now open the article in a new tab. Draft/scheduled chips are
non-clickable spans (informational only). The Edit button is the sole
path to the edit page, removing the redundant double-link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:52:23 +01:00