The b04b36a2 fix (loading=true initially) wasn't enough on its own:
once loadCard() got 401, apiClient cleared tokens, scheduled the
redirect, and threw. The caller's catch logged the error and the
finally block ran `loading = false` before the browser actually
navigated away — so Alpine re-rendered with loading=false + card=null
and the "Rejoignez notre programme" CTA flashed for a beat.
Fix: in apiClient's 3 401 paths, when redirectIfCustomerAreaUnauthorized
returns true (meaning a navigation was scheduled), return a
never-resolving promise instead of throwing. The caller's await never
returns, their .finally() never fires, the loading spinner stays up,
and the browser navigates cleanly with no intermediate render.
Other personas (admin/store/merchant) — where the helper returns false
because the path doesn't match /account/* — still get the existing
throw, preserving their current behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loyalty dashboard's "Rejoignez notre programme" CTA flashed for one
render tick on a 401-triggered redirect: Alpine initialised the
component with loading=false + card=null, the template rendered
`x-show="!loading && !card"`, then the async API call completed with
401, apiClient.redirectIfCustomerAreaUnauthorized fired, and the
browser navigated away.
Flip the initial state to loading=true so both the card view
(x-show="!loading && card") and the join CTA (x-show="!loading &&
!card") stay hidden until the API call resolves. The template's
existing `x-show="loading"` spinner branch covers the in-flight
window.
Same fix in loyalty-history.js (same x-show pattern). Customer
profile + addresses already initialise loading=true, so no flicker
there.
User repro'd by deleting localStorage.customer_token + F5 on
/account/loyalty: pre-fix flashed the CTA for ~half a second before
redirect; post-fix should jump straight to the spinner, then to
/account/login.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The customer-area used to be mounted at /shop/* and was migrated to
/storefront long ago, but apiClient.js still carried the dead /shop/
checks alongside the live ones added in a0ae6388. Removed:
- /shop/ + /api/v1/shop/ predicates from getToken()'s customer-area
branch (lines 62-63).
- Same predicates from clearTokens()'s customer-area branch
(lines 409-410).
- Updated both functions' JSDoc to list the actual live paths
(/account/* + /api/v1/storefront/*) and to mention the /merchants/*
branch that was already in code but missing from the comment.
No behaviour change — verified zero callers via grep across app/,
static/, middleware/. The /shop/ branches always evaluated false in
production.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the customer's JWT (30-min TTL via JWT_EXPIRE_MINUTES) expires in
localStorage, subsequent API calls from a customer-area page returned
401 → callers showed an unrelated error UI (loyalty dashboard rendered
the "join now" CTA because card came back null on the catch path).
Three changes in static/shared/js/api-client.js:
1. Path detection in getToken() + clearTokens() now recognises
/account/* and /api/v1/storefront/* as customer-area routes (the
only existing checks were for /shop/* which was never used in this
codebase). Also clears customer_user alongside customer_token.
2. New redirectIfCustomerAreaUnauthorized() helper: on a /account/*
page, sends the browser to /account/login?next=<current path>
(with a guard to skip the redirect when already on the login page,
avoiding loops). Called from all three 401 paths (request,
requestFormData, getBlob).
3. login.html now honours the ?next= query param (in addition to the
legacy ?return=), so the redirect lands the user back where their
session expired.
Other personas (admin/store/merchant) are unaffected — the helper is
a no-op outside /account/*.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2026-05-18 cache-busting system was only catching a fraction of
includes because:
1. FE-024 anti-pattern only matched `'<module>_static'` mount names
(e.g. `'core_static'`, `'billing_static'`). The bare `'static'`
mount — which is what every persona base.html uses for shared JS,
CSS, and Tailwind output — never matched.
2. The rule explicitly excluded `base.html` files, which are exactly
where most of the shared JS/CSS includes live.
User reported only a handful of files had `?v=` in their Network tab.
Swept 5 persona base.html + 15 standalone templates (login/register/
forgot/reset password, error pages, onboarding, invitation-accept,
admin module-info/config, etc.) — 53 url_for('static', ...) refs for
.js/.css converted to static_v(request, 'static', ...).
Then tightened FE-024:
- Added an anti-pattern for the bare `'static'` mount.
- Dropped `base.html` from exceptions (kept `partials/`).
Re-running the validator: same 126-warning baseline, 0 FE-024 hits.
Now every deploy flips the `?v=<sha>` query string on every shared
asset; browsers refetch automatically without a hard refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs from Test 5.1 on FR storefront dashboard:
1. Loyalty + Orders dashboard cards (`StorefrontDashboardCard.title`/
`subtitle`/`value_label`) were hardcoded English. Added `language`
to `WidgetContext`; customer dashboard route passes
`request.state.language` through; loyalty and orders widget
providers now call `translate(..., context.language)` with new
`widget.*` i18n keys × 4 locales each.
2. Customer-module locale JSON has redundant top-level `customers`
wrapper, so after the module-locale loader auto-namespaces under
module code `customers`, the actual key path is
`customers.customers.customer_number` (matches the existing
`loyalty.loyalty.wallet.apple` pattern). My earlier sweep used the
single-prefix path for 8 references — fixed all to double-prefix.
Both bugs were visible end-of-day yesterday after the api container
recreate landed `1bade6e6`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five-issue triage shipped as four commits today: storefront i18n sweep
(10a99f98), FR password_reset accents + store-name signature
(b463c6bf), DE password_reset umlauts (36fd3781), Alpine x-text quoting
fix (1bade6e6), plus a seed-script sys.path fix (213a6053) hit during
the prod reseed. Test 5.0 (forgot-password end-to-end on FR) verified
end-of-day; Test 5 proper (login + dashboard + history) blocks on
recreating the prod api container tomorrow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Script lived at scripts/seed/seed_email_templates_core.py and inserted
Path(__file__).parent.parent into sys.path — that resolves to
scripts/, not the project root, so `from app.core.database import get_db`
raises ModuleNotFoundError when run from inside the container.
The loyalty sibling already uses parent.parent.parent correctly.
Hit during the FR/DE password_reset reseed on prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the end-of-day wrap skill so it works for any session topic
(not just the loyalty E2E walkthrough) and is safe to run with other
agents working on the same repo concurrently. Detects the session's
topic, creates a memory file if no matching one exists, only edits a
proposal doc if one already exists for the topic, and pushes via
fetch + pull --rebase + retry-once instead of bare push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dashboard unread-messages count crashed Alpine with
"expected expression, got '}'" because I emitted Jinja {{ _(...) | tojson }}
directly inside x-text="..." — the JSON's double quotes broke out of the
attribute, leaving Alpine to parse a malformed expression.
The messages card has its own nested x-data="{ unreadCount: 0 }" scope,
so the parent component's i18n property isn't reachable. Moved the
singular/plural strings onto window.__accountDashboardI18n (added next
to logoutSuccess/logoutFailed) and referenced them by global path from
x-text — no quoting collision and the nested scope sees window fine.
Other touched templates (login/register/forgot/reset-password) already
use x-data='...' (single-quoted outer attribute) for their |tojson
language-selector args, so this collision only existed in dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same accent-stripped pattern as the FR fix in b463c6bf: the DE
password_reset template was missing every umlaut. Restored throughout
the name, description, subject, body_html, and body_text:
- zurucksetzen → zurücksetzen
- Zurucksetzung → Zurücksetzung
- Passwortzurucksetzung → Passwortzurücksetzung
- Schaltflache → Schaltfläche
- lauft → läuft
- konnen → können
- Grussen → Grüßen
Same deploy step: re-run scripts/seed/seed_email_templates_core.py on
prod to upsert the existing row in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the password_reset email body had unaccented French
("demande" instead of "demandé", "L'equipe" instead of "L'équipe") and
the signature was the generic "L'équipe" without the store name.
FR template was missing accents throughout — fixed all of them:
Envoye→Envoyé, Reinitialiser→Réinitialiser, recu→reçu, reinitialisation
→réinitialisation, creer→créer, demande→demandé, equipe→équipe.
Signature on all 4 locales now includes {{ store_name }} (auto-injected
by EmailService.get_branding), so users see "L'équipe Fashion Hub" /
"The Fashion Hub Team" / "Das Fashion Hub Team" / "D'Fashion Hub Team"
instead of an unbranded "The Team".
The seeder is idempotent (upsert on code+language), so re-running
seed_email_templates_core.py on prod will update the existing rows in
place — no DB wipe needed.
Note: DE template still has missing umlauts (zurucksetzen→zurücksetzen,
Schaltflache→Schaltfläche, lauft→läuft, etc.) — left for a separate
DE/LB quality sweep since the user only reported FR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Test 5 (storefront password reset + customer dashboard) surfaced five
issues that all traced back to missing i18n plumbing:
- Forgot-password email arrived in EN regardless of storefront locale —
handler now prefers request.state.language over customer.preferred_language,
and loyalty self-enrollment backfills preferred_language for new + returning
customers so future locale-sensitive flows hit the right language without
being told twice.
- reset-password.html rendered "undefined" icon boxes because $icon magic
wasn't loaded in the standalone page — replaced with inline SVGs matching
the forgot-password.html convention.
- reset-password.html was hardcoded English: added lang attr, full _()
sweep (22 new auth.* keys × 4 locales), language selector, and JS
validation strings exposed via tojson.
- "Continue shopping" CTA renamed to "Back to Home" (auth.back_to_home,
4 locales) on login + forgot + reset — loyalty storefronts have no
catalog to continue to, mirroring the earlier enroll-success rename.
- /account dashboard, profile, addresses were hardcoded English in the
body (menu was FR because base layout uses _()). New customers.storefront
.pages.{dashboard,profile,addresses}.* namespace (~80 keys × 4 locales),
templates updated, Alpine JS strings injected via window.__*I18n.
18 files, 18 changed; arch validation: 126 warnings before = 126 after,
mkdocs --strict clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a post-audit section to the persona-template consolidation audit
capturing what came out of the in-prod card-detail test on
rewardflow.lu vs fashionhub.rewardflow.lu:
- Template alignment != data alignment: shared partial guarantees the
markup is the same per persona, NOT that the API response is.
Loyalty's category column rendered empty on merchant + admin
because only the store route enriched category_names. Future
migrations should diff API response shapes per persona, not just
templates. Fixed in d32c1fd5.
- Locale-aware formatters are infrastructure, not per-feature. The
hardcoded 'en-US' bug spanned 27 callsites across 20+ files. Now
swept (dd1f9af8 + 06e59f73 + bb4c4004) and locked down by the
JS-016 architecture rule at error severity (eaf180c6).
- Sweep + rule, not just sweep. Each cleanup should land with a
matching arch rule so the work doesn't decay. Table of the three
rules currently guarding this surface (TPL-016, FE-024, JS-016).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architecture rule that fails CI on any new toLocaleDateString /
toLocaleString / toLocaleTimeString / new Intl.* call that hardcodes
'en-US' instead of using I18n.locale. The whole codebase was cleaned
in the preceding commits (06e59f73, bb4c4004, dd1f9af8) so the rule
ships at error severity from day one.
- Rule definition in .architecture-rules/frontend.yaml under
javascript_rules; exceptions: i18n.js (defines the helper), vendor/.
- _check_hardcoded_locale in scripts/validate/validate_architecture.py
wired into both JS validation sites (full scan + per-file -f mode).
- Suppressible per-line with `// noqa: JS-016` for the rare case where
a specific locale is genuinely required (e.g., a US-only invoice
formatter that must use en-US regardless of UI language).
Validator output: 0 JS-016 hits across the codebase. Negative-tested
with a planted violation — rule fires correctly and clears on
removal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 06e59f73 which swept non-loyalty modules. The earlier
loyalty fix (dd1f9af8) only touched the shared/ factories; persona-
specific JS files in loyalty's admin/, merchant/, store/, and
storefront/ dirs were missed and still hardcoded 'en-US'.
13 occurrences across 8 files now use I18n.locale:
- admin: loyalty-analytics.js, loyalty-merchant-detail.js,
loyalty-programs.js
- merchant: loyalty-analytics.js
- store: loyalty-analytics.js, loyalty-terminal.js
- storefront: loyalty-dashboard.js, loyalty-history.js
After this commit grep -rn "'en-US'" --include=*.js across the whole
repo returns nothing. Clearing the deck so the JS-016 rule can ship
at error severity in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to dd1f9af8 which fixed loyalty. The same hardcoded 'en-US'
in toLocaleDateString / toLocaleString / Intl.NumberFormat appeared in
13 files across catalog, marketplace, orders, tenancy, inventory,
monitoring, cms, the storefront layout, and the shared Utils helper
itself. After this sweep, no non-loyalty JS hardcodes 'en-US' anymore;
all use I18n.locale and respect the user's dashboard language.
The highest-leverage one is static/shared/js/utils.js (Utils.formatDate
/ Utils.formatDateTime / Utils.formatCurrency / Utils.formatNumber) —
those four helpers are called from across the frontends so this one
edit fixes most secondary callsites for free.
Codemod scope was conservative: only replaced 'en-US' when it
appeared as the first argument to toLocale* or new Intl.* calls, to
avoid touching unrelated occurrences (none found, but the guard
matters if more get added).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update.
- Test 4 (cross-store redemption) verified: card #5's transaction
history now spans store_id=4 (FASHIONHUB, all the earnings) and
store_id=5 (FASHIONOUTLET, today's -100 redemption). Cross-location
flow confirmed.
- Bug found + fixed (478c3a9c) on the storefront auth API. Both
POST /api/v1/storefront/auth/forgot-password and .../reset-password
declared bare `email: str` / `reset_token: str, new_password: str`
params, which FastAPI treats as query strings. The frontend sends
JSON body, so the call 422'd with "missing query parameter email".
Added PasswordResetRequest + PasswordResetConfirm Pydantic body
schemas; switched both endpoints to body: <Schema>. Surfaced
trying to test Test 5's customer login flow.
- /loyalty-wrap skill committed (d03b96da) — mechanises the end-of-day
routine. First invokable as /loyalty-wrap tomorrow (skills load at
session start).
Carries Test 5 into next session (now unblocked by the auth fix), plus
a new TODO from the user: transaction categories should be creatable
by merchants and store owners, not admin-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/v1/storefront/auth/forgot-password and .../reset-password were
both declared with bare `email: str` / `reset_token: str, new_password: str`
parameters. FastAPI treats unannotated str params as query parameters, so
the frontend's JSON body was ignored and the endpoint 422'd with
"missing query parameter 'email'". The docstrings on both endpoints
already said "Request Body" — intent was clear, implementation drifted.
Add two new Pydantic body schemas in tenancy/schemas/auth.py:
PasswordResetRequest { email: str } (forgot)
PasswordResetConfirm { reset_token: str, new_password: str } (reset)
Re-export from tenancy/schemas/__init__.py, import in
customers/routes/api/storefront.py, and switch both endpoint signatures
to take `body: <Schema>`. Internal usage reads body.email / body.reset_token
/ body.new_password.
Surfaced during Test 5 when user clicked "forgot password" on the customer
storefront login page to set a password for the first time after a
self-enrollment flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dates rendered in English even when the dashboard language was set to
French (or any other locale). The 5 shared loyalty Alpine factories
hardcoded 'en-US' in every toLocaleDateString / toLocaleString /
Intl.NumberFormat call, ignoring the user's selected language.
- Add `I18n.locale` getter to static/shared/js/i18n.js that returns
the current dashboard language code (en/fr/de/lb). Falls back to
'en' if I18n isn't initialised yet.
- Replace 'en-US' with I18n.locale in 5 loyalty shared factories:
loyalty-cards-list, loyalty-card-detail-view, loyalty-transactions-
list, loyalty-pins-list, loyalty-devices-list.
- Also fix a latent bug in loyalty-transactions-list.formatDateTime
that called toLocaleDateString with hour/minute opts (silently
ignored — same bug previously fixed in loyalty-card-detail-view).
Scoped to loyalty per session decision; other modules with the same
hardcoded 'en-US' pattern (catalog, billing, etc.) are tracked as a
follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The transaction-history table on the card-detail page rendered a
Category column only on the store frontend. Merchant and admin saw
five columns instead of six, even though the merchant report
prompted the audit (rewardflow.lu/merchants/loyalty/cards/6 vs
fashionhub.rewardflow.lu/store/.../cards/6).
Root cause was two layers:
- API: only store's GET /cards/{id}/transactions enriched
tx.category_names from tx.category_ids; merchant's and admin's
endpoints returned raw rows with category_names=null.
- Template: the shared partial's show_category_column flag was set
to true only on the store wrapper.
Backfill the same `category_service.validate_category_for_store`
lookup loop into merchant.py::get_card_transactions and
admin.py::get_merchant_card_transactions, accepting Request to read
request.state.language for localised category names. Add
`{% set show_category_column = true %}` to the merchant and admin
card-detail wrappers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks every multi-persona module's templates/{admin,merchant,store}/
and classifies each feature cluster as YES / PARTIAL / NO (legit
exception) / N/A for consolidation. Produces a prioritized 10-item
backlog across 3 waves (~8-9 days of focused work, ~3,100-3,500 LOC
removable).
Headline findings:
- 141 persona templates across 9 modules; loyalty already migrated
with 8 shared partials.
- Wave 1 (low risk, ~1,190 LOC): messaging.messages,
messaging.notifications, billing.billing-history.
- Wave 2 (3-persona my-account is the marquee item, ~1,430 LOC):
tenancy.my-account, tenancy.profile, messaging.email-templates.
- Wave 3 (higher complexity, ~1,820 LOC): tenancy.team,
catalog.store-products lists, customers.customers, tenancy.login
(security-gated).
- Anti-candidates documented inline so contributors don't try to
force-fit them (catalog product forms, marketplace admin vs store,
cms content-page-edit, etc.).
Backend services are uniformly scope-agnostic for every top-10
candidate -- no service/route work required.
Added to mkdocs nav under Proposals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Frontend Error Handling (apiClient)" section to the error
handling guide. Callers can branch on `error.errorCode` and read
`error.details` to localise toasts instead of rendering the raw
English `message`, as already done in the loyalty terminal cooldown
handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanises the closing routine we've been doing manually every
session in the loyalty pre-launch walkthrough: update the persistent
memory file + the go-live readiness proposal with today's progress,
run mkdocs strict + architecture validation, commit and push the doc
changes, then print a concise recap with next-session carry-over.
Skill triggers on phrases like "call it a night", "save memory and
docs", "wrap up", etc. when the session has been on the loyalty
walkthrough. SKILL.md documents exact file paths, section templates,
commit message shape, target remote (gitea), and the edge cases
(TPL-016 noise from another agent's in-flight work, architecture
errors blocking push, mkdocs strict failures).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update. Three things in this session:
- Test 3 (staff stamps/points at terminal) — all 6 sub-steps verified
on prod, including the new cooldown rejection and its localised
toast. Tests 1-3 now done; Tests 4-8 ahead.
- Cooldown bug (93ab072f) + localised toast (aa8ca594) — points-based
programs were silently bypassing program.cooldown_minutes because
points_service.earn_points wrote last_points_at but never read it.
Mirror the stamp check + raise new PointsCooldownException with
error_code POINTS_COOLDOWN. Then localise the terminal toast in
en/fr/de/lb (new cooldown_wait_minutes key) and propagate
error.details through the apiClient so the catch site can render
{minutes}.
- Routing investigation (no fix yet, queued for post-walkthrough) —
user hit a 404 on .../platforms/loyalty/store/fashionhub/dashboard
on the subdomain. Diagnosed 4 distinct bugs from path-based→
subdomain/custom-domain drift (Mount 1 broken, server redirect
store.py:86, JS login.js:155, sidebar URL builder). Ran the full
middleware suite (185 tests pass) — depth on inbound resolution,
zero coverage on outbound URL construction; that's why the bugs
slip through. Scoped a Redirect Trace tool on /admin/platform-debug
+ matching integration tests as the regression net.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When earn-points or add-stamp was rejected by the new cooldown
enforcement, the terminal showed the raw English error message from
the backend in the toast, even on FR / DE / LB locales:
"Transaction failed: Please wait 15 minutes between point-earning..."
Two-part fix:
1. static/shared/js/api-client.js — when raising apiError on non-OK
responses, also propagate the `details` payload from the response
body (alongside the existing errorCode). Without this the catch
sites had no structured access to e.g. cooldown_minutes.
2. loyalty-terminal.js — in the catch around the transaction dispatch,
when error.errorCode is POINTS_COOLDOWN or STAMP_COOLDOWN, render a
new localised key loyalty.store.terminal.cooldown_wait_minutes with
{minutes} interpolated from error.details.cooldown_minutes (with a
fallback to this.program.cooldown_minutes). Toast type switches to
'warning' since the rejection is soft (try again later) rather than
a hard failure. Other errors keep the existing 'transaction_failed'
path so nothing else regresses.
Added the new key in en / fr / de / lb under the existing
loyalty.store.terminal.* namespace (sibling of the existing
cooldown_active label).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architecture rule that warns on any template under
app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html that
exceeds 75 LOC AND does not {% include %} a `*/shared/*` partial.
Catches new persona-specific templates that inline body content rather
than sharing it with sibling personas (the project-wide pain point that
prompted the persona-template-consolidation work).
- Rule definition in .architecture-rules/frontend.yaml at warning
severity. Suppressible per-file with `{# noqa: TPL-016 #}`.
- Check function `_check_persona_template_shared_include` in
scripts/validate/validate_architecture.py, wired at both template
validation sites (full scan + per-file -f mode).
- Loyalty was migrated under this rule and reports clean (5 legit
exceptions carry noqa with reason).
- First run surfaces ~110 warnings across other modules — the
migration backlog. Severity stays at warning until at least one
non-loyalty module is migrated, then escalate to error.
See docs/architecture/persona-template-consolidation.md for the
pattern this rule guards.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document how admin/merchant/store templates share a single shared/ body
partial while keeping their three separate base templates. Covers:
- The wrapper/partial split and why the three base templates must stay
separate (nav + permissions isolation).
- The scope contract: pass strings + booleans only, no macro objects,
no `persona` enum.
- The backend mirror: services scope-agnostic, routes inject scope via
auth deps, same Pydantic shape across personas.
- Legit exceptions and the heuristic for when to keep a template
standalone (multi-tenant aggregators, persona-unique features).
- Forward reference to the TPL-016 architecture rule.
Adds both docs to mkdocs nav under Architecture and Proposals
sections.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The store frontend was inlining two CRUD bodies that already had shared
equivalents under loyalty/templates/loyalty/shared/. Migrate them to the
established pattern (thin per-persona wrapper + shared body partial).
- store/cards.html: 171 -> 56 LOC. Now sets cards_api_prefix /
cards_base_url / show_store_filter=false and includes
shared/cards-list.html (same partial merchant already uses).
- store/card-detail.html: 205 -> 55 LOC. Includes
shared/card-detail-view.html with new flags show_copy_buttons,
show_category_column, show_pagination so its extras survive.
- shared/card-detail-view.html: gain those three boolean flags plus
reads txLabels/txNotes from the Alpine factory (empty defaults so
admin/merchant callers still get raw values).
- shared/loyalty-card-detail-view.js: factory accepts txLabels, txNotes,
paginate config; exposes pagination state unconditionally so the
partial's pagination macro resolves; fix latent bug where
formatDateTime called toLocaleDateString with ignored hour/minute
opts.
- store/loyalty-cards.js + loyalty-card-detail.js: now thin wrappers
calling the shared factories.
- locales/{en,fr,de,lb}.json: add loyalty.shared.card_detail.col_category
for the new optional column.
- Add `noqa: TPL-016` on the 5 legit-exception loyalty templates
(admin/programs aggregator, admin/merchant-settings, admin/wallet-debug,
store/enroll, store/terminal) ahead of the rule landing in a follow-up
commit. Note the per-file reason inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stamp_service.add_stamp checks card.last_stamp_at + cooldown_minutes
before crediting and raises StampCooldownException if too soon. The
parallel points_service.earn_points writes card.last_points_at but
never reads it for enforcement — so cooldown_minutes was silently
ignored for points-based programs.
Mirror the stamps check in points_service.earn_points: after acquiring
the row lock, compare now vs last_points_at + cooldown_minutes and
raise the new PointsCooldownException if the cashier is inside the
window. Add PointsCooldownException alongside StampCooldownException
in exceptions.py with parity wording / error code POINTS_COOLDOWN.
Surfaced during Test 3 step 3.6 — repeated earn-points calls for the
same card kept crediting the customer with no rate limit even though
the program's cooldown_minutes was set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update. Test 2 (cross-store re-enrollment at FASHIONOUTLET
with the email from Test 1) walked cleanly with all behavioral checks
green:
- exactly 1 loyalty_cards row, no duplicate
- zero new email_logs rows (no duplicate welcome email)
- cross-location locations block lists both stores
- title already-enrolled branch renders correctly
One copy bug surfaced and was fixed in dee2eab2: the title at
enroll-success.html:21 was already x-text-conditional on
already_enrolled, but the subtitle below was a static
{{ _('success.message') }} so two contradicting messages stacked
("You're already a member!" + "You're now a member..."). Made the
subtitle conditional the same way and added a new
already_enrolled_message i18n key in en/fr/de/lb.
Test 2 marked done. Carries forward Test 3 plus the existing follow-ups
(Hetzner doc check, B1-F unit tests, prospecting tasks/__init__ fix,
other-module email audit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When re-enrolling (already a member), the success page showed two
contradicting messages stacked:
Title: "Vous êtes déjà membre !" (correct, conditional)
Subtitle: "Vous êtes maintenant membre..." (wrong — static)
The title was already x-text-conditional based on
enrollContext.already_enrolled, but the subtitle was a server-side
{{ _('success.message') }} so it always rendered the "you're now a
member" copy regardless of branch.
Make the subtitle conditional the same way:
- new i18n key already_enrolled_message in en/fr/de/lb
("Welcome back — your card is ready whenever you are." and
locale-appropriate equivalents)
- expose success_message + already_enrolled_message in i18nStrings
- subtitle becomes x-text="already_enrolled ? msg2 : msg1"
Found during Test 2 round 2 — cross-store re-enrollment at
FASHIONOUTLET with the email from Test 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day update. Adds a new section to the go-live readiness doc
covering today's three shipped commits:
- 236fee01 — enrollment-success CTA rename (Continuer mes achats
-> Retour à l'accueil)
- ab3e133a — flatpickr birthday picker so Firefox honors dd/mm/yyyy on
FR. Firefox-specific limitation (Mozilla bug #1344625) — Chrome /
Safari / Edge already respected <html lang="fr"> from earlier fix.
- 5f288502 — admin program form now warns when terms fields are both
empty and disables save until at least one is filled, so a
merchant can't accidentally ship a program with a non-clickable
Conditions Générales link on the storefront.
Marks Test 1 fully done (all 6 originally-reported bugs B1-A..B1-F
plus today's 2 follow-up nits resolved end-to-end on prod). Carries
forward the remaining items from yesterday's queue: Test 2, Hetzner
doc check, unit tests for the B1-F chain, prospecting tasks/__init__
fix, and the other-module email-path audit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The initial codemod only converted url_for('*_static', path='*.js'|'*.css')
patterns and missed 19 raw /static/... references — most importantly the
shared/fonts/inter.css link in all four base.html files, plus a handful
of <script src="/static/modules/..."> tags in marketplace/billing/orders
templates and the storefront login/register/forgot/reset pages.
Result: deploys now flip ?v=<sha> on every JS/CSS asset that reaches the
browser, not just the ones loaded via url_for().
FE-024 rule extended to flag src="/static/...*.(js|css)" patterns too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a merchant saves a loyalty program with both terms_text and
terms_cms_page_slug empty, the storefront enrollment page renders the
"Terms & Conditions" link as a non-clickable <span> (see enroll.html
template branch) — customers can't read what they're agreeing to.
Two changes to the shared program-form to make this impossible to ship
by accident:
1. Yellow warning banner inside the Terms section, visible only when
both fields are empty. Tells the admin what the storefront will
look like and what to fix.
2. Save button is disabled until at least one of the two terms
fields is filled. The button gets a localised :title tooltip
explaining why it's disabled, and disabled:cursor-not-allowed so
the disabled state is obvious on hover.
Added three i18n keys (terms_required_warning, terms_text_hint,
terms_required_tooltip) in en/fr/de/lb, plus a small "either this or
the slug above is required" hint under the textarea so each field is
self-explanatory in isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native <input type="date"> defers display format to the browser's
locale, which most engines pick up from the <html lang> attribute.
Firefox is the exception — it ignores lang and uses the OS locale
instead (Mozilla bug #1344625, still open). So FR users on Firefox
still saw mm/dd/yyyy even after the lang fix from earlier this week.
Swap to flatpickr for both the customer storefront enrollment page
and the staff terminal enrollment page. Configure:
- dateFormat: 'Y-m-d' (what gets sent to the API — ISO, what
Pydantic's date field expects)
- altInput: true (flatpickr creates a separate visible input)
- altFormat: 'd/m/Y' (what the user sees — universal in Europe)
- locale: current_language (FR/DE/LB month + day names)
- maxDate: 'today' (no future birthdays)
Load flatpickr core + the optional locale JS via the existing
{% block extra_head %} / {% block extra_scripts %} hooks. The
loyalty/store/enroll.html template didn't have those blocks before,
added them in the same commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.
- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
across 123 templates. Images/fonts/JSON locales intentionally
unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.
Closes plan from docs/proposals/static-asset-cache-busting.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The loyalty storefront is a registration / wallet endpoint, not a
catalog — there's nothing to continue shopping toward. The button
already navigates to {{ base_url }} (the homepage), so the
destination was correct; only the wording was wrong.
Rename the i18n key continue_shopping -> back_to_home in
loyalty/enroll-success.html and all four locale files (en/fr/de/lb):
EN: "Continue Shopping" -> "Back to Home"
FR: "Continuer mes achats" -> "Retour à l'accueil"
DE: "Weiter einkaufen" -> "Zurück zur Startseite"
LB: "Weider akafen" -> "Zréck op d'Haaptsäit"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-of-day 2026-05-17 update to the go-live readiness doc.
Welcome email B1-F is now fully resolved end-to-end (enrollment ->
celery dispatch -> email_logs status=sent -> emails landing). The
issue was a chain of four nested bugs each masking the next — the
doc lists all four with commit hashes (44c42909, 3e650ff8, 2a216101,
5b21908b) plus the three earlier same-session fixes for the SMTP
password eye toggle, the JS error on /admin/loyalty/programs, and
the 422 on ProgramCreate.
Also captured:
- Audit finding: prospecting/tasks/__init__.py has the same bug as
bug #3 (scan_tasks.py exists but not imported).
- Six queued follow-ups for next session: two Test 1 storefront nits
(date format on FR, "Continuer mes achats" CTA), Test 2 cross-
store re-enrollment, Hetzner doc check, a concrete unit-test list
that would have caught each of the four B1-F bugs, the prospecting
__init__.py fix, and a wider audit of every module's email path
to find any other silently-broken @shared_task registration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Celery workers were failing every task that touched the DB with
InvalidRequestError: "expression 'ContentPage' failed to locate a name".
Root cause: the worker process only loads task modules during startup,
not routes or services. Models reached only via SQLAlchemy
string-based relationships (e.g. Platform.relationship("ContentPage"))
were never imported, so the mapper couldn't resolve the name when the
first task tried to open a DB session. The FastAPI process avoids this
because api_router transitively imports the world; the worker doesn't.
Add _preload_all_module_models() in celery_config.py that walks the
module registry and importlib.import_module's each "app.modules.<code>.models"
package. Called at module import time, before any task runs.
Surfaced while finally getting loyalty.send_notification_email registered
on the worker — the task ran, hit the DB to load email settings, and
exploded on the unresolved Platform -> ContentPage relationship.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The notifications task module was never imported by the loyalty.tasks
package __init__, so celery's discovery walk loaded the package but
never executed the @shared_task decorator on send_notification_email.
The task was missing from the worker's [tasks] registry, so every
.delay() call resulted in NotRegistered on the worker side — message
ACKed, task silently dropped, no email_logs row written.
Add the import (and update the module docstring / __all__) so the
task is registered alongside the other loyalty background tasks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a Celery task failed, on_failure passed extra={"args": ..., "kwargs": ...}
to logger.error. Python's logging.makeRecord rejects any extra key that
collides with a built-in LogRecord attribute, and "args" is one (used for
printf formatting). The KeyError raised inside the error handler then
cascaded through Celery's trace.handle_failure, masking the real task
exception entirely.
Rename the keys to task_args / task_kwargs.
Surfaced while debugging why the loyalty welcome email never got sent —
the underlying task was failing, but the on_failure handler crashed before
logging the real cause, leaving nothing in worker logs to investigate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposes a one-liner Jinja helper that appends ?v=<commit-sha> to
static JS/CSS URLs, leveraging the existing build_info pipeline so
users no longer need to hard-reload after every deploy. Documents the
codemod scope (143 callsites), open decisions, and the server-side
Cache-Control: immutable follow-up that makes the version flip pay off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 'clipboard' name is not registered in static/shared/js/icons.js;
'clipboard-copy' is. Browser logged "Icon \"clipboard\" not found".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the FastAPI process called send_notification_email.delay() it
crashed with kombu OperationalError "[Errno 111] Connection refused"
because the published task was going to amqp://localhost// instead
of our configured redis broker.
Root cause: tasks decorated with @shared_task bind to whatever
Celery considers the "current default app" at decoration time. Our
celery_app (with redis broker) was never imported during FastAPI
startup, so the loyalty / billing / etc. task modules registered
their @shared_task functions against Celery's built-in default —
which has broker_url=amqp://localhost// and no RabbitMQ deployed.
Two-part fix:
1. app/core/celery_config.py — call celery_app.set_default() so
any @shared_task evaluated after this module loads binds to our
app rather than the built-in default.
2. main.py — import app.core.celery_config near the top, BEFORE
api_router (which transitively imports the task modules). The
side-effect of the import (creating celery_app + set_default())
must run before any @shared_task decorator fires. An # isort:
split below the import stops the import-sorter from alphabetising
it back behind app.api.main and re-introducing the bug.
User-visible effect: loyalty welcome emails (and every other
@shared_task-based notification) now actually queue + send.
Surfaced this while debugging why enrollment on prod produced a
loyalty_card row but no email_logs row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin program-edit form sends null for empty number inputs.
ProgramCreate had minimum_purchase_cents declared as int (default
0, ge=0), which rejected null with 422 — even though the DB column
is NOT NULL with default 0 and "0 means no minimum" is the
documented semantics.
Add a field_validator(mode="before") that coerces None to 0 so
the admin form (and any other client that sends null for an empty
optional number) goes through cleanly. The other tolerant fields
in the schema (stamps_reward_value_cents, points_expiration_days)
are already int | None; ProgramUpdate already accepts null here.
User hit this after a clean-DB reset prevented falling back to a
pre-existing program; the merchant area form happens to send 0
instead of null, masking the bug there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The warning panel was wrapped in x-show, which only toggles
display:none — child :href bindings still evaluate, so the
'selectedMerchant.id' access inside the link threw
'TypeError: can't access property "id", selectedMerchant is null'
on every reactive update before a merchant was picked.
Switch to <template x-if> so the element is removed from the DOM
entirely when the condition is false; child bindings then never
run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI: add Clear and Copy-to-clipboard (TSV) buttons, an in-page Recent
Queries pane (localStorage, capped at 20, de-duped) and a pencil-edit
flow for saved queries with a dedicated SQL field in the modal.
Bind Ctrl/Cmd+S to open the save modal (or edit the active saved query).
Backend: harden validate_query with a multi-statement guard that
respects string literals + comments. Stop swallowing record_query_run
errors silently — log via logger.exception so failures show up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap the SMTP password input in a relative container with an
eye / eye-off toggle button so admins can reveal the value while
typing — helps catch copy-paste artifacts and typos when
re-applying credentials after a reset.
State scoped to the field's own x-data block so it doesn't leak
into the parent Alpine context. autocomplete=new-password
prevents browsers from autofilling stale values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a short "Next session" subsection to the 2026-05-16 update
spelling out the three steps to pick up Test 1 round 2 (SMTP
re-apply post-reset, program sanity check, live log tail + fresh
enrollment).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>