Compare commits

...

41 Commits

Author SHA1 Message Date
223650a52b docs(ops): record 2026-06-06 Gitea+CI migration execution + runbook lessons
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Successful in 2h59m23s
CI / validate (push) Successful in 42s
CI / dependency-scanning (push) Successful in 43s
CI / docs (push) Successful in 1m20s
CI / deploy (push) Failing after 7s
Add the "Executed: 2026-06-06" record to the 2c runbook (new box
gitea-ci-fsn1-1, Falkenstein CX22, IPs, outcome) and fold the real-world
lessons into the steps: pin the Gitea image version (not latest),
ON_ERROR_STOP + count check on DB restore, the old-runner-survives-in-
migrated-DB gotcha (delete from action_runner + stop prod service), generate
runner token as the git user, expected volume-already-exists warning, and the
root-vs-sudo note.

Held local (not pushed) — pushing stacks a 2nd ~3h CI run behind the in-flight one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:01:54 +02:00
c93346f8ff docs(ops): add CI-runner offload (2a) + Gitea migration (2c) runbooks
Some checks failed
CI / docs (push) Blocked by required conditions
CI / deploy (push) Blocked by required conditions
CI / ruff (push) Successful in 2m7s
CI / validate (push) Successful in 39s
CI / dependency-scanning (push) Successful in 45s
CI / pytest (push) Failing after 3h3m22s
Document two ways to take CI/Gitea load off the production box, since the
HostHighCpuUsage floods are caused by act_runner running ruff/pytest/validate
on the prod server (not by Gitea hosting, which is light):

- 2a "Offloading CI to a Separate Server" — move just the act_runner to a
  cheap x86 box (no data migration, no DNS, no downtime). Includes the smaller
  build-burst caveat (deploy still builds on prod) + the registry-pull path.
- 2c "Migrating Gitea to a Separate Server" — full separation runbook:
  pg_dump + data-volume tar/restore, DNS cutover, Caddy/SSL, rollback. Notes
  the box becomes stateful/critical (backups + hardening).

mkdocs --strict clean; arch validation 0 new findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:45:23 +02:00
64c8a0ec2c chore(ops): prune build cache in deploy.sh + document rescale & disk maintenance
All checks were successful
CI / ruff (push) Successful in 44s
CI / pytest (push) Successful in 2h39m22s
CI / validate (push) Successful in 32s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 54s
CI / deploy (push) Successful in 3m15s
deploy.sh already pruned old images but never build cache — the larger half
of disk creep from CI rebuilds (root fs hit 83% on prod). Add
`docker builder prune --filter until=168h` alongside the existing image prune
so cleanup happens every deploy, version-controlled, no host cron.

Docs (hetzner-server-setup.md, Maintenance section):
- New "Rescaling / Upgrading the Server" — when/why, same-arch (Arm/CAX) +
  CPU-RAM-only vs irreversible disk-expand constraints, poweroff→rescale→
  power-on→verify steps, and the Arm-capacity-unavailable-in-DC caveat.
- New "Disk Maintenance (Docker Pruning)" — emergency manual prune + the
  automated deploy.sh approach.
- Fixed stale Resource Budget: cadvisor 128→192 MB (matches compose),
  total 672→736 MB, and "live-upgrade" wording (rescale needs a power-off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:36:53 +02:00
ac7850b880 docs(auth): record 2026-05-31 401-redirect review + dev/prod symmetry proposal
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Reviewed commit 4423f0a5 (generalize 401 redirect to all 4 personas) and
verified production has no coverage gap: the only uncovered storefront page
(/loyalty/join) hits exclusively public, no-auth endpoints that can't 401,
and authenticated storefront pages live under /account/ which is already
covered. Remaining gap is dev/path-based-mode only (/platforms/...) — captured
as a deferred enhancement in docs/proposals/auth-redirect-dev-prod-symmetry.md
with the proposed one-line prefix-strip fix and two open implementation
details. No code shipped this session. mkdocs --strict clean; architecture
validation passed (0 new findings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:34:09 +02:00
4423f0a5ed fix(api-client): generalize 401 redirect from /account/* to all 4 personas
All checks were successful
CI / ruff (push) Successful in 18s
CI / docs (push) Successful in 56s
CI / pytest (push) Successful in 2h48m6s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 37s
CI / deploy (push) Successful in 1m14s
Yesterday's redirectIfCustomerAreaUnauthorized was scoped to /account/*
only. Admin, store, and merchant pages still hit the same UX gap when
an AJAX call returned 401 on token expiry: apiClient cleared tokens
and threw, leaving the page in a broken state with whatever generic
error UI the caller had wired up — no redirect, no `?next=` round-trip,
identical bug to the customer flicker we fixed in `b04b36a2` /
`6564f138`.

Rename and dispatch by path:
  - /account/*     (not /account/login)         → /account/login?next=…
  - /admin/*       (not /admin/login)           → /admin/login?next=…
  - /merchants/*   (not /merchants/login)       → /merchants/login?next=…
  - /store/{code}/* (not /store/{code}/login)   → /store/{code}/login?next=…
  - anything else                               → return false (caller throws)

Store paths include the per-store code, so the helper does a small regex
to extract `{code}` from the current pathname and builds the persona's
login URL with the right prefix.

All three 401 handlers in apiClient (request, requestFormData, getBlob)
already wrap this with the `return new Promise(() => {})` pattern from
6564f138, so the caller's `.finally(() => loading = false)` doesn't fire
before navigation completes — kills the wrong-state UI flash on every
persona, not just customer.

Login pages updated to honour `?next=` precedence over the existing
`*_last_visited_page` localStorage fallback, with persona-specific
safety checks (must start with /admin/, /merchants/, /store/{code}/
respectively; must not be a login or onboarding URL). The store login
also normalises the basePath because the store-code path prefix can
flip between subdomain (/store/{code}/...) and dev/path-based
(/platforms/{platform}/store/{code}/...) modes.

Customer login already honoured `?next=` from bbb481aa; left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 13:02:59 +02:00
947ca43c7b docs(loyalty): record 2026-05-30 afternoon — prod-readiness 1-3 done + alerting back online
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h39m33s
CI / validate (push) Successful in 35s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 56s
CI / deploy (push) Successful in 1m13s
Picked up the morning's carry-over and ran the full prod-readiness
chain end-to-end. Resolution: SG credential out of git permanently
via untrack + .example template (e44f5c04); per-host migration on
prod (alertmanager.yml gitignored, real file lives outside git);
deploy-api-only.sh succeeded for the first time; today's 9 queued
loyalty commits live on prod with ?v=e44f5c04 (and verified by
re-running the loyalty redirect flicker repro — clean).

Multi-hour rabbit hole on actual email delivery: provider's port 587
PLAIN backend is OAuth-wired (returns RFC 6749 invalid_grant text
for password auth); switched to provider's documented port 465 SSL/TLS
endpoint. Discovered Hetzner Cloud blocks outbound 25 and 465 by
default as anti-spam policy. Auto-approved unblock ticket landed in
minutes; one-line smarthost change to :465 reactivated email
alerting after 13+ days down. alertmanager handles implicit TLS on
465 natively, no stunnel/relay needed.

Hetzner doc updated with the egress-block warning + mail1 SMTP
callout in 1227567d as 5h-debug payback. Next session resumes at
Test 5.2 (/account/loyalty with 168 pts customer) → 5.3 history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:20:00 +02:00
1227567d08 docs(hetzner): record 25/465 egress block + mail1 SMTP setup (5h debug payback)
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Hetzner Cloud silently blocks outbound TCP 25 and 465 on every Cloud
Server. The block sits upstream of the VM — UFW and iptables look
completely clean — so it presents as a generic "connection times out"
that's easy to misdiagnose as a credential or DNS issue. Spent ~5 hours
on 2026-05-30 working through swaks/tcpdump/auth-backend hypotheses
before finding Hetzner's own docs that mention the policy.

Two doc additions:

- Step 4 (Firewall Configuration) gets a warning admonition right after
  the UFW status check. Explains the upstream nature of the block,
  gives the symptom signature (nc to 587 succeeds, nc to 465 silently
  times out), and includes the auto-approved unblock ticket template
  with sample text.

- Step 19.5 (Alertmanager SMTP) gets a "live prod uses
  mail1.myservices.hosting:465" callout reflecting the reality that
  the SendGrid setup documented in that section is no longer how this
  prod env is wired. The callout captures the actual smarthost config
  (with smtp_auth_password kept gitignored, only .example ships in
  repo), the two prerequisites (Hetzner unblock + implicit-TLS-aware
  smarthost port), and the redacted swaks verification command. The
  rest of §19.5 stays as a reference for greenfield deploys that
  prefer SendGrid.

Saves the next person from repeating the same hours-long detour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:54:10 +02:00
e44f5c0458 chore(alertmanager): untrack alertmanager.yml + ship .example template (post-SMTP migration)
All checks were successful
CI / ruff (push) Successful in 17s
CI / pytest (push) Successful in 2h48m4s
CI / validate (push) Successful in 36s
CI / docs (push) Successful in 56s
CI / deploy (push) Successful in 1m12s
CI / dependency-scanning (push) Successful in 37s
Yesterday's deploy debug surfaced a SendGrid API key pasted into the
tracked monitoring/alertmanager/alertmanager.yml on prod, with the
in-repo file literally captioning the field "TODO: Paste your SG.xxx
API key here" — actively encouraging the anti-pattern. Forensic
follow-up (bash history lines 290-357) confirmed it was a user-driven
nano edit that was never committed, just left as a long-running local
mod. Three problems collapsed into this finding:

  1. Real SMTP credential lived in a tracked git file on prod.
  2. The SendGrid → mail1.myservices.hosting SMTP migration never
     touched alertmanager — it still pointed at smtp.sendgrid.net.
  3. The alertmanager container has been Up 13 days with the
     pre-paste empty smtp_auth_password loaded from disk, so prod's
     email alerting has been silently failing.

Resolution shipped here:

- `git rm --cached monitoring/alertmanager/alertmanager.yml` so the
  prod-edited file on each host stops being a tracked file and the
  credential can't accidentally reach git again.
- Add `monitoring/alertmanager/alertmanager.yml` to .gitignore.
- Ship `monitoring/alertmanager/alertmanager.yml.example` as the
  template — pre-filled with the post-migration non-secret routing
  (`mail1.myservices.hosting:587`, `support@wizard.lu` auth,
  `alerts@wizard.lu` From for inbox clarity), only `smtp_auth_password`
  left as `CHANGEME`. Includes inline guidance for the From-vs-auth
  rule that some SMTP relays enforce.

Per-host steps (Hetzner): backup the prod-edited file → revert local
change → pull → copy the template over the old file → fill in the
password → SIGHUP alertmanager. Doc reference will follow in the next
commit (Hetzner deploy doc still needs an "alertmanager.yml lives
outside git" footnote).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 11:32:10 +02:00
cff0b3f911 docs(loyalty): record 2026-05-30 widget i18n + cache-bust + 401 redirect + alertmanager finding
All checks were successful
CI / pytest (push) Successful in 2h49m26s
CI / docs (push) Successful in 55s
CI / ruff (push) Successful in 18s
CI / validate (push) Successful in 35s
CI / dependency-scanning (push) Successful in 35s
CI / deploy (push) Successful in 1m51s
Nine code commits shipped today (5f359283c13e8e29) covering Test 5
widget/customer-module i18n, a 53-template cache-bust sweep with
FE-024 rule tightening, the customer-storefront 401-to-/account/login
redirect, the loyalty redirect-flicker fix, the login JS i18n sweep,
and a new scripts/deploy-api-only.sh script + Hetzner §16.5 split.
None of them are on prod yet — surfaced during the deploy that the
new dirty-tree gate is correctly blocking on monitoring/alertmanager/
alertmanager.yml, which holds a SendGrid API key pasted into a tracked
file. Knock-on finding: alertmanager has been running with stale empty
SMTP config for 13+ days, AND the file still references SendGrid
instead of the post-migration smarthost, so prod's alerting is silently
broken. User opted to fix prod-readiness items first thing tomorrow
before resuming Test 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:20:07 +02:00
c13e8e29b5 feat(deploy): scripts/deploy-api-only.sh + Hetzner doc for manual code-only redeploys
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Manual deploys had been using a bare `git pull && docker compose up -d
--build api` sequence, which works for the container itself but silently
skipped writing `.build-info`. The stale `.build-info` left
`?v=<commit-sha>` pointing at the previous deploy's SHA on every shared
JS/CSS URL — so browsers happily kept cached pre-fix assets even after
a successful rebuild. Bit us today: ~5 hours of "is this even deployed?"
debugging on the loyalty-dashboard redirect-flicker fix.

deploy.sh wasn't a substitute because it's a CI/CD script: stashes
working tree, runs alembic, restarts every service in the full profile
(db, redis, api, celery-worker, celery-beat, flower), 60s health budget.
Heavy and disruptive for an api-only hotfix.

New scripts/deploy-api-only.sh fills the gap with the narrow path:

  - Refuses if working tree is dirty (no silent stash → no pop conflicts).
  - git pull --ff-only.
  - Writes .build-info (the critical missing step).
  - docker compose -f docker-compose.yml --profile full up -d --build api
    (only the api service — db/redis/celery untouched).
  - Tight 30s health budget since DB doesn't need to come back up.
  - Exit codes 0/1/2/3 for clean automation.

docs/deployment/hetzner-server-setup.md §16.5 split into 16.5a
(code-only — points at the new script as the default) and 16.5b
(full deploy fallback — kept the existing deploy.sh path for migrations
/ Dockerfile / docker-compose / requirements changes). §12 footnote on
.build-info refreshed to mention both scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:55:05 +02:00
c9fe717184 fix(login-i18n): translate the 3 hardcoded JS toasts in customer login
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Three more hardcoded English strings in customers/storefront/login.html
were still bypassing i18n because they were emitted from Alpine
showAlert() calls in <script>:

- "Account created successfully! Please sign in." (post-register toast)
- "Login successful! Redirecting..." (post-login toast)
- "Invalid email or password" (login-error fallback)

Same pattern as the earlier forgot/reset-password sweep: defined
window.__customerLoginI18n with `tojson` server-rendered values, read
them once at function entry as `const i18n = ...`, and swapped each
hardcoded string for an i18n property.

Two new auth.* keys × 4 locales (registration_success_signin,
login_success_redirecting). The third reuses the existing
auth.invalid_credentials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:41:15 +02:00
bbb481aad4 fix(login-i18n): translate the "Welcome back ..." subtitle on customer login
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
Last unilingual string in customers/storefront/login.html was the
"Welcome back to your shopping experience" line on the branding side.
Was flagged as scope-creep-skip during the original Test 5 i18n sweep
since the user only complained about reset-password and dashboard
pages; surfaced now that the 401 redirect lands users on /account/login
mid-French-session.

Replaced with {{ _("auth.login_subtitle") }} and added the key to all
4 core locales (en/fr/de/lb). Reworded slightly from "shopping
experience" since loyalty storefronts have no catalog — same reasoning
as the auth.continue_shopping → auth.back_to_home rename earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:36:56 +02:00
6564f13898 fix(api-client): never-resolving promise on 401 redirect kills the wrong-UI flash
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 22:35:12 +02:00
b04b36a232 fix(loyalty-storefront): initial loading=true so 401 redirect doesn't flash wrong UI
Some checks failed
CI / ruff (push) Successful in 18s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 22:27:17 +02:00
856db328b5 chore(api-client): drop dead /shop/ path branches (migrated to /storefront long ago)
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 21:37:37 +02:00
a0ae638821 fix(storefront-auth): apiClient redirects to login on 401 from /account/*
Some checks failed
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
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>
2026-05-29 21:28:34 +02:00
3ce9468397 fix(cache-bust): close FE-024 gaps so every JS/CSS gets ?v=<sha>
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 21:01:44 +02:00
5f359283bc fix(storefront-i18n): dashboard widgets translate + correct customer-module key paths
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 20:45:46 +02:00
acbe2eff1a docs(loyalty): record 2026-05-29 Test 5.0 i18n sweep + FR/DE email accents
All checks were successful
CI / ruff (push) Successful in 19s
CI / pytest (push) Successful in 2h48m44s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 55s
CI / deploy (push) Successful in 1m52s
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>
2026-05-29 00:17:49 +02:00
213a60533c fix(seed): correct sys.path so seed_email_templates_core can import app.*
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 00:02:44 +02:00
7e3a1cb93f chore(skills): generalize loyalty-wrap into topic-agnostic /wrap
Some checks failed
CI / ruff (push) Successful in 20s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-29 00:00:54 +02:00
1bade6e6b2 fix(storefront): broken Alpine x-text from |tojson inside double-quoted attribute
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-28 23:55:14 +02:00
36fd37813b fix(email): DE password_reset umlauts (zurucksetzen → zurücksetzen etc.)
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-28 23:51:32 +02:00
b463c6bf48 fix(email): FR password_reset accents + store-name signature on all 4 locales
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-28 23:48:13 +02:00
10a99f98fe fix(storefront): i18n sweep + locale-aware reset-password and welcome email
Some checks failed
CI / ruff (push) Successful in 19s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-28 23:06:11 +02:00
f04cbb8ca2 docs(audit): lessons learned from loyalty migration
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h51m39s
CI / validate (push) Successful in 40s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 54s
CI / deploy (push) Successful in 1m46s
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>
2026-05-24 23:59:05 +02:00
eaf180c64f feat(arch-rules): JS-016 blocks hardcoded 'en-US' in JS at error severity
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / ruff (push) Successful in 17s
CI / pytest (push) Has been cancelled
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>
2026-05-24 23:52:14 +02:00
bb4c400436 fix(loyalty): sweep remaining hardcoded 'en-US' in persona JS files
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>
2026-05-24 23:51:24 +02:00
06e59f73b3 fix(i18n): sweep hardcoded 'en-US' from non-loyalty JS to I18n.locale
Some checks failed
CI / ruff (push) Successful in 16s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-24 23:38:35 +02:00
a21dbbcddf docs(loyalty): record 2026-05-24 Test 4 + storefront auth body-schema fix
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-24 23:36:31 +02:00
478c3a9c50 fix(storefront-auth): forgot/reset password accept JSON body, not query
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-24 23:06:10 +02:00
dd1f9af811 fix(i18n): locale-aware date/number formatting in loyalty factories
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-24 23:03:03 +02:00
d32c1fd545 fix(loyalty): show category column on card-detail for all 3 personas
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>
2026-05-24 23:02:07 +02:00
58a9e3f740 docs(proposals): cross-module persona-template consolidation audit
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h49m10s
CI / validate (push) Successful in 34s
CI / dependency-scanning (push) Successful in 35s
CI / docs (push) Successful in 56s
CI / deploy (push) Successful in 1m11s
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>
2026-05-24 14:09:39 +02:00
82939c0005 docs(api): document apiClient error.status / errorCode / details surface
All checks were successful
CI / ruff (push) Successful in 16s
CI / pytest (push) Successful in 2h48m39s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 36s
CI / docs (push) Successful in 57s
CI / deploy (push) Successful in 1m53s
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>
2026-05-24 00:32:42 +02:00
d03b96da1c chore(skill): add loyalty-wrap end-of-day skill
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-24 00:01:40 +02:00
78621cb7bb docs(loyalty): record 2026-05-23 Test 3 + cooldown bug + routing investigation
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-23 23:44:23 +02:00
aa8ca59493 fix(loyalty-terminal): localise cooldown toast (was raw English)
Some checks failed
CI / ruff (push) Successful in 17s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
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>
2026-05-23 23:16:32 +02:00
f9a15deed7 feat(arch-rules): TPL-016 flags large persona templates that skip shared/
Some checks failed
CI / ruff (push) Successful in 17s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
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>
2026-05-23 23:11:21 +02:00
f82dce30ca docs(architecture): persona template consolidation pattern + proposal
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>
2026-05-23 23:10:29 +02:00
c661c1e394 refactor(loyalty): migrate store cards + card-detail to shared partials
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>
2026-05-23 23:09:29 +02:00
111 changed files with 7332 additions and 4847 deletions

View File

@@ -391,6 +391,41 @@ javascript_rules:
exceptions:
- "init-alpine.js"
- id: "JS-016"
name: "Do not hardcode 'en-US' (or any locale) in Intl/toLocale calls"
severity: "error"
description: |
Locale-aware APIs (toLocaleDateString, toLocaleString, toLocaleTimeString,
new Intl.NumberFormat, new Intl.DateTimeFormat, etc.) must NOT receive a
hardcoded locale tag like 'en-US'. The user's dashboard language won't be
respected and dates/numbers will render in English even when FR/DE/LB is
selected.
Use the `I18n.locale` getter from static/shared/js/i18n.js, which returns
the current dashboard language (falls back to 'en' if I18n hasn't loaded).
WRONG (always renders dates in English):
date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
new Intl.NumberFormat('en-US').format(num);
RIGHT:
date.toLocaleDateString(I18n.locale, { year: 'numeric', month: 'short', day: 'numeric' });
new Intl.NumberFormat(I18n.locale).format(num);
Suppress with `// noqa: JS-016` on the line for the rare case where a
specific locale is genuinely required (e.g., a US-only invoice number
formatter that must use en-US regardless of UI language).
pattern:
file_pattern: "static/**/js/**/*.js"
anti_patterns:
- "toLocaleDateString\\(\\s*['\"]en-US['\"]"
- "toLocaleString\\(\\s*['\"]en-US['\"]"
- "toLocaleTimeString\\(\\s*['\"]en-US['\"]"
- "new\\s+Intl\\.\\w+\\(\\s*['\"]en-US['\"]"
exceptions:
- "i18n.js"
- "vendor/"
- id: "JS-012"
name: "Do not include /api/v1 prefix in API endpoints"
severity: "error"
@@ -644,6 +679,37 @@ template_rules:
exceptions:
- "shared/macros/headers.html"
- id: "TPL-016"
name: "Persona templates >75 LOC must include a shared/ partial"
severity: "warning"
description: |
Any persona template (under app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html)
that exceeds 75 LOC AND does not {% include %} a `*/shared/*` partial is likely
duplicating body content that should live in a shared partial used by all three
personas. See docs/architecture/persona-template-consolidation.md for the pattern.
RIGHT (thin wrapper + shared body):
{% extends "store/base.html" %}
...page-header + loading/error...
{% set cards_api_prefix = '/store/loyalty' %}
{% set cards_base_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% include 'loyalty/shared/cards-list.html' %}
WRONG (inlined table + filters that already exist in shared/):
{% extends "store/base.html" %}
...200 lines of inline <table>/filters/pagination identical to merchant's...
Suppress for legit exceptions (admin multi-merchant aggregator, store-only
hardware UI, persona-unique tabbed dashboard) with `{# noqa: TPL-016 #}`
anywhere in the file.
pattern:
file_pattern: "app/modules/*/templates/*/{admin,merchant,store}/*.html"
threshold_loc: 75
required_pattern: "{% include .*/shared/.*"
exceptions:
- "base.html"
- "partials/"
- id: "TPL-014"
name: "Use new modal_simple macro API with call block"
severity: "error"
@@ -961,9 +1027,9 @@ frontend_component_rules:
file_pattern: "app/**/templates/**/*.html"
anti_patterns:
- "url_for\\(\\s*['\"]\\w+_static['\"]\\s*,\\s*path=['\"][^'\"]+\\.(?:js|css)['\"]"
- "url_for\\(\\s*['\"]static['\"]\\s*,\\s*path=['\"][^'\"]+\\.(?:js|css)['\"]"
- "(?:src|href)=['\"]/static/[^'\"]+\\.(?:js|css)['\"]"
exceptions:
- "base.html"
- "partials/"
# ============================================================================

View File

@@ -0,0 +1,158 @@
---
name: wrap
description: End-of-day session wrap that records *whatever this session was about* into the right persistent memory file (and, if one exists, the matching in-repo proposal doc), runs mkdocs strict + architecture validation if docs changed, commits and pushes any doc edits, then prints a concise recap with next-session carry-over. Triggers on "call it a night", "wrap up", "save memory and docs", "let's pause for today", "end of session", "save where we are", "goodnight let's commit". Safe to run with other agents concurrently on the same repo — uses pull-rebase-retry on push and stages only its own files.
---
# Session wrap
Closes out a working session by persisting today's progress so the next session (or another agent) can pick up cold. Generic across topics — the skill figures out what the session was about and routes the writes accordingly.
## When to invoke
Trigger phrases (non-exhaustive): "call it a night", "save memory and docs", "wrap up", "let's pause for today", "end of session", "save where we are", "goodnight let's commit", "good night".
The skill works for *any* topic. Don't bail out because the session wasn't loyalty.
## Step 0 — Identify the session topic
Look back over the current conversation and answer three questions in your head:
1. **What was the session about?** A single noun phrase: "loyalty E2E walkthrough", "admin/merchant/store shell unification scoping", "messaging.messages consolidation", "infra debug", etc.
2. **Was a code commit shipped?** Check `git log --oneline -10` and compare against what the user actually saw happen in this session. If commits landed, list them. If not, the wrap is doc-only.
3. **Is there an existing memory file that matches?** `ls ~/.claude/projects/-home-samir-Documents-PycharmProjects-letzshop-product-import/memory/project_*.md` — pick the closest match by name/description. If none fits, you'll create a new one in step 1.
Pick the topic slug (kebab-case) you'll use throughout: e.g. `loyalty-e2e-walkthrough`, `shell-unification`, `messaging-consolidation`.
## Step 1 — Update (or create) the memory file
Memory dir: `~/.claude/projects/-home-samir-Documents-PycharmProjects-letzshop-product-import/memory/`
**If a matching `project_*.md` already exists** (e.g. `project_loyalty_e2e_walkthrough.md`):
- Read it. Insert a new dated section **above** the file's last "current state" / "paused" / "carry over" heading (whatever the trailing heading is — different files use different conventions). Update that trailing heading's date to today.
- Edit pattern: use the Edit tool with an `old_string` anchored on the trailing heading line, so two concurrent agents inserting different sections don't race on the same anchor.
**If no matching file exists**, create `project_<topic-slug>.md` with this frontmatter + skeleton:
```markdown
---
name: project-<topic-slug>
description: <one-line summary of the work this file tracks>
metadata:
type: project
---
# <Topic title>
**Started:** YYYY-MM-DD.
**Why:** <what kicked this off — link related memories with [[name]]>.
**How to apply:** <what the next session / another agent should do with this file>.
## YYYY-MM-DD — <one-line headline>
<body — see template below>
## Current state — paused end of day YYYY-MM-DD
<one-paragraph "where we are" + next concrete step>
```
Then add an index line to `MEMORY.md` under the right section (or create a new section if the topic is new):
```markdown
- [<Topic title>](project_<topic-slug>.md) — <one-line hook>
```
**Dated-section body template** (used in both new and existing files):
```markdown
## YYYY-MM-DD — <headline>
### <Sub-block per thing that happened>
What surfaced, root cause if a bug, decisions made, commit hash + subject if shipped (`abc12345` <subject>). For investigations that didn't ship code, include file:line citations.
### Carry over to next session
- 1-N bullets covering the next concrete steps.
```
## Step 2 — Update the proposal doc (only if one exists)
Look for an in-repo proposal/doc that corresponds to this topic. Common spots:
- `docs/proposals/*.md` (most common — e.g. `loyalty-go-live-readiness.md`, `cms-redesign-alignment.md`, `persona-template-consolidation-audit.md`)
- `docs/architecture/*.md` (for pattern/architecture work)
- `app/modules/<m>/docs/*.md` (for module-internal trackers)
**If one exists** that's the team-readable counterpart to this session's work: insert a dated section above the doc's status/board/timeline heading, mirroring the memory entry but team-framed (less first-person, no internal links to memory files).
**If none exists**, skip this step — don't fabricate a proposal doc just to have something to commit. Step 3 + 4 then become no-ops (no docs changed → nothing to validate or push).
## Step 3 — Validate doc changes (only if step 2 wrote a file)
Per `~/.claude/CLAUDE.md`'s global rules: mkdocs strict + architecture validation after any doc change. Run both in parallel via `run_in_background: true`:
```bash
mkdocs build --strict 2>&1 | tail -6
pre-commit run validate-architecture --all-files 2>&1 | tail -5
```
Both must pass. Architecture warnings are OK (the project has a baseline); only **new** errors block the wrap. mkdocs strict failures are usually a broken nav entry or cross-link — fix and re-run before pushing.
If either fails: do NOT push. Surface the failure to the user and ask whether to fix or skip the wrap.
## Step 4 — Commit + push (only if step 2 wrote a file)
**Concurrency safety: another agent may be doing the same wrap on this repo right now.** Follow this exact order:
1. `git status --short` — confirm the only modified/added file is the doc you just edited. If unrelated files are dirty (another agent in flight), do NOT stage them; commit only your own file by name.
2. `git fetch gitea master` — get the latest remote tip without merging.
3. `git pull --rebase gitea master` — rebase any new commits from the other agent on top of HEAD before you commit. If this fails with a conflict on the doc file you're about to edit, the other agent committed first to the same doc; re-read the file, re-apply your section, and retry from step 1.
4. Stage ONLY the doc file by explicit path (`git add docs/proposals/<file>.md` — never `-A` / `.`). The memory file at `~/.claude/...` is outside the repo; don't try to add it.
5. Commit with the message template below.
6. `git push gitea master`. If push is rejected (`! [rejected]` or `non-fast-forward`), another agent pushed between your pull and push. Run `git pull --rebase gitea master` and `git push gitea master` once more. If it still fails after one retry, surface to user — don't loop.
Push to **gitea** (not `origin` — that's the Synology backup; prod pulls from gitea).
**Commit message template:**
```
docs(<scope>): record YYYY-MM-DD <one-line headline>
<2-4 sentence body covering the session's work — what shipped (with
commit hashes), what was scoped/investigated without shipping, and
what's carrying over to next session.>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```
Scope is the topic area: `loyalty`, `audit`, `arch`, `cms`, `infra`, etc. — match it to the doc's directory.
## Step 5 — Recap to the user
End the turn with a tight summary:
- Today's shipped commits if any (`hash` one-line subject for each)
- What's complete (Tests/items done, bugs resolved)
- Carry-over for next session (mirrors the memory file's "Carry over" subsection)
- Sign-off: `Just say "resume" tomorrow and I'll pick up at <next concrete item>. Goodnight 🌙`
## Edge cases
- **Nothing shipped today, only scoping/investigation**: still record in memory (and proposal if one exists). The commit message body should reflect that no code shipped, only docs. If no proposal exists and the session was pure scoping, memory write alone is enough — no commit, no push.
- **No matching memory file AND no matching proposal**: create the memory file only; skip the proposal/commit/push entirely. Recap still happens.
- **Other agent's `TPL-016`/other warning count keeps climbing**: expected when another agent is actively addressing the rule. As long as none of the new warnings are in files this session touched, ignore.
- **Architecture validation surfaces new ERRORS in files this session didn't touch**: not your fault, but don't push. Tell the user another agent likely introduced them and ask whether to push docs anyway or wait.
- **`git pull --rebase` conflicts on the doc you're editing**: the other agent shipped to the same doc first. Re-read the doc (it now has their section), re-apply yours below theirs (or above, depending on convention — most files insert newest-on-top), retry.
- **`git push` rejected twice in a row**: stop retrying; surface to user with the remote tip's last 3 commits so they can decide.
- **Branch isn't `master`**: confirm with the user before pushing. Wraps have only ever targeted master.
- **gitea push fails (auth, network)**: tell the user to push manually; don't retry blindly.
## What the skill explicitly does NOT do
- Does not commit code changes. Code commits should already be in by wrap time. The wrap commit is doc-only.
- Does not run the test suite. Code commits earlier in the session would have triggered pre-commit hooks already.
- Does not update `~/.claude/CLAUDE.md` or other user-level files.
- Does not advance the work (no test execution, no new fixes) — only records what already happened.
- Does not stage files it didn't write itself (`-A` / `.` are forbidden — they'd grab another agent's in-flight edits).

4
.gitignore vendored
View File

@@ -192,3 +192,7 @@ exports/
# Security audit (needs revamping)
scripts/security-audit/
# Alertmanager config is per-host (contains SMTP credentials) — ship
# alertmanager.yml.example as the template, real file lives outside git.
monitoring/alertmanager/alertmanager.yml

View File

@@ -405,7 +405,7 @@ function adminStoreProducts() {
*/
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return '-';
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(I18n.locale, {
style: 'currency',
currency: currency || 'EUR'
}).format(price);

View File

@@ -272,7 +272,7 @@ function contentPagesManager() {
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'

View File

@@ -72,12 +72,14 @@ class WidgetContext:
date_to: End of date range filter
limit: Maximum number of items for list widgets
include_details: Whether to include extra details (may be expensive)
language: Storefront/dashboard locale (e.g. "fr") for i18n of card strings
"""
date_from: datetime | None = None
date_to: datetime | None = None
limit: int = 5
include_details: bool = False
language: str | None = None
# =============================================================================

View File

@@ -236,13 +236,25 @@ function adminLogin() {
// Super admin or single platform - proceed to dashboard
this.success = 'Login successful! Redirecting...';
// Check for last visited page (saved before logout)
// Decide where to land after login. Precedence:
// 1. ?next=<path> — set by apiClient on 401 mid-session;
// lands the user back on the page they were on.
// 2. admin_last_visited_page localStorage — fallback for
// organic logins where no `?next=` is supplied.
// 3. /admin/dashboard — last-resort default.
// Path safety check on (1) and (2): must start with /admin/
// and not be a login / select-platform URL.
const nextParam = new URLSearchParams(window.location.search).get('next');
const lastPage = localStorage.getItem('admin_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/admin/') && !lastPage.includes('/login') && !lastPage.includes('/select-platform'))
? lastPage
: '/admin/dashboard';
const isSafeAdminUrl = (u) =>
u && u.startsWith('/admin/') && !u.includes('/login') && !u.includes('/select-platform');
const redirectTo =
isSafeAdminUrl(nextParam) ? nextParam :
isSafeAdminUrl(lastPage) ? lastPage :
'/admin/dashboard';
loginLog.info('=== EXECUTING REDIRECT ===');
loginLog.debug('next param:', nextParam);
loginLog.debug('Last visited page:', lastPage);
loginLog.debug('Target URL:', redirectTo);

View File

@@ -131,13 +131,21 @@ function merchantLogin() {
// Show success message
this.success = 'Login successful! Redirecting...';
// Check for last visited page
// Decide where to land after login. Precedence:
// 1. ?next=<path> — set by apiClient on 401 mid-session.
// 2. merchant_last_visited_page localStorage fallback.
// 3. /merchants/dashboard default.
const nextParam = new URLSearchParams(window.location.search).get('next');
const lastPage = localStorage.getItem('merchant_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/merchants/') && !lastPage.includes('/login'))
? lastPage
: '/merchants/dashboard';
const isSafeMerchantUrl = (u) =>
u && u.startsWith('/merchants/') && !u.includes('/login');
const redirectTo =
isSafeMerchantUrl(nextParam) ? nextParam :
isSafeMerchantUrl(lastPage) ? lastPage :
'/merchants/dashboard';
loginLog.info('Redirecting to:', redirectTo);
loginLog.debug('next param:', nextParam, '| lastPage:', lastPage);
window.location.href = redirectTo;
} catch (error) {

View File

@@ -233,7 +233,7 @@ function storefrontLayoutData() {
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'

View File

@@ -59,6 +59,95 @@
"profile": "Profil",
"addresses": "Adressen",
"settings": "Einstellungen"
},
"pages": {
"dashboard": {
"title": "Mein Konto",
"welcome_back": "Willkommen zurück, {name}!",
"profile_card_title": "Profil",
"profile_card_subtitle": "Informationen bearbeiten",
"addresses_card_title": "Adressen",
"addresses_card_subtitle": "Adressen verwalten",
"messages_card_title": "Nachrichten",
"messages_card_subtitle": "Support kontaktieren",
"unread_messages_singular": "{count} ungelesene Nachricht",
"unread_messages_plural": "{count} ungelesene Nachrichten",
"summary_title": "Kontoübersicht",
"customer_since": "Kunde seit",
"logout": "Abmelden",
"logout_confirm_title": "Abmeldung bestätigen",
"logout_confirm_message": "Sind Sie sicher, dass Sie sich abmelden möchten? Sie müssen sich erneut anmelden, um auf Ihr Konto zuzugreifen.",
"logout_success": "Abmeldung erfolgreich",
"logout_failed": "Abmeldung fehlgeschlagen"
},
"profile": {
"breadcrumb_account": "Mein Konto",
"breadcrumb_profile": "Profil",
"title": "Mein Profil",
"subtitle": "Verwalten Sie Ihre Kontoinformationen und Einstellungen",
"info_section_title": "Profilinformationen",
"info_section_subtitle": "Persönliche Daten aktualisieren",
"email_label": "E-Mail-Adresse",
"save_changes": "Änderungen speichern",
"saving": "Speichern...",
"prefs_section_title": "Einstellungen",
"prefs_section_subtitle": "Verwalten Sie Ihre Kontoeinstellungen",
"preferred_language": "Bevorzugte Sprache",
"use_shop_default": "Shop-Standard verwenden",
"marketing_communications": "Marketing-Kommunikation",
"marketing_desc": "E-Mails über neue Produkte, Angebote und Aktionen erhalten",
"save_preferences": "Einstellungen speichern",
"change_password_subtitle": "Aktualisieren Sie Ihr Kontopasswort",
"changing": "Wird geändert...",
"account_info": "Kontoinformationen",
"member_since": "Mitglied seit",
"profile_updated": "Profil erfolgreich aktualisiert",
"preferences_updated": "Einstellungen erfolgreich aktualisiert",
"password_changed": "Passwort erfolgreich geändert",
"failed_to_load": "Profil konnte nicht geladen werden",
"failed_to_save_profile": "Profil konnte nicht gespeichert werden",
"failed_to_save_preferences": "Einstellungen konnten nicht gespeichert werden",
"failed_to_change_password": "Passwort konnte nicht geändert werden"
},
"addresses": {
"title": "Meine Adressen",
"subtitle": "Verwalten Sie Ihre Liefer- und Rechnungsadressen",
"add_address": "Adresse hinzufügen",
"empty_state_title": "Noch keine Adressen",
"empty_state_subtitle": "Fügen Sie Ihre erste Adresse hinzu, um den Bezahlvorgang zu beschleunigen.",
"add_first_address": "Erste Adresse hinzufügen",
"default_shipping": "Standard-Lieferadresse",
"default_billing": "Standard-Rechnungsadresse",
"shipping": "Lieferung",
"billing": "Rechnung",
"set_default": "Als Standard festlegen",
"edit_address": "Adresse bearbeiten",
"add_new_address": "Neue Adresse",
"address_type": "Adresstyp",
"shipping_address": "Lieferadresse",
"billing_address": "Rechnungsadresse",
"company_optional": "Unternehmen (optional)",
"address_line_1": "Adresse",
"address_line_2_optional": "Adresszusatz (optional)",
"postal_code": "Postleitzahl",
"city": "Stadt",
"country": "Land",
"set_as_default_shipping": "Als Standard-Lieferadresse festlegen",
"set_as_default_billing": "Als Standard-Rechnungsadresse festlegen",
"save_changes": "Änderungen speichern",
"saving": "Speichern...",
"delete_address": "Adresse löschen",
"delete_confirm": "Sind Sie sicher, dass Sie diese Adresse löschen möchten? Dieser Vorgang ist nicht rückgängig zu machen.",
"deleting": "Wird gelöscht...",
"address_updated": "Adresse aktualisiert",
"address_added": "Adresse hinzugefügt",
"address_deleted": "Adresse gelöscht",
"default_updated": "Standardadresse aktualisiert",
"failed_to_load": "Adressen konnten nicht geladen werden. Bitte erneut versuchen.",
"failed_to_save": "Adresse konnte nicht gespeichert werden. Bitte erneut versuchen.",
"failed_to_delete": "Adresse konnte nicht gelöscht werden",
"failed_to_set_default": "Standardadresse konnte nicht festgelegt werden"
}
}
}
}

View File

@@ -59,6 +59,95 @@
"profile": "Profile",
"addresses": "Addresses",
"settings": "Settings"
},
"pages": {
"dashboard": {
"title": "My Account",
"welcome_back": "Welcome back, {name}!",
"profile_card_title": "Profile",
"profile_card_subtitle": "Edit your information",
"addresses_card_title": "Addresses",
"addresses_card_subtitle": "Manage addresses",
"messages_card_title": "Messages",
"messages_card_subtitle": "Contact support",
"unread_messages_singular": "{count} unread message",
"unread_messages_plural": "{count} unread messages",
"summary_title": "Account Summary",
"customer_since": "Customer Since",
"logout": "Logout",
"logout_confirm_title": "Logout Confirmation",
"logout_confirm_message": "Are you sure you want to logout? You'll need to sign in again to access your account.",
"logout_success": "Logged out successfully",
"logout_failed": "Logout failed"
},
"profile": {
"breadcrumb_account": "My Account",
"breadcrumb_profile": "Profile",
"title": "My Profile",
"subtitle": "Manage your account information and preferences",
"info_section_title": "Profile Information",
"info_section_subtitle": "Update your personal details",
"email_label": "Email Address",
"save_changes": "Save Changes",
"saving": "Saving...",
"prefs_section_title": "Preferences",
"prefs_section_subtitle": "Manage your account preferences",
"preferred_language": "Preferred Language",
"use_shop_default": "Use shop default",
"marketing_communications": "Marketing Communications",
"marketing_desc": "Receive emails about new products, offers, and promotions",
"save_preferences": "Save Preferences",
"change_password_subtitle": "Update your account password",
"changing": "Changing...",
"account_info": "Account Information",
"member_since": "Member Since",
"profile_updated": "Profile updated successfully",
"preferences_updated": "Preferences updated successfully",
"password_changed": "Password changed successfully",
"failed_to_load": "Failed to load profile",
"failed_to_save_profile": "Failed to save profile",
"failed_to_save_preferences": "Failed to save preferences",
"failed_to_change_password": "Failed to change password"
},
"addresses": {
"title": "My Addresses",
"subtitle": "Manage your shipping and billing addresses",
"add_address": "Add Address",
"empty_state_title": "No addresses yet",
"empty_state_subtitle": "Add your first address to speed up checkout.",
"add_first_address": "Add Your First Address",
"default_shipping": "Default Shipping",
"default_billing": "Default Billing",
"shipping": "Shipping",
"billing": "Billing",
"set_default": "Set as Default",
"edit_address": "Edit Address",
"add_new_address": "Add New Address",
"address_type": "Address Type",
"shipping_address": "Shipping Address",
"billing_address": "Billing Address",
"company_optional": "Company (optional)",
"address_line_1": "Address",
"address_line_2_optional": "Address Line 2 (optional)",
"postal_code": "Postal Code",
"city": "City",
"country": "Country",
"set_as_default_shipping": "Set as default shipping address",
"set_as_default_billing": "Set as default billing address",
"save_changes": "Save Changes",
"saving": "Saving...",
"delete_address": "Delete Address",
"delete_confirm": "Are you sure you want to delete this address? This action cannot be undone.",
"deleting": "Deleting...",
"address_updated": "Address updated",
"address_added": "Address added",
"address_deleted": "Address deleted",
"default_updated": "Default address updated",
"failed_to_load": "Failed to load addresses. Please try again.",
"failed_to_save": "Failed to save address. Please try again.",
"failed_to_delete": "Failed to delete address",
"failed_to_set_default": "Failed to set default address"
}
}
}
}

View File

@@ -59,6 +59,95 @@
"profile": "Profil",
"addresses": "Adresses",
"settings": "Paramètres"
},
"pages": {
"dashboard": {
"title": "Mon compte",
"welcome_back": "Bon retour, {name} !",
"profile_card_title": "Profil",
"profile_card_subtitle": "Modifier vos informations",
"addresses_card_title": "Adresses",
"addresses_card_subtitle": "Gérer vos adresses",
"messages_card_title": "Messages",
"messages_card_subtitle": "Contacter le support",
"unread_messages_singular": "{count} message non lu",
"unread_messages_plural": "{count} messages non lus",
"summary_title": "Résumé du compte",
"customer_since": "Client depuis",
"logout": "Se déconnecter",
"logout_confirm_title": "Confirmer la déconnexion",
"logout_confirm_message": "Êtes-vous sûr de vouloir vous déconnecter ? Vous devrez vous reconnecter pour accéder à votre compte.",
"logout_success": "Déconnexion réussie",
"logout_failed": "Échec de la déconnexion"
},
"profile": {
"breadcrumb_account": "Mon compte",
"breadcrumb_profile": "Profil",
"title": "Mon profil",
"subtitle": "Gérez les informations et préférences de votre compte",
"info_section_title": "Informations du profil",
"info_section_subtitle": "Mettez à jour vos informations personnelles",
"email_label": "Adresse e-mail",
"save_changes": "Enregistrer",
"saving": "Enregistrement...",
"prefs_section_title": "Préférences",
"prefs_section_subtitle": "Gérez les préférences de votre compte",
"preferred_language": "Langue préférée",
"use_shop_default": "Utiliser la langue de la boutique",
"marketing_communications": "Communications marketing",
"marketing_desc": "Recevoir des e-mails sur les nouveaux produits, offres et promotions",
"save_preferences": "Enregistrer les préférences",
"change_password_subtitle": "Mettez à jour le mot de passe de votre compte",
"changing": "Changement...",
"account_info": "Informations du compte",
"member_since": "Membre depuis",
"profile_updated": "Profil mis à jour avec succès",
"preferences_updated": "Préférences mises à jour avec succès",
"password_changed": "Mot de passe modifié avec succès",
"failed_to_load": "Échec du chargement du profil",
"failed_to_save_profile": "Échec de l'enregistrement du profil",
"failed_to_save_preferences": "Échec de l'enregistrement des préférences",
"failed_to_change_password": "Échec du changement de mot de passe"
},
"addresses": {
"title": "Mes adresses",
"subtitle": "Gérez vos adresses de livraison et de facturation",
"add_address": "Ajouter une adresse",
"empty_state_title": "Aucune adresse",
"empty_state_subtitle": "Ajoutez votre première adresse pour accélérer le paiement.",
"add_first_address": "Ajouter votre première adresse",
"default_shipping": "Livraison par défaut",
"default_billing": "Facturation par défaut",
"shipping": "Livraison",
"billing": "Facturation",
"set_default": "Définir par défaut",
"edit_address": "Modifier l'adresse",
"add_new_address": "Nouvelle adresse",
"address_type": "Type d'adresse",
"shipping_address": "Adresse de livraison",
"billing_address": "Adresse de facturation",
"company_optional": "Société (facultatif)",
"address_line_1": "Adresse",
"address_line_2_optional": "Complément d'adresse (facultatif)",
"postal_code": "Code postal",
"city": "Ville",
"country": "Pays",
"set_as_default_shipping": "Définir comme adresse de livraison par défaut",
"set_as_default_billing": "Définir comme adresse de facturation par défaut",
"save_changes": "Enregistrer",
"saving": "Enregistrement...",
"delete_address": "Supprimer l'adresse",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer cette adresse ? Cette action est irréversible.",
"deleting": "Suppression...",
"address_updated": "Adresse mise à jour",
"address_added": "Adresse ajoutée",
"address_deleted": "Adresse supprimée",
"default_updated": "Adresse par défaut mise à jour",
"failed_to_load": "Échec du chargement des adresses. Veuillez réessayer.",
"failed_to_save": "Échec de l'enregistrement de l'adresse. Veuillez réessayer.",
"failed_to_delete": "Échec de la suppression de l'adresse",
"failed_to_set_default": "Échec de la définition de l'adresse par défaut"
}
}
}
}

View File

@@ -59,6 +59,95 @@
"profile": "Profil",
"addresses": "Adressen",
"settings": "Astellungen"
},
"pages": {
"dashboard": {
"title": "Mäi Kont",
"welcome_back": "Wëllkomm zréck, {name} !",
"profile_card_title": "Profil",
"profile_card_subtitle": "Informatiounen änneren",
"addresses_card_title": "Adressen",
"addresses_card_subtitle": "Adressen verwalten",
"messages_card_title": "Noriichten",
"messages_card_subtitle": "Support kontaktéieren",
"unread_messages_singular": "{count} ongeliesen Noriicht",
"unread_messages_plural": "{count} ongeliesen Noriichten",
"summary_title": "Kontoiwwersiicht",
"customer_since": "Client zënter",
"logout": "Ofmellen",
"logout_confirm_title": "Ofmellen bestätegen",
"logout_confirm_message": "Sidd Dir sécher datt Dir Iech ofmellen wëllt? Dir musst Iech erëm aloggen fir op Äre Kont ze zougräifen.",
"logout_success": "Erfollegräich ofgemellt",
"logout_failed": "Ofmellen feelgeschloen"
},
"profile": {
"breadcrumb_account": "Mäi Kont",
"breadcrumb_profile": "Profil",
"title": "Mäi Profil",
"subtitle": "Verwalt Är Kontodaten an Astellungen",
"info_section_title": "Profilinformatiounen",
"info_section_subtitle": "Är perséinlech Donnéeën updaten",
"email_label": "E-Mail-Adress",
"save_changes": "Änneren späicheren",
"saving": "Späicheren...",
"prefs_section_title": "Astellungen",
"prefs_section_subtitle": "Verwalt Är Kontosastellungen",
"preferred_language": "Bevorzucht Sprooch",
"use_shop_default": "Buttek-Standard benotzen",
"marketing_communications": "Marketing-Kommunikatioun",
"marketing_desc": "E-Maile mat neie Produkter, Offeren a Promotioune kréien",
"save_preferences": "Astellunge späicheren",
"change_password_subtitle": "Aktualiséiert Äert Kontopasswuert",
"changing": "Änneren...",
"account_info": "Kontoinformatiounen",
"member_since": "Member zënter",
"profile_updated": "Profil erfollegräich aktualiséiert",
"preferences_updated": "Astellungen erfollegräich aktualiséiert",
"password_changed": "Passwuert erfollegräich geännert",
"failed_to_load": "Profil konnt net geluede ginn",
"failed_to_save_profile": "Profil konnt net späichert ginn",
"failed_to_save_preferences": "Astellunge konnten net späichert ginn",
"failed_to_change_password": "Passwuert konnt net geännert ginn"
},
"addresses": {
"title": "Meng Adressen",
"subtitle": "Verwalt Är Liwwer- a Rechnungsadressen",
"add_address": "Adress derbäisetzen",
"empty_state_title": "Nach keng Adressen",
"empty_state_subtitle": "Setzt Är éischt Adress derbäi fir den Bezuelvirgang ze beschleunegen.",
"add_first_address": "Éischt Adress derbäisetzen",
"default_shipping": "Standard-Liwweradress",
"default_billing": "Standard-Rechnungsadress",
"shipping": "Liwwerung",
"billing": "Rechnung",
"set_default": "Als Standard festleeën",
"edit_address": "Adress änneren",
"add_new_address": "Nei Adress",
"address_type": "Adresstyp",
"shipping_address": "Liwweradress",
"billing_address": "Rechnungsadress",
"company_optional": "Entreprise (fakultativ)",
"address_line_1": "Adress",
"address_line_2_optional": "Adresszousatz (fakultativ)",
"postal_code": "Postleitzuel",
"city": "Stad",
"country": "Land",
"set_as_default_shipping": "Als Standard-Liwweradress festleeën",
"set_as_default_billing": "Als Standard-Rechnungsadress festleeën",
"save_changes": "Änneren späicheren",
"saving": "Späicheren...",
"delete_address": "Adress läschen",
"delete_confirm": "Sidd Dir sécher datt Dir dës Adress läsche wëllt? Dës Aktioun kann net réckgängeg gemaach ginn.",
"deleting": "Läschen...",
"address_updated": "Adress aktualiséiert",
"address_added": "Adress derbäigesat",
"address_deleted": "Adress geläscht",
"default_updated": "Standardadress aktualiséiert",
"failed_to_load": "Adressen konnten net geluede ginn. Probéiert nach eng Kéier.",
"failed_to_save": "Adress konnt net späichert ginn. Probéiert nach eng Kéier.",
"failed_to_delete": "Adress konnt net geläscht ginn",
"failed_to_set_default": "Standardadress konnt net festgeluecht ginn"
}
}
}
}

View File

@@ -49,6 +49,8 @@ from app.modules.messaging.services.email_service import (
from app.modules.tenancy.exceptions import StoreNotFoundException
from app.modules.tenancy.schemas.auth import (
LogoutResponse,
PasswordResetConfirm,
PasswordResetRequest,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
@@ -230,7 +232,9 @@ def customer_logout(request: Request, response: Response):
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse) # public
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
def forgot_password(
request: Request, body: PasswordResetRequest, db: Session = Depends(get_db)
):
"""
Request password reset for customer.
@@ -245,6 +249,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
email = body.email
logger.debug(
f"[CUSTOMER_STOREFRONT] forgot_password for store {store.subdomain}",
extra={
@@ -265,11 +270,12 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
reset_link = f"{scheme}://{host}/account/reset-password?token={plaintext_token}"
email_service = EmailService(db)
request_language = getattr(request.state, "language", None)
email_service.send_template(
template_code="password_reset",
to_email=customer.email,
to_name=customer.full_name,
language=customer.preferred_language or "en",
language=request_language or customer.preferred_language or "en",
variables={
"customer_name": customer.first_name or customer.full_name,
"reset_link": reset_link,
@@ -299,7 +305,7 @@ def forgot_password(request: Request, email: str, db: Session = Depends(get_db))
@router.post("/auth/reset-password", response_model=PasswordResetResponse) # public
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
request: Request, body: PasswordResetConfirm, db: Session = Depends(get_db)
):
"""
Reset customer password using reset token.
@@ -323,8 +329,8 @@ def reset_password(
customer = customer_service.validate_and_reset_password(
db=db,
store_id=store.id,
reset_token=reset_token,
new_password=new_password,
reset_token=body.reset_token,
new_password=body.new_password,
)
db.commit()

View File

@@ -196,6 +196,7 @@ async def shop_account_dashboard_page(
)
# Collect dashboard cards from enabled modules via widget protocol
from app.modules.contracts.widgets import WidgetContext
from app.modules.core.services.widget_aggregator import widget_aggregator
store = getattr(request.state, "store", None)
@@ -207,6 +208,9 @@ async def shop_account_dashboard_page(
store_id=store.id,
customer_id=current_customer.id,
platform_id=platform.id,
context=WidgetContext(
language=getattr(request.state, "language", None)
),
)
return templates.TemplateResponse(

View File

@@ -568,6 +568,7 @@ class CustomerService:
last_name: str = "",
phone: str | None = None,
birth_date: date | None = None,
preferred_language: str | None = None,
) -> Customer:
"""
Create a customer for loyalty/external enrollment.
@@ -606,6 +607,7 @@ class CustomerService:
last_name=last_name,
phone=phone,
birth_date=birth_date,
preferred_language=preferred_language,
hashed_password=unusable_hash,
customer_number=cust_number,
store_id=store_id,

View File

@@ -1,7 +1,7 @@
{# app/templates/storefront/account/addresses.html #}
{% extends "storefront/base.html" %}
{% block title %}My Addresses - {{ store.name }}{% endblock %}
{% block title %}{{ _('customers.storefront.pages.addresses.title') }} - {{ store.name }}{% endblock %}
{% block alpine_data %}addressesPage(){% endblock %}
@@ -10,14 +10,14 @@
<!-- Page Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Addresses</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your shipping and billing addresses</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.addresses.title') }}</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">{{ _('customers.storefront.pages.addresses.subtitle') }}</p>
</div>
<button @click="openAddModal()"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
style="background-color: var(--color-primary)">
<span class="-ml-1 mr-2 h-5 w-5" x-html="$icon('plus', 'h-5 w-5')"></span>
Add Address
{{ _('customers.storefront.pages.addresses.add_address') }}
</button>
</div>
@@ -38,12 +38,12 @@
<div x-show="!loading && !error && addresses.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<span class="mx-auto h-12 w-12 text-gray-400 block" x-html="$icon('map-pin', 'h-12 w-12 mx-auto')"></span>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">{{ _('customers.storefront.pages.addresses.empty_state_title') }}</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ _('customers.storefront.pages.addresses.empty_state_subtitle') }}</p>
<button @click="openAddModal()"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark"
style="background-color: var(--color-primary)">
Add Your First Address
{{ _('customers.storefront.pages.addresses.add_first_address') }}
</button>
</div>
@@ -56,14 +56,14 @@
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="address.address_type === 'shipping' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'">
<span class="-ml-0.5 mr-1 h-3 w-3" x-html="$icon('check-circle', 'h-3 w-3')"></span>
Default <span x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'" class="ml-1"></span>
<span x-text="address.address_type === 'shipping' ? i18n.defaultShipping : i18n.defaultBilling"></span>
</span>
</div>
<!-- Address Type Badge (non-default) -->
<div x-show="!address.is_default" class="absolute top-4 right-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'"></span>
x-text="address.address_type === 'shipping' ? i18n.shipping : i18n.billing"></span>
</div>
<!-- Address Content -->
@@ -81,16 +81,16 @@
<button @click="openEditModal(address)"
class="text-sm font-medium text-primary hover:text-primary-dark"
style="color: var(--color-primary)">
Edit
{{ _('common.edit') }}
</button>
<button x-show="!address.is_default"
@click="setAsDefault(address.id)"
class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Set as Default
{{ _('customers.storefront.pages.addresses.set_default') }}
</button>
<button @click="openDeleteModal(address.id)"
class="text-sm font-medium text-red-600 hover:text-red-700">
Delete
{{ _('common.delete') }}
</button>
</div>
</div>
@@ -140,28 +140,28 @@
<div class="sm:flex sm:items-start">
<div class="w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-6"
x-text="editingAddress ? 'Edit Address' : 'Add New Address'"></h3>
x-text="editingAddress ? i18n.editAddress : i18n.addNewAddress"></h3>
<form @submit.prevent="saveAddress()" class="space-y-4">
<!-- Address Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Type</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.address_type') }}</label>
<select x-model="addressForm.address_type"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
<option value="shipping">Shipping Address</option>
<option value="billing">Billing Address</option>
<option value="shipping">{{ _('customers.storefront.pages.addresses.shipping_address') }}</option>
<option value="billing">{{ _('customers.storefront.pages.addresses.billing_address') }}</option>
</select>
</div>
<!-- Name Row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.customers.first_name') }} *</label>
<input type="text" x-model="addressForm.first_name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.customers.last_name') }} *</label>
<input type="text" x-model="addressForm.last_name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
@@ -169,21 +169,21 @@
<!-- Company -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company (optional)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.company_optional') }}</label>
<input type="text" x-model="addressForm.company"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- Address Line 1 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.address_line_1') }} *</label>
<input type="text" x-model="addressForm.address_line_1" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- Address Line 2 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2 (optional)</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.address_line_2_optional') }}</label>
<input type="text" x-model="addressForm.address_line_2"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
@@ -191,12 +191,12 @@
<!-- City & Postal Code -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.postal_code') }} *</label>
<input type="text" x-model="addressForm.postal_code" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.city') }} *</label>
<input type="text" x-model="addressForm.city" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
@@ -204,7 +204,7 @@
<!-- Country -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('customers.storefront.pages.addresses.country') }} *</label>
<select x-model="addressForm.country_iso"
@change="addressForm.country_name = countries.find(c => c.iso === addressForm.country_iso)?.name || ''"
required
@@ -220,9 +220,8 @@
<input type="checkbox" x-model="addressForm.is_default"
class="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
style="color: var(--color-primary)">
<label class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Set as default <span x-text="addressForm.address_type === 'shipping' ? 'shipping' : 'billing'"></span> address
</label>
<label class="ml-2 text-sm text-gray-700 dark:text-gray-300"
x-text="addressForm.address_type === 'shipping' ? i18n.setAsDefaultShipping : i18n.setAsDefaultBilling"></label>
</div>
<!-- Error Message -->
@@ -234,14 +233,14 @@
<div class="mt-6 flex justify-end space-x-3">
<button type="button" @click="showAddressModal = false"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
Cancel
{{ _('common.cancel') }}
</button>
<button type="submit"
:disabled="saving"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark disabled:opacity-50"
style="background-color: var(--color-primary)">
<span x-show="!saving" x-text="editingAddress ? 'Save Changes' : 'Add Address'"></span>
<span x-show="saving">Saving...</span>
<span x-show="!saving" x-text="editingAddress ? i18n.saveChanges : i18n.addAddress"></span>
<span x-show="saving">{{ _('customers.storefront.pages.addresses.saving') }}</span>
</button>
</div>
</form>
@@ -288,10 +287,10 @@
<span class="h-6 w-6 text-red-600 dark:text-red-400" x-html="$icon('exclamation-triangle', 'h-6 w-6')"></span>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Delete Address</h3>
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">{{ _('customers.storefront.pages.addresses.delete_address') }}</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to delete this address? This action cannot be undone.
{{ _('customers.storefront.pages.addresses.delete_confirm') }}
</p>
</div>
</div>
@@ -301,12 +300,12 @@
<button @click="confirmDelete()"
:disabled="deleting"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50">
<span x-show="!deleting">Delete</span>
<span x-show="deleting">Deleting...</span>
<span x-show="!deleting">{{ _('common.delete') }}</span>
<span x-show="deleting">{{ _('customers.storefront.pages.addresses.deleting') }}</span>
</button>
<button @click="showDeleteModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto sm:text-sm">
Cancel
{{ _('common.cancel') }}
</button>
</div>
</div>
@@ -316,9 +315,31 @@
{% block extra_scripts %}
<script>
window.__addressesPageI18n = {
defaultShipping: {{ _('customers.storefront.pages.addresses.default_shipping')|tojson }},
defaultBilling: {{ _('customers.storefront.pages.addresses.default_billing')|tojson }},
shipping: {{ _('customers.storefront.pages.addresses.shipping')|tojson }},
billing: {{ _('customers.storefront.pages.addresses.billing')|tojson }},
editAddress: {{ _('customers.storefront.pages.addresses.edit_address')|tojson }},
addNewAddress: {{ _('customers.storefront.pages.addresses.add_new_address')|tojson }},
addAddress: {{ _('customers.storefront.pages.addresses.add_address')|tojson }},
saveChanges: {{ _('customers.storefront.pages.addresses.save_changes')|tojson }},
setAsDefaultShipping: {{ _('customers.storefront.pages.addresses.set_as_default_shipping')|tojson }},
setAsDefaultBilling: {{ _('customers.storefront.pages.addresses.set_as_default_billing')|tojson }},
addressUpdated: {{ _('customers.storefront.pages.addresses.address_updated')|tojson }},
addressAdded: {{ _('customers.storefront.pages.addresses.address_added')|tojson }},
addressDeleted: {{ _('customers.storefront.pages.addresses.address_deleted')|tojson }},
defaultUpdated: {{ _('customers.storefront.pages.addresses.default_updated')|tojson }},
failedToLoad: {{ _('customers.storefront.pages.addresses.failed_to_load')|tojson }},
failedToSave: {{ _('customers.storefront.pages.addresses.failed_to_save')|tojson }},
failedToDelete: {{ _('customers.storefront.pages.addresses.failed_to_delete')|tojson }},
failedToSetDefault: {{ _('customers.storefront.pages.addresses.failed_to_set_default')|tojson }},
};
function addressesPage() {
const i18n = window.__addressesPageI18n || {};
return {
...storefrontLayoutData(),
i18n,
// State
loading: true,
@@ -403,14 +424,14 @@ function addressesPage() {
window.location.href = '{{ base_url }}account/login';
return;
}
throw new Error('Failed to load addresses');
throw new Error(i18n.failedToLoad);
}
const data = await response.json();
this.addresses = data.addresses;
} catch (err) {
console.error('[ADDRESSES] Error loading:', err);
this.error = 'Failed to load addresses. Please try again.';
this.error = i18n.failedToLoad;
} finally {
this.loading = false;
}
@@ -476,15 +497,15 @@ function addressesPage() {
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || data.message || 'Failed to save address');
throw new Error(data.detail || data.message || i18n.failedToSave);
}
this.showAddressModal = false;
this.showToast(this.editingAddress ? 'Address updated' : 'Address added', 'success');
this.showToast(this.editingAddress ? i18n.addressUpdated : i18n.addressAdded, 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error saving:', err);
this.formError = err.message || 'Failed to save address. Please try again.';
this.formError = err.message || i18n.failedToSave;
} finally {
this.saving = false;
}
@@ -508,15 +529,15 @@ function addressesPage() {
});
if (!response.ok) {
throw new Error('Failed to delete address');
throw new Error(i18n.failedToDelete);
}
this.showDeleteModal = false;
this.showToast('Address deleted', 'success');
this.showToast(i18n.addressDeleted, 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error deleting:', err);
this.showToast('Failed to delete address', 'error');
this.showToast(i18n.failedToDelete, 'error');
} finally {
this.deleting = false;
}
@@ -533,14 +554,14 @@ function addressesPage() {
});
if (!response.ok) {
throw new Error('Failed to set default address');
throw new Error(i18n.failedToSetDefault);
}
this.showToast('Default address updated', 'success');
this.showToast(i18n.defaultUpdated, 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error setting default:', err);
this.showToast('Failed to set default address', 'error');
this.showToast(i18n.failedToSetDefault, 'error');
}
}
}

View File

@@ -2,7 +2,7 @@
{% extends "storefront/base.html" %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% block title %}My Account - {{ store.name }}{% endblock %}
{% block title %}{{ _('customers.storefront.pages.dashboard.title') }} - {{ store.name }}{% endblock %}
{% block alpine_data %}accountDashboard(){% endblock %}
@@ -10,8 +10,8 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Account</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Welcome back, {{ user.first_name }}!</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.dashboard.title') }}</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">{{ _('customers.storefront.pages.dashboard.welcome_back', name=user.first_name) }}</p>
</div>
<!-- Dashboard Grid -->
@@ -49,8 +49,8 @@
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('user', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Profile</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Edit your information</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.dashboard.profile_card_title') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('customers.storefront.pages.dashboard.profile_card_subtitle') }}</p>
</div>
</div>
<div>
@@ -66,8 +66,8 @@
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('map-pin', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Addresses</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage addresses</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.dashboard.addresses_card_title') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('customers.storefront.pages.dashboard.addresses_card_subtitle') }}</p>
</div>
</div>
</a>
@@ -85,26 +85,27 @@
x-text="unreadCount > 9 ? '9+' : unreadCount"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Messages</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Contact support</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.dashboard.messages_card_title') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ _('customers.storefront.pages.dashboard.messages_card_subtitle') }}</p>
</div>
</div>
<div x-show="unreadCount > 0">
<p class="text-sm text-primary font-medium" style="color: var(--color-primary)" x-text="unreadCount + ' unread message' + (unreadCount > 1 ? 's' : '')"></p>
<p class="text-sm text-primary font-medium" style="color: var(--color-primary)"
x-text="(unreadCount === 1 ? window.__accountDashboardI18n.unreadSingular : window.__accountDashboardI18n.unreadPlural).replace('{count}', unreadCount)"></p>
</div>
</a>
</div>
<!-- Account Summary -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Account Summary</h3>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">{{ _('customers.storefront.pages.dashboard.summary_title') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Since</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('customers.storefront.pages.dashboard.customer_since') }}</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.created_at.strftime('%B %Y') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{{ _('customers.customers.customer_number') }}</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>
</div>
</div>
@@ -114,7 +115,7 @@
<div class="mt-8 flex justify-end">
<button @click="showLogoutModal = true"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
Logout
{{ _('customers.storefront.pages.dashboard.logout') }}
</button>
</div>
</div>
@@ -122,19 +123,26 @@
<!-- Logout Confirmation Modal -->
{{ confirm_modal(
id='logoutModal',
title='Logout Confirmation',
message="Are you sure you want to logout? You'll need to sign in again to access your account.",
title=_('customers.storefront.pages.dashboard.logout_confirm_title'),
message=_('customers.storefront.pages.dashboard.logout_confirm_message'),
confirm_action='confirmLogout()',
show_var='showLogoutModal',
confirm_text='Logout',
cancel_text='Cancel',
confirm_text=_('customers.storefront.pages.dashboard.logout'),
cancel_text=_('common.cancel'),
variant='danger'
) }}
{% endblock %}
{% block extra_scripts %}
<script>
window.__accountDashboardI18n = {
logoutSuccess: {{ _('customers.storefront.pages.dashboard.logout_success')|tojson }},
logoutFailed: {{ _('customers.storefront.pages.dashboard.logout_failed')|tojson }},
unreadSingular: {{ _('customers.storefront.pages.dashboard.unread_messages_singular')|tojson }},
unreadPlural: {{ _('customers.storefront.pages.dashboard.unread_messages_plural')|tojson }},
};
function accountDashboard() {
const i18n = window.__accountDashboardI18n || {};
return {
...storefrontLayoutData(),
showLogoutModal: false,
@@ -155,7 +163,7 @@ function accountDashboard() {
localStorage.removeItem('customer_token');
// Show success message
this.showToast('Logged out successfully', 'success');
this.showToast(i18n.logoutSuccess, 'success');
// Redirect to login page
setTimeout(() => {
@@ -163,7 +171,7 @@ function accountDashboard() {
}, 500);
} else {
console.error('Logout failed with status:', response.status);
this.showToast('Logout failed', 'error');
this.showToast(i18n.logoutFailed, 'error');
// Still redirect on failure (cookie might be deleted)
setTimeout(() => {
window.location.href = '{{ base_url }}account/login';
@@ -172,7 +180,7 @@ function accountDashboard() {
})
.catch(error => {
console.error('Logout error:', error);
this.showToast('Logout failed', 'error');
this.showToast(i18n.logoutFailed, 'error');
// Redirect anyway
setTimeout(() => {
window.location.href = '{{ base_url }}account/login';

View File

@@ -39,7 +39,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -153,7 +153,7 @@
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
&larr; {{ _("auth.continue_shopping") }}
&larr; {{ _("auth.back_to_home") }}
</a>
</p>
@@ -180,6 +180,15 @@
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
{# Translated client-side strings — kept in sync with auth.* keys above #}
<script>
window.__forgotPasswordI18n = {
emailRequired: {{ _('auth.email_required')|tojson }},
invalidEmail: {{ _('auth.invalid_email')|tojson }},
forgotPasswordFailed: {{ _('auth.forgot_password_failed')|tojson }},
};
</script>
<!-- Forgot Password Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
@@ -199,6 +208,7 @@
}
function forgotPassword() {
const i18n = window.__forgotPasswordI18n || {};
return {
// Data
email: '',
@@ -240,12 +250,12 @@
// Basic validation
if (!this.email) {
this.errors.email = 'Email is required';
this.errors.email = i18n.emailRequired;
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.email)) {
this.errors.email = 'Please enter a valid email address';
this.errors.email = i18n.invalidEmail;
return;
}
@@ -265,7 +275,7 @@
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Failed to send reset link');
throw new Error(data.detail || i18n.forgotPasswordFailed);
}
// Success - show email sent message
@@ -273,7 +283,7 @@
} catch (error) {
console.error('Forgot password error:', error);
this.showAlert(error.message || 'Failed to send reset link. Please try again.');
this.showAlert(error.message || i18n.forgotPasswordFailed);
} finally {
this.loading = false;
}

View File

@@ -38,7 +38,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -56,7 +56,7 @@
<div class="text-6xl mb-4">🛒</div>
{% endif %}
<h2 class="text-2xl font-bold text-white mb-2">{{ store.name }}</h2>
<p class="text-white opacity-90">Welcome back to your shopping experience</p>
<p class="text-white opacity-90">{{ _("auth.login_subtitle") }}</p>
</div>
</div>
@@ -157,7 +157,7 @@
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
&larr; {{ _("auth.continue_shopping") }}
&larr; {{ _("auth.back_to_home") }}
</a>
</p>
@@ -182,11 +182,20 @@
</div>
<!-- Icons (registers $icon magic helper for Alpine) -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
{# Translated client-side strings — kept in sync with auth.* keys above #}
<script>
window.__customerLoginI18n = {
registrationSuccess: {{ _('auth.registration_success_signin')|tojson }},
loginSuccess: {{ _('auth.login_success_redirecting')|tojson }},
invalidCredentials: {{ _('auth.invalid_credentials')|tojson }},
};
</script>
<!-- Login Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
@@ -206,6 +215,7 @@
}
function customerLogin() {
const i18n = window.__customerLoginI18n || {};
return {
// Data
credentials: {
@@ -234,7 +244,7 @@
checkRegistrationSuccess() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('registered') === 'true') {
this.showAlert('Account created successfully! Please sign in.', 'success');
this.showAlert(i18n.registrationSuccess, 'success');
}
},
@@ -293,17 +303,20 @@
localStorage.setItem('customer_token', data.access_token);
localStorage.setItem('customer_user', JSON.stringify(data.user));
this.showAlert('Login successful! Redirecting...', 'success');
this.showAlert(i18n.loginSuccess, 'success');
// Redirect to account page or return URL
// Redirect to account page or return URL.
// Accepts `?next=` (apiClient's 401-handler convention)
// or `?return=` (legacy) — `next` wins.
setTimeout(() => {
const returnUrl = new URLSearchParams(window.location.search).get('return') || '{{ base_url }}account';
const params = new URLSearchParams(window.location.search);
const returnUrl = params.get('next') || params.get('return') || '{{ base_url }}account';
window.location.href = returnUrl;
}, 1000);
} catch (error) {
console.error('Login error:', error);
this.showAlert(error.message || 'Invalid email or password');
this.showAlert(error.message || i18n.invalidCredentials);
} finally {
this.loading = false;
}

View File

@@ -1,7 +1,7 @@
{# app/templates/storefront/account/profile.html #}
{% extends "storefront/base.html" %}
{% block title %}My Profile - {{ store.name }}{% endblock %}
{% block title %}{{ _('customers.storefront.pages.profile.title') }} - {{ store.name }}{% endblock %}
{% block alpine_data %}shopProfilePage(){% endblock %}
@@ -11,19 +11,19 @@
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}account/dashboard" class="hover:text-primary">My Account</a>
<a href="{{ base_url }}account/dashboard" class="hover:text-primary">{{ _('customers.storefront.pages.profile.breadcrumb_account') }}</a>
</li>
<li class="flex items-center">
<span class="h-4 w-4 mx-2" x-html="$icon('chevron-right', 'h-4 w-4')"></span>
<span class="text-gray-900 dark:text-white">Profile</span>
<span class="text-gray-900 dark:text-white">{{ _('customers.storefront.pages.profile.breadcrumb_profile') }}</span>
</li>
</ol>
</nav>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Profile</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your account information and preferences</p>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.profile.title') }}</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">{{ _('customers.storefront.pages.profile.subtitle') }}</p>
</div>
<!-- Loading State -->
@@ -58,15 +58,15 @@
<!-- Profile Information Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Update your personal details</p>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.profile.info_section_title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('customers.storefront.pages.profile.info_section_subtitle') }}</p>
</div>
<form @submit.prevent="saveProfile" class="p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- First Name -->
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
{{ _('customers.customers.first_name') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="first_name" x-model="profileForm.first_name" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -78,7 +78,7 @@
<!-- Last Name -->
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
{{ _('customers.customers.last_name') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="last_name" x-model="profileForm.last_name" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -91,7 +91,7 @@
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email Address <span class="text-red-500">*</span>
{{ _('customers.storefront.pages.profile.email_label') }} <span class="text-red-500">*</span>
</label>
<input type="email" id="email" x-model="profileForm.email" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -103,7 +103,7 @@
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone Number
{{ _('auth.phone_number') }}
</label>
<input type="tel" id="phone" x-model="profileForm.phone"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -122,7 +122,7 @@
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style="background-color: var(--color-primary)">
<span x-show="savingProfile" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
<span x-text="savingProfile ? 'Saving...' : 'Save Changes'"></span>
<span x-text="savingProfile ? i18n.saving : i18n.saveChanges"></span>
</button>
</div>
</form>
@@ -131,25 +131,25 @@
<!-- Preferences Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Preferences</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your account preferences</p>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('customers.storefront.pages.profile.prefs_section_title') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('customers.storefront.pages.profile.prefs_section_subtitle') }}</p>
</div>
<form @submit.prevent="savePreferences" class="p-6 space-y-6">
<!-- Language -->
<div>
<label for="language" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Preferred Language
{{ _('customers.storefront.pages.profile.preferred_language') }}
</label>
<select id="language" x-model="preferencesForm.preferred_language"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
<option value="">Use shop default</option>
<option value="">{{ _('customers.storefront.pages.profile.use_shop_default') }}</option>
<option value="en">English</option>
<option value="fr">Francais</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="lb">Letzebuergesch</option>
<option value="lb">Lëtzebuergesch</option>
</select>
</div>
@@ -164,10 +164,10 @@
</div>
<div class="ml-3">
<label for="marketing_consent" class="text-sm font-medium text-gray-700 dark:text-gray-300">
Marketing Communications
{{ _('customers.storefront.pages.profile.marketing_communications') }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
Receive emails about new products, offers, and promotions
{{ _('customers.storefront.pages.profile.marketing_desc') }}
</p>
</div>
</div>
@@ -182,7 +182,7 @@
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style="background-color: var(--color-primary)">
<span x-show="savingPreferences" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
<span x-text="savingPreferences ? 'Saving...' : 'Save Preferences'"></span>
<span x-text="savingPreferences ? i18n.saving : i18n.savePreferences"></span>
</button>
</div>
</form>
@@ -191,14 +191,14 @@
<!-- Change Password Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Update your account password</p>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _('auth.change_password') }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('customers.storefront.pages.profile.change_password_subtitle') }}</p>
</div>
<form @submit.prevent="changePassword" class="p-6 space-y-6">
<!-- Current Password -->
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password <span class="text-red-500">*</span>
{{ _('auth.current_password') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="current_password" x-model="passwordForm.current_password" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -210,7 +210,7 @@
<!-- New Password -->
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password <span class="text-red-500">*</span>
{{ _('auth.new_password') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="new_password" x-model="passwordForm.new_password" required
minlength="8"
@@ -219,14 +219,14 @@
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 8 characters with at least one letter and one number
{{ _('auth.password_requirements') }}
</p>
</div>
<!-- Confirm Password -->
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm New Password <span class="text-red-500">*</span>
{{ _('auth.confirm_password') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
@@ -235,7 +235,7 @@
style="--tw-ring-color: var(--color-primary)">
<p x-show="passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class="mt-1 text-xs text-red-500">
Passwords do not match
{{ _('auth.passwords_do_not_match') }}
</p>
</div>
@@ -252,7 +252,7 @@
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style="background-color: var(--color-primary)">
<span x-show="changingPassword" class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
<span x-text="changingPassword ? 'Changing...' : 'Change Password'"></span>
<span x-text="changingPassword ? i18n.changing : i18n.changePassword"></span>
</button>
</div>
</form>
@@ -260,22 +260,22 @@
<!-- Account Info (read-only) -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Account Information</h3>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">{{ _('customers.storefront.pages.profile.account_info') }}</h3>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Customer Number</dt>
<dt class="text-gray-500 dark:text-gray-400">{{ _('customers.customers.customer_number') }}</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.customer_number || '-'"></dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Member Since</dt>
<dt class="text-gray-500 dark:text-gray-400">{{ _('customers.storefront.pages.profile.member_since') }}</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatDate(profile?.created_at)"></dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Total Orders</dt>
<dt class="text-gray-500 dark:text-gray-400">{{ _('customers.customers.total_orders') }}</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.total_orders || 0"></dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Total Spent</dt>
<dt class="text-gray-500 dark:text-gray-400">{{ _('customers.customers.total_spent') }}</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatPrice(profile?.total_spent)"></dd>
</div>
</dl>
@@ -286,9 +286,26 @@
{% block extra_scripts %}
<script>
window.__shopProfileI18n = {
saving: {{ _('customers.storefront.pages.profile.saving')|tojson }},
saveChanges: {{ _('customers.storefront.pages.profile.save_changes')|tojson }},
savePreferences: {{ _('customers.storefront.pages.profile.save_preferences')|tojson }},
changing: {{ _('customers.storefront.pages.profile.changing')|tojson }},
changePassword: {{ _('auth.change_password')|tojson }},
profileUpdated: {{ _('customers.storefront.pages.profile.profile_updated')|tojson }},
preferencesUpdated: {{ _('customers.storefront.pages.profile.preferences_updated')|tojson }},
passwordChanged: {{ _('customers.storefront.pages.profile.password_changed')|tojson }},
failedToLoad: {{ _('customers.storefront.pages.profile.failed_to_load')|tojson }},
failedToSaveProfile: {{ _('customers.storefront.pages.profile.failed_to_save_profile')|tojson }},
failedToSavePreferences: {{ _('customers.storefront.pages.profile.failed_to_save_preferences')|tojson }},
failedToChangePassword: {{ _('customers.storefront.pages.profile.failed_to_change_password')|tojson }},
passwordsDoNotMatch: {{ _('auth.passwords_do_not_match')|tojson }},
};
function shopProfilePage() {
const i18n = window.__shopProfileI18n || {};
return {
...storefrontLayoutData(),
i18n,
// State
profile: null,
@@ -347,7 +364,7 @@ function shopProfilePage() {
window.location.href = '{{ base_url }}account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load profile');
throw new Error(i18n.failedToLoad);
}
this.profile = await response.json();
@@ -366,7 +383,7 @@ function shopProfilePage() {
} catch (err) {
console.error('Error loading profile:', err);
this.error = err.message || 'Failed to load profile';
this.error = err.message || i18n.failedToLoad;
} finally {
this.loading = false;
}
@@ -395,11 +412,11 @@ function shopProfilePage() {
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save profile');
throw new Error(error.detail || i18n.failedToSaveProfile);
}
this.profile = await response.json();
this.successMessage = 'Profile updated successfully';
this.successMessage = i18n.profileUpdated;
// Update localStorage user data
const userStr = localStorage.getItem('customer_user');
@@ -415,7 +432,7 @@ function shopProfilePage() {
} catch (err) {
console.error('Error saving profile:', err);
this.error = err.message || 'Failed to save profile';
this.error = err.message || i18n.failedToSaveProfile;
} finally {
this.savingProfile = false;
}
@@ -444,16 +461,16 @@ function shopProfilePage() {
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save preferences');
throw new Error(error.detail || i18n.failedToSavePreferences);
}
this.profile = await response.json();
this.successMessage = 'Preferences updated successfully';
this.successMessage = i18n.preferencesUpdated;
setTimeout(() => this.successMessage = '', 5000);
} catch (err) {
console.error('Error saving preferences:', err);
this.error = err.message || 'Failed to save preferences';
this.error = err.message || i18n.failedToSavePreferences;
} finally {
this.savingPreferences = false;
}
@@ -461,7 +478,7 @@ function shopProfilePage() {
async changePassword() {
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
this.passwordError = 'Passwords do not match';
this.passwordError = i18n.passwordsDoNotMatch;
return;
}
@@ -487,7 +504,7 @@ function shopProfilePage() {
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to change password');
throw new Error(error.detail || i18n.failedToChangePassword);
}
// Clear password form
@@ -497,12 +514,12 @@ function shopProfilePage() {
confirm_password: ''
};
this.successMessage = 'Password changed successfully';
this.successMessage = i18n.passwordChanged;
setTimeout(() => this.successMessage = '', 5000);
} catch (err) {
console.error('Error changing password:', err);
this.passwordError = err.message || 'Failed to change password';
this.passwordError = err.message || i18n.failedToChangePassword;
} finally {
this.changingPassword = false;
}

View File

@@ -39,7 +39,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>

View File

@@ -1,11 +1,11 @@
{# app/templates/storefront/account/reset-password.html #}
{# standalone #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="resetPassword()" lang="en">
<html :class="{ 'dark': dark }" x-data="resetPassword()" lang="{{ current_language|default('fr') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reset Password - {{ store.name }}</title>
<title>{{ _("auth.reset_password") }} - {{ store.name }}</title>
<!-- Fonts: Local fallback + Google Fonts -->
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
@@ -39,7 +39,7 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -57,7 +57,7 @@
<div class="text-6xl mb-4">🔑</div>
{% endif %}
<h2 class="text-2xl font-bold text-white mb-2">{{ store.name }}</h2>
<p class="text-white opacity-90">Create new password</p>
<p class="text-white opacity-90">{{ _("auth.reset_password_subtitle") }}</p>
</div>
</div>
@@ -68,21 +68,22 @@
<template x-if="tokenInvalid">
<div class="text-center">
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900">
<span class="w-8 h-8 text-red-600 dark:text-red-400" x-html="$icon('x-mark', 'w-8 h-8')"></span>
<svg class="w-8 h-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Invalid or Expired Link
{{ _("auth.invalid_or_expired_link") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
This password reset link is invalid or has expired.
Please request a new password reset link.
{{ _("auth.invalid_or_expired_link_desc") }}
</p>
<a href="{{ base_url }}account/forgot-password"
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
Request New Link
{{ _("auth.request_new_link") }}
</a>
</div>
</template>
@@ -91,11 +92,11 @@
<template x-if="!tokenInvalid && !resetComplete">
<div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Reset Your Password
{{ _("auth.reset_your_password") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
Enter your new password below. Password must be at least 8 characters.
{{ _("auth.reset_password_form_desc") }}
</p>
<!-- Error Message -->
@@ -107,14 +108,14 @@
<!-- Reset Password Form -->
<form @submit.prevent="handleSubmit">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">New Password</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.new_password") }}</span>
<input x-model="password"
:disabled="loading"
@input="clearErrors"
type="password"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.password }"
placeholder="Enter new password"
placeholder="{{ _('auth.new_password_placeholder') }}"
autocomplete="new-password"
required />
<span x-show="errors.password" x-text="errors.password"
@@ -122,14 +123,14 @@
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Confirm Password</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.confirm_password") }}</span>
<input x-model="confirmPassword"
:disabled="loading"
@input="clearErrors"
type="password"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.confirmPassword }"
placeholder="Confirm new password"
placeholder="{{ _('auth.confirm_password_placeholder') }}"
autocomplete="new-password"
required />
<span x-show="errors.confirmPassword" x-text="errors.confirmPassword"
@@ -138,10 +139,13 @@
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Reset Password</span>
<span x-show="!loading">{{ _("auth.reset_password_btn") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Resetting...
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ _("auth.resetting") }}
</span>
</button>
</form>
@@ -152,21 +156,22 @@
<template x-if="resetComplete">
<div class="text-center">
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Password Reset Complete
{{ _("auth.password_reset_complete") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
Your password has been successfully reset.
You can now sign in with your new password.
{{ _("auth.password_reset_success_desc") }}
</p>
<a href="{{ base_url }}account/login"
class="btn-primary-theme inline-block px-6 py-2 text-sm font-medium text-white rounded-lg">
Sign In
{{ _("auth.sign_in") }}
</a>
</div>
</template>
@@ -174,19 +179,34 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.remember_password") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/login">
Sign in
{{ _("auth.sign_in") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
← Continue shopping
&larr; {{ _("auth.back_to_home") }}
</a>
</p>
<!-- Language selector -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -196,9 +216,37 @@
<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
{# Translated client-side strings — kept in sync with auth.* keys above #}
<script>
window.__resetPasswordI18n = {
passwordRequired: {{ _('auth.password_required')|tojson }},
passwordTooShort: {{ _('auth.password_too_short')|tojson }},
pleaseConfirmPassword: {{ _('auth.please_confirm_password')|tojson }},
passwordsDoNotMatch: {{ _('auth.passwords_do_not_match')|tojson }},
resetPasswordFailed: {{ _('auth.reset_password_failed')|tojson }},
};
</script>
<!-- Reset Password Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function resetPassword() {
const i18n = window.__resetPasswordI18n || {};
return {
// Data
token: '',
@@ -251,22 +299,22 @@
// Validation
if (!this.password) {
this.errors.password = 'Password is required';
this.errors.password = i18n.passwordRequired;
return;
}
if (this.password.length < 8) {
this.errors.password = 'Password must be at least 8 characters';
this.errors.password = i18n.passwordTooShort;
return;
}
if (!this.confirmPassword) {
this.errors.confirmPassword = 'Please confirm your password';
this.errors.confirmPassword = i18n.pleaseConfirmPassword;
return;
}
if (this.password !== this.confirmPassword) {
this.errors.confirmPassword = 'Passwords do not match';
this.errors.confirmPassword = i18n.passwordsDoNotMatch;
return;
}
@@ -294,7 +342,7 @@
return;
}
}
throw new Error(data.detail || 'Failed to reset password');
throw new Error(data.detail || i18n.resetPasswordFailed);
}
// Success
@@ -302,7 +350,7 @@
} catch (error) {
console.error('Reset password error:', error);
this.showAlert(error.message || 'Failed to reset password. Please try again.');
this.showAlert(error.message || i18n.resetPasswordFailed);
} finally {
this.loading = false;
}

View File

@@ -489,7 +489,7 @@ function adminInventory() {
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
},
/**

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ Platform admin endpoints for:
import logging
from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Depends, Path, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -412,6 +412,7 @@ def get_merchant_card(
response_model=TransactionListResponse,
)
def get_merchant_card_transactions(
request: Request,
merchant_id: int = Path(..., gt=0),
card_id: int = Path(..., gt=0),
skip: int = Query(0, ge=0),
@@ -430,10 +431,26 @@ def get_merchant_card_transactions(
db, card_id, skip=skip, limit=limit
)
return TransactionListResponse(
transactions=[TransactionResponse.model_validate(t) for t in transactions],
total=total,
)
# Enrich category_names from category_ids (same as store + merchant routes
# — keeps the transaction history aligned across personas).
from app.modules.loyalty.services.category_service import category_service
lang = getattr(request.state, "language", "en") or "en"
tx_responses = []
for t in transactions:
tx = TransactionResponse.model_validate(t)
if t.category_ids and isinstance(t.category_ids, list):
names = []
for cid in t.category_ids:
name = category_service.validate_category_for_store(
db, cid, t.store_id or 0, lang=lang
)
if name:
names.append(name)
tx.category_names = names if names else None
tx_responses.append(tx)
return TransactionListResponse(transactions=tx_responses, total=total)
@router.get("/merchants/{merchant_id}/transactions", response_model=TransactionListResponse)

View File

@@ -23,7 +23,7 @@ registration under /api/v1/merchants/loyalty/*).
import logging
from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_api, get_merchant_for_current_user
@@ -256,6 +256,7 @@ def get_card_detail(
@router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse)
def get_card_transactions(
request: Request,
card_id: int = Path(..., gt=0),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
@@ -271,10 +272,26 @@ def get_card_transactions(
db, card_id, skip=skip, limit=limit
)
return TransactionListResponse(
transactions=[TransactionResponse.model_validate(t) for t in transactions],
total=total,
)
# Enrich category_names from category_ids (same as store route — keeps the
# transaction history aligned across personas).
from app.modules.loyalty.services.category_service import category_service
lang = getattr(request.state, "language", "en") or "en"
tx_responses = []
for t in transactions:
tx = TransactionResponse.model_validate(t)
if t.category_ids and isinstance(t.category_ids, list):
names = []
for cid in t.category_ids:
name = category_service.validate_category_for_store(
db, cid, t.store_id or 0, lang=lang
)
if name:
names.append(name)
tx.category_names = names if names else None
tx_responses.append(tx)
return TransactionListResponse(transactions=tx_responses, total=total)
# =============================================================================

View File

@@ -94,6 +94,7 @@ def self_enroll(
customer_name=data.customer_name,
customer_phone=data.customer_phone,
customer_birthday=data.customer_birthday,
customer_language=getattr(request.state, "language", None),
)
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")

View File

@@ -188,6 +188,7 @@ class CardService:
customer_name: str | None = None,
customer_phone: str | None = None,
customer_birthday: date | None = None,
customer_language: str | None = None,
) -> int:
"""
Resolve a customer ID from either a direct ID or email lookup.
@@ -224,11 +225,18 @@ class CardService:
customer = customer_service.get_customer_by_email(db, store_id, email)
if customer:
# Backfill birthday on existing customer if they didn't have
# one before — keeps the enrollment form useful for returning
# customers who never previously provided a birthday.
# Backfill birthday + preferred_language on existing customer
# if they were missing — keeps the enrollment form useful for
# returning customers and lets transactional emails (welcome,
# password reset) hit the right locale.
dirty = False
if customer_birthday and not customer.birth_date:
customer.birth_date = customer_birthday
dirty = True
if customer_language and not customer.preferred_language:
customer.preferred_language = customer_language
dirty = True
if dirty:
db.flush()
return customer.id
@@ -250,8 +258,17 @@ class CardService:
.first()
)
if existing_cardholder:
dirty = False
if customer_birthday and not existing_cardholder.birth_date:
existing_cardholder.birth_date = customer_birthday
dirty = True
if (
customer_language
and not existing_cardholder.preferred_language
):
existing_cardholder.preferred_language = customer_language
dirty = True
if dirty:
db.flush()
return existing_cardholder.id
@@ -272,6 +289,7 @@ class CardService:
last_name=last_name,
phone=customer_phone,
birth_date=customer_birthday,
preferred_language=customer_language,
)
logger.info(
f"Created customer {customer.id} ({email}) "

View File

@@ -51,6 +51,7 @@ class LoyaltyWidgetProvider:
) -> list[StorefrontDashboardCard]:
"""Provide the Loyalty Rewards card for the customer dashboard."""
from app.modules.loyalty.models.loyalty_card import LoyaltyCard
from app.utils.i18n import translate
card = (
db.query(LoyaltyCard)
@@ -62,17 +63,26 @@ class LoyaltyWidgetProvider:
)
points = card.points_balance if card else None
subtitle = "View your points & rewards" if card else "Join our rewards program"
lang = context.language if context else None
subtitle_key = (
"loyalty.widget.rewards.subtitle_member"
if card
else "loyalty.widget.rewards.subtitle_join"
)
return [
StorefrontDashboardCard(
key="loyalty.rewards",
icon="gift",
title="Loyalty Rewards",
subtitle=subtitle,
title=translate("loyalty.widget.rewards.title", lang),
subtitle=translate(subtitle_key, lang),
route="account/loyalty",
value=points,
value_label="Points Balance" if points is not None else None,
value_label=(
translate("loyalty.widget.rewards.value_label", lang)
if points is not None
else None
),
order=30,
),
]

View File

@@ -188,7 +188,7 @@ function adminLoyaltyAnalytics() {
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
}
};
}

View File

@@ -255,7 +255,7 @@ function adminLoyaltyMerchantDetail() {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -269,7 +269,7 @@ function adminLoyaltyMerchantDetail() {
// Format number with thousands separator
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
},
// Transaction categories

View File

@@ -325,7 +325,7 @@ function adminLoyaltyPrograms() {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -339,7 +339,7 @@ function adminLoyaltyPrograms() {
// Format number with thousands separator
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
}
};
}

View File

@@ -98,7 +98,7 @@ function merchantLoyaltyAnalytics() {
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
}
};
}

View File

@@ -8,9 +8,12 @@ const loyaltyCardDetailViewLog = window.LogConfig.loggers.loyaltyCardDetailView
* Factory that returns an Alpine.js data object for a single loyalty card detail view.
*
* @param {Object} config
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.backUrl - URL for the "back to list" navigation
* @param {string} config.currentPage - Alpine currentPage identifier
* @param {string} config.apiPrefix - API path prefix, e.g. '/store/loyalty'
* @param {string} config.backUrl - URL for the "back to list" navigation
* @param {string} config.currentPage - Alpine currentPage identifier
* @param {boolean} [config.paginate] - Enable paginated transaction history (default false, loads up to 50)
* @param {Object} [config.txLabels] - Map of transaction_type -> translated label (default {})
* @param {Object} [config.txNotes] - Map of raw_note -> translated note (default {})
*/
function loyaltyCardDetailView(config) {
const guardKey = '_loyaltyCardDetailView_' + config.currentPage + '_initialized';
@@ -24,6 +27,14 @@ function loyaltyCardDetailView(config) {
card: null,
transactions: [],
// Translation maps (defaults to empty so admin/merchant get raw values)
txLabels: config.txLabels || {},
txNotes: config.txNotes || {},
// Pagination (always present so the shared template's pagination macro
// resolves; only the wrapper that sets show_pagination renders it)
pagination: { page: 1, per_page: 20, total: 0 },
// State
loading: false,
error: null,
@@ -41,7 +52,6 @@ function loyaltyCardDetailView(config) {
await parentInit.call(this);
}
// Extract cardId from URL: find 'cards' segment and take the next segment
this.cardId = this._extractCardIdFromUrl();
if (!this.cardId) {
this.error = I18n.t('loyalty.errors.card_not_found');
@@ -49,6 +59,12 @@ function loyaltyCardDetailView(config) {
return;
}
if (config.paginate && window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* keep default */ }
}
loyaltyCardDetailViewLog.info('Card ID extracted:', this.cardId);
await this.loadData();
loyaltyCardDetailViewLog.info('=== LOYALTY CARD DETAIL VIEW INITIALIZATION COMPLETE ===');
@@ -100,30 +116,74 @@ function loyaltyCardDetailView(config) {
}
},
async loadTransactions() {
async loadTransactions(page = 1) {
try {
const response = await apiClient.get(
config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50'
);
let url;
if (config.paginate) {
const skip = (page - 1) * this.pagination.per_page;
url = config.apiPrefix + '/cards/' + this.cardId
+ '/transactions?skip=' + skip + '&limit=' + this.pagination.per_page;
} else {
url = config.apiPrefix + '/cards/' + this.cardId + '/transactions?limit=50';
}
const response = await apiClient.get(url);
if (response) {
this.transactions = Array.isArray(response)
? response
: (response.transactions || []);
if (config.paginate) {
this.pagination.total = response.total || 0;
this.pagination.page = page;
}
}
} catch (error) {
loyaltyCardDetailViewLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro).
// Always present; only rendered when the template sets show_pagination.
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
return this.pagination.total === 0
? 0
: (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p !== '...' && p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
// Formatting helpers
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
return num == null ? '0' : new Intl.NumberFormat(I18n.locale).format(num);
},
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -136,7 +196,7 @@ function loyaltyCardDetailView(config) {
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -223,13 +223,13 @@ function loyaltyCardsList(config) {
// Formatting helpers
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
return num == null ? '0' : new Intl.NumberFormat(I18n.locale).format(num);
},
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'

View File

@@ -110,7 +110,7 @@ function loyaltyDevicesList(config) {
formatDate(value) {
if (!value) return '-';
try {
return new Date(value).toLocaleString();
return new Date(value).toLocaleString(I18n.locale);
} catch (e) {
return value;
}

View File

@@ -317,7 +317,7 @@ function loyaltyPinsList(config) {
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -330,7 +330,7 @@ function loyaltyPinsList(config) {
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -209,13 +209,13 @@ function loyaltyTransactionsList(config) {
// Formatting helpers
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
return num == null ? '0' : new Intl.NumberFormat(I18n.locale).format(num);
},
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -228,7 +228,7 @@ function loyaltyTransactionsList(config) {
formatDateTime(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -174,7 +174,7 @@ function storeLoyaltyAnalytics() {
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
}
};
}

View File

@@ -1,148 +1,17 @@
// app/modules/loyalty/static/store/js/loyalty-card-detail.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Store wrapper around the shared loyaltyCardDetailView factory.
const loyaltyCardDetailLog = window.LogConfig.loggers.loyaltyCardDetail || window.LogConfig.createLogger('loyaltyCardDetail');
function storeLoyaltyCardDetail() {
return {
...data(),
return loyaltyCardDetailView({
apiPrefix: '/store/loyalty',
backUrl: '/store/' + (window.STORE_CODE || '') + '/loyalty/cards',
currentPage: 'cards',
cardId: null,
card: null,
transactions: [],
pagination: { page: 1, per_page: 20, total: 0 },
txLabels: window._cardDetailLabels?.txLabels || {},
txNotes: window._cardDetailLabels?.txNotes || {},
loading: false,
error: null,
async init() {
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZING ===');
if (window._loyaltyCardDetailInitialized) return;
window._loyaltyCardDetailInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Extract card ID from URL
const pathParts = window.location.pathname.split('/');
const cardsIndex = pathParts.indexOf('cards');
if (cardsIndex !== -1 && pathParts[cardsIndex + 1]) {
this.cardId = parseInt(pathParts[cardsIndex + 1]);
}
if (!this.cardId) {
this.error = 'Invalid card ID';
return;
}
// Use platform pagination setting if available
if (window.PlatformSettings) {
try {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
} catch (e) { /* use default */ }
}
await this.loadData();
loyaltyCardDetailLog.info('=== LOYALTY CARD DETAIL PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.loadCard(),
this.loadTransactions()
]);
} catch (error) {
loyaltyCardDetailLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadCard() {
const response = await apiClient.get(`/store/loyalty/cards/${this.cardId}`);
if (response) {
this.card = response;
loyaltyCardDetailLog.info('Card loaded:', this.card.card_number);
}
},
async loadTransactions(page = 1) {
try {
const skip = (page - 1) * this.pagination.per_page;
const response = await apiClient.get(
`/store/loyalty/cards/${this.cardId}/transactions?skip=${skip}&limit=${this.pagination.per_page}`
);
if (response && response.transactions) {
this.transactions = response.transactions;
this.pagination.total = response.total || 0;
this.pagination.page = page;
loyaltyCardDetailLog.info(`Loaded ${this.transactions.length} of ${this.pagination.total} transactions (page ${page})`);
}
} catch (error) {
loyaltyCardDetailLog.warn('Failed to load transactions:', error.message);
}
},
// Standard pagination interface (matches shared pagination macro)
get totalPages() {
return Math.max(1, Math.ceil(this.pagination.total / this.pagination.per_page));
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
return Math.min(this.pagination.page * this.pagination.per_page, this.pagination.total);
},
get pageNumbers() {
const pages = [];
for (let i = 1; i <= this.totalPages; i++) {
if (i === 1 || i === this.totalPages || Math.abs(i - this.pagination.page) <= 1) {
pages.push(i);
} else if (pages[pages.length - 1] !== '...') {
pages.push('...');
}
}
return pages;
},
previousPage() { if (this.pagination.page > 1) this.loadTransactions(this.pagination.page - 1); },
nextPage() { if (this.pagination.page < this.totalPages) this.loadTransactions(this.pagination.page + 1); },
goToPage(p) { if (p >= 1 && p <= this.totalPages) this.loadTransactions(p); },
formatNumber(num) {
return num == null ? '0' : new Intl.NumberFormat('en-US').format(num);
},
formatDate(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
} catch (e) { return dateString; }
},
formatDateTime(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
} catch (e) { return dateString; }
}
};
paginate: true,
txLabels: (window._cardDetailLabels && window._cardDetailLabels.txLabels) || {},
txNotes: (window._cardDetailLabels && window._cardDetailLabels.txNotes) || {},
});
}
if (!window.LogConfig.loggers.loyaltyCardDetail) {

View File

@@ -1,162 +1,15 @@
// app/modules/loyalty/static/store/js/loyalty-cards.js
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// Store wrapper around the shared loyaltyCardsList factory.
const loyaltyCardsLog = window.LogConfig.loggers.loyaltyCards || window.LogConfig.createLogger('loyaltyCards');
function storeLoyaltyCards() {
return {
...data(),
return loyaltyCardsList({
apiPrefix: '/store/loyalty',
baseUrl: '/store/' + (window.STORE_CODE || '') + '/loyalty/cards',
showStoreFilter: false,
currentPage: 'cards',
// Data
cards: [],
program: null,
stats: {
total_cards: 0,
active_cards: 0,
new_this_month: 0,
total_points_balance: 0
},
// Filters
filters: {
search: '',
status: ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// State
loading: false,
error: null,
async init() {
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZING ===');
if (window._loyaltyCardsInitialized) return;
window._loyaltyCardsInitialized = true;
// IMPORTANT: Call parent init first to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadData();
loyaltyCardsLog.info('=== LOYALTY CARDS PAGE INITIALIZATION COMPLETE ===');
},
async loadData() {
this.loading = true;
this.error = null;
try {
await this.loadProgram();
if (this.program) {
await Promise.all([this.loadCards(), this.loadStats()]);
}
} catch (error) {
loyaltyCardsLog.error('Failed to load data:', error);
this.error = error.message;
} finally {
this.loading = false;
}
},
async loadProgram() {
try {
const response = await apiClient.get('/store/loyalty/program');
if (response) this.program = response;
} catch (error) {
if (error.status !== 404) throw error;
}
},
async loadCards() {
const params = new URLSearchParams();
params.append('skip', (this.pagination.page - 1) * this.pagination.per_page);
params.append('limit', this.pagination.per_page);
if (this.filters.search) params.append('search', this.filters.search);
if (this.filters.status) params.append('is_active', this.filters.status === 'active');
const response = await apiClient.get(`/store/loyalty/cards?${params}`);
if (response) {
this.cards = response.cards || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
}
},
async loadStats() {
try {
const response = await apiClient.get('/store/loyalty/stats');
if (response) {
this.stats = {
total_cards: response.total_cards || 0,
active_cards: response.active_cards || 0,
new_this_month: response.new_this_month || 0,
total_points_balance: response.total_points_balance || 0
};
}
} catch (error) {
loyaltyCardsLog.warn('Failed to load stats:', error.message);
}
},
debouncedSearch() {
if (this._searchTimeout) clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadCards();
}, 300);
},
applyFilter() {
this.pagination.page = 1;
this.loadCards();
},
get totalPages() { return this.pagination.pages; },
get startIndex() { return this.pagination.total === 0 ? 0 : (this.pagination.page - 1) * this.pagination.per_page + 1; },
get endIndex() { const end = this.pagination.page * this.pagination.per_page; return end > this.pagination.total ? this.pagination.total : end; },
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); }
else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
}
return pages;
},
previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadCards(); } },
nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadCards(); } },
goToPage(num) { if (num !== '...' && num >= 1 && num <= this.totalPages) { this.pagination.page = num; this.loadCards(); } },
formatNumber(num) { return num == null ? '0' : new Intl.NumberFormat('en-US').format(num); },
formatDate(dateString) {
if (!dateString) return 'Never';
try {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) { return dateString; }
}
};
});
}
if (!window.LogConfig.loggers.loyaltyCards) {

View File

@@ -275,7 +275,14 @@ function storeLoyaltyTerminal() {
await this.loadRecentTransactions();
} catch (error) {
Utils.showToast(I18n.t('loyalty.store.terminal.transaction_failed', {message: error.message}), 'error');
// Localise well-known errors by error_code instead of
// showing the raw English message from the API.
if (error.errorCode === 'POINTS_COOLDOWN' || error.errorCode === 'STAMP_COOLDOWN') {
const minutes = error.details?.cooldown_minutes ?? this.program?.cooldown_minutes ?? '';
Utils.showToast(I18n.t('loyalty.store.terminal.cooldown_wait_minutes', {minutes}), 'warning');
} else {
Utils.showToast(I18n.t('loyalty.store.terminal.transaction_failed', {message: error.message}), 'error');
}
loyaltyTerminalLog.error('Transaction failed:', error);
} finally {
this.processing = false;
@@ -392,7 +399,7 @@ function storeLoyaltyTerminal() {
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
},
// Format time
@@ -400,7 +407,7 @@ function storeLoyaltyTerminal() {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
return date.toLocaleTimeString(I18n.locale, {
hour: '2-digit',
minute: '2-digit'
});

View File

@@ -16,8 +16,14 @@ function customerLoyaltyDashboard() {
// Wallet
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
// UI state
loading: false,
// UI state.
// `loading` starts true so the "join the program" CTA
// (x-show="!loading && !card") doesn't flash for one render tick
// before init() / loadData() fires. With loading=true initially,
// both the card view and the join CTA stay hidden until the API
// call resolves — and if a 401 triggers a redirect, the user
// never sees the wrong UI.
loading: true,
showBarcode: false,
async init() {
@@ -73,13 +79,13 @@ function customerLoyaltyDashboard() {
formatNumber(num) {
if (num == null) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
},
formatDate(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'

View File

@@ -18,8 +18,11 @@ function customerLoyaltyHistory() {
pages: 0
},
// State
loading: false,
// State.
// Starts true so empty / no-data branches don't flash for one
// tick before the API call resolves (and if a 401 triggers the
// login redirect, the user never sees the wrong UI).
loading: true,
async init() {
loyaltyHistoryLog.info('Customer loyalty history initializing...');
@@ -93,13 +96,13 @@ function customerLoyaltyHistory() {
formatNumber(num) {
if (num == null) return '0';
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
},
formatDateTime(dateString) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString('en-US', {
return new Date(dateString).toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -17,6 +17,7 @@
{% set card_detail_api_prefix = '/admin/loyalty/merchants/' + merchant_id|string %}
{% set card_detail_back_url = '/admin/loyalty/merchants/' + merchant_id|string + '/cards' %}
{% set show_category_column = true %}
{% include 'loyalty/shared/card-detail-view.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/merchant-settings.html #}
{# noqa: TPL-016 — admin-only merchant config aggregator, no per-persona equivalent #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import detail_page_header %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/programs.html #}
{# noqa: TPL-016 — admin multi-merchant programs aggregator: different shape from the single-program merchant/store views #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/admin/wallet-debug.html #}
{# noqa: TPL-016 — admin-only Apple/Google wallet diagnostic tool, no persona equivalent #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}

View File

@@ -20,6 +20,7 @@
{% set card_detail_api_prefix = '/merchants/loyalty' %}
{% set card_detail_back_url = '/merchants/loyalty/cards' %}
{% set show_category_column = true %}
{% include 'loyalty/shared/card-detail-view.html' %}
{% endblock %}

View File

@@ -3,9 +3,17 @@
Shared loyalty card detail view partial. Set these variables before including:
- card_detail_api_prefix (str): API base URL for card data
- card_detail_back_url (str): URL for the back button
- show_copy_buttons (bool, default false): Show clipboard-copy buttons next to customer name/email/phone/card_number
- show_category_column (bool, default false): Add a "Category" column to the transaction history table
- show_pagination (bool, default false): Render pagination after the transaction history table
The factory exposes `txLabels` (object: transaction_type -> translated label) and
`txNotes` (object: raw_note -> translated note) — defaults to {} so admin/merchant
callers that don't provide translations get the raw values shown.
#}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/pagination.html' import pagination %}
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
@@ -58,15 +66,42 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.name') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.email') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.phone') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.birthday') }}</p>
@@ -84,7 +119,16 @@
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.card_number') }}</p>
{% if show_copy_buttons %}
<div class="flex items-center gap-2">
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
{% else %}
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
{% endif %}
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.shared.card_detail.status') }}</p>
@@ -110,12 +154,17 @@
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.shared.card_detail.transaction_history') }}
</h3>
{% set _tx_colspan = 6 if show_category_column else 5 %}
{% call table_wrapper() %}
{% if show_category_column %}
{{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_category'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }}
{% else %}
{{ table_header([_('loyalty.shared.card_detail.col_date'), _('loyalty.shared.card_detail.col_type'), _('loyalty.shared.card_detail.col_points'), _('loyalty.shared.card_detail.col_location'), _('loyalty.shared.card_detail.col_notes')]) }}
{% endif %}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="transactions.length === 0">
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<td colspan="{{ _tx_colspan }}" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('clock', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.shared.card_detail.no_transactions') }}</p>
@@ -132,15 +181,22 @@
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
}"
x-text="tx.transaction_type.replace(/_/g, ' ')"></span>
x-text="txLabels[tx.transaction_type] || tx.transaction_type.replace(/_/g, ' ')"></span>
</td>
<td class="px-4 py-3 text-sm font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
{% if show_category_column %}
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
{% endif %}
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.notes || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="txNotes[tx.notes] || tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
{% if show_pagination %}
{{ pagination() }}
{% endif %}
</div>

View File

@@ -2,8 +2,6 @@
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}{{ _('loyalty.store.card_detail.title') }}{% endblock %}
@@ -20,168 +18,19 @@
{{ error_state(_('loyalty.store.card_detail.error_loading')) }}
<div x-show="!loading && card">
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.points_balance') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.points_balance)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('trending-up', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.total_earned') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_earned)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('gift', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.total_redeemed') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(card?.total_points_redeemed)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('calendar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.card_detail.member_since') }}</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="formatDate(card?.created_at)">-</p>
</div>
</div>
</div>
<div class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Customer Info -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('user', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.card_detail.customer_information') }}
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.name') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_name || '-'">-</p>
<button x-show="card?.customer_name" @click="Utils.copyToClipboard(card.customer_name)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.email') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_email || '-'">-</p>
<button x-show="card?.customer_email" @click="Utils.copyToClipboard(card.customer_email)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.phone') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_phone || '-'">-</p>
<button x-show="card?.customer_phone" @click="Utils.copyToClipboard(card.customer_phone)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.birthday') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.customer_birthday || '-'">-</p>
</div>
</div>
</div>
<!-- Card Info -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('credit-card', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.card_detail.card_details') }}
</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.card_number') }}</p>
<div class="flex items-center gap-2">
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="card?.card_number">-</p>
<button x-show="card?.card_number" @click="Utils.copyToClipboard(card.card_number)" type="button" aria-label="Copy" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('clipboard-copy', 'w-3.5 h-3.5')"></span>
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.status') }}</p>
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="card?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
x-text="card?.is_active ? $t('loyalty.common.active') : $t('loyalty.common.inactive')"></span>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.last_activity') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(card?.last_activity_at) || 'Never'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">{{ _('loyalty.store.card_detail.enrolled_at') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="card?.enrolled_at_store_name || 'Unknown'">-</p>
</div>
</div>
</div>
</div>
<!-- Transaction History -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('clock', 'inline w-5 h-5 mr-2')"></span>
{{ _('loyalty.store.card_detail.transaction_history') }}
</h3>
{% call table_wrapper() %}
{{ table_header([_('loyalty.store.card_detail.col_date'), _('loyalty.store.card_detail.col_type'), _('loyalty.store.card_detail.col_points'), _('loyalty.store.terminal.select_category'), _('loyalty.store.card_detail.col_location'), _('loyalty.store.card_detail.col_notes')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="transactions.length === 0">
<tr>
<td colspan="6" class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
{{ _('loyalty.store.card_detail.no_transactions') }}
</td>
</tr>
</template>
<template x-for="tx in transactions" :key="tx.id">
<tr class="text-gray-700 dark:text-gray-400">
<td class="px-4 py-3 text-sm" x-text="formatDateTime(tx.transaction_at)"></td>
<td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': tx.points_delta > 0,
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': tx.points_delta < 0
}"
x-text="txLabels[tx.transaction_type] || tx.transaction_type.replace(/_/g, ' ')"></span>
</td>
<td class="px-4 py-3 text-sm font-medium"
:class="tx.points_delta > 0 ? 'text-green-600' : 'text-orange-600'"
x-text="(tx.points_delta > 0 ? '+' : '') + formatNumber(tx.points_delta)"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="tx.category_names?.join(', ') || '-'"></td>
<td class="px-4 py-3 text-sm" x-text="tx.store_name || '-'"></td>
<td class="px-4 py-3 text-sm text-gray-500" x-text="txNotes[tx.notes] || tx.notes || '-'"></td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% set card_detail_api_prefix = '/store/loyalty' %}
{% set card_detail_back_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% set show_copy_buttons = true %}
{% set show_category_column = true %}
{% set show_pagination = true %}
{% include 'loyalty/shared/card-detail-view.html' %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Server-rendered transaction type labels + system note translations
// Server-rendered transaction type labels + system note translations.
// The shared factory reads these via window._cardDetailLabels.
window._cardDetailLabels = {
txLabels: {
card_created: {{ _('loyalty.transactions.card_created')|tojson }},
@@ -201,5 +50,6 @@ window._cardDetailLabels = {
}
};
</script>
<script defer src="{{ static_v(request, 'loyalty_static', path='shared/js/loyalty-card-detail-view.js') }}"></script>
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-card-detail.js') }}"></script>
{% endblock %}

View File

@@ -1,9 +1,7 @@
{# app/modules/loyalty/templates/loyalty/store/cards.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}{{ _('loyalty.store.cards.title') }}{% endblock %}
@@ -12,7 +10,6 @@
{% block alpine_data %}storeLoyaltyCards(){% endblock %}
{% block content %}
<!-- Page Header -->
{% call page_header_flex(title=_('loyalty.store.cards.title'), subtitle=_('loyalty.store.cards.subtitle')) %}
<div class="flex items-center gap-3">
{{ refresh_button(loading_var='loading', onclick='loadCards()', variant='secondary') }}
@@ -25,7 +22,6 @@
{% endcall %}
{{ loading_state(_('loyalty.store.cards.loading')) }}
{{ error_state(_('loyalty.store.cards.error_loading')) }}
<!-- No Program Setup Notice -->
@@ -48,124 +44,13 @@
</div>
</div>
<!-- Stats Cards -->
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('users', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.total_members') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_cards)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.active_30d') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.active_cards)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('user-plus', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.new_this_month') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.new_this_month)">0</p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('currency-dollar', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('loyalty.store.cards.total_points_balance') }}</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_points_balance)">0</p>
</div>
</div>
</div>
<!-- Search and Filters -->
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="{{ _('loyalty.store.cards.search_placeholder') }}"
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div>
</div>
<select x-model="filters.status" @change="applyFilter()"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
<option value="">{{ _('loyalty.store.cards.all_status') }}</option>
<option value="active">{{ _('loyalty.common.active') }}</option>
<option value="inactive">{{ _('loyalty.common.inactive') }}</option>
</select>
</div>
</div>
<!-- Cards Table -->
<div x-show="!loading && program">
{% call table_wrapper() %}
{{ table_header([_('loyalty.store.cards.col_member'), _('loyalty.store.cards.col_card_number'), _('loyalty.store.cards.col_points_balance'), _('loyalty.store.cards.col_last_activity'), _('loyalty.store.cards.col_status'), _('loyalty.store.cards.col_actions')]) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-if="cards.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ _('loyalty.store.cards.no_members') }}</p>
<p class="text-xs mt-1" x-text="filters.search ? '{{ _('loyalty.store.cards.adjust_search')|replace("'", "\\'") }}' : '{{ _('loyalty.store.cards.enroll_first')|replace("'", "\\'") }}'"></p>
</div>
</td>
</tr>
</template>
<template x-for="card in cards" :key="card.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3">
<div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block"
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '20'">
<span class="absolute inset-0 flex items-center justify-center text-xs font-semibold"
:style="'color: ' + (program?.card_color || '#4F46E5')"
x-text="card.customer_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
<div>
<p class="font-semibold" x-text="card.customer_name || 'Unknown'"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="card.customer_email || card.customer_phone || '-'"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm font-mono" x-text="card.card_number"></td>
<td class="px-4 py-3 text-sm font-semibold" x-text="formatNumber(card.points_balance)"></td>
<td class="px-4 py-3 text-sm" x-text="formatDate(card.last_activity_at)"></td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="card.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100'"
x-text="card.is_active ? '{{ _('loyalty.common.active')|replace("'", "\\'") }}' : '{{ _('loyalty.common.inactive')|replace("'", "\\'") }}'"></span>
</td>
<td class="px-4 py-3">
<a :href="'/store/{{ store_code }}/loyalty/cards/' + card.id"
class="text-purple-600 hover:text-purple-700 dark:text-purple-400"
>{{ _('loyalty.common.view') }}</a>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination() }}
</div>
{% set cards_api_prefix = '/store/loyalty' %}
{% set cards_base_url = '/store/' ~ store_code ~ '/loyalty/cards' %}
{% set show_store_filter = false %}
{% include 'loyalty/shared/cards-list.html' %}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ static_v(request, 'loyalty_static', path='shared/js/loyalty-cards-list.js') }}"></script>
<script defer src="{{ static_v(request, 'loyalty_static', path='store/js/loyalty-cards.js') }}"></script>
{% endblock %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/store/enroll.html #}
{# noqa: TPL-016 — store-only counter-staff enrollment flow, no merchant/admin equivalent #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import detail_page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}

View File

@@ -1,4 +1,5 @@
{# app/modules/loyalty/templates/loyalty/store/terminal.html #}
{# noqa: TPL-016 — store-only POS hardware terminal UI, no persona equivalent #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}

View File

@@ -387,7 +387,7 @@ function adminImports() {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
return date.toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -157,7 +157,7 @@ function adminMarketplaceProductDetail() {
*/
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return '-';
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(I18n.locale, {
style: 'currency',
currency: currency || 'EUR'
}).format(price);
@@ -170,7 +170,7 @@ function adminMarketplaceProductDetail() {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -515,7 +515,7 @@ function adminMarketplaceProducts() {
*/
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return '-';
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(I18n.locale, {
style: 'currency',
currency: currency || 'EUR'
}).format(price);

View File

@@ -453,7 +453,7 @@ function adminMarketplace() {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
return date.toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -8,7 +8,7 @@
<title>Welcome to Orion - Setup Your Account</title>
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='store/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='store/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
@@ -373,10 +373,10 @@
</div>
<!-- Scripts -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js"></script>
<script defer src="{{ static_v(request, 'marketplace_static', path='store/js/onboarding.js') }}"></script>
</body>

View File

@@ -387,7 +387,7 @@ function adminImports() {
try {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
return date.toLocaleString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -99,7 +99,7 @@ function adminPlatformHealth() {
if (typeof num === 'number' && num % 1 !== 0) {
return num.toFixed(2);
}
return new Intl.NumberFormat('en-US').format(num);
return new Intl.NumberFormat(I18n.locale).format(num);
},
/**

View File

@@ -1,4 +1,11 @@
{
"widget": {
"summary": {
"title": "Bestellungen",
"subtitle": "Bestellverlauf anzeigen",
"value_label": "Bestellungen gesamt"
}
},
"orders": {
"title": "Bestellungen",
"order": "Bestellung",

View File

@@ -1,4 +1,11 @@
{
"widget": {
"summary": {
"title": "Orders",
"subtitle": "View order history",
"value_label": "Total Orders"
}
},
"orders": {
"title": "Orders",
"order": "Order",

View File

@@ -1,4 +1,11 @@
{
"widget": {
"summary": {
"title": "Commandes",
"subtitle": "Voir l'historique des commandes",
"value_label": "Total des commandes"
}
},
"orders": {
"title": "Commandes",
"order": "Commande",

View File

@@ -1,4 +1,11 @@
{
"widget": {
"summary": {
"title": "Bestellungen",
"subtitle": "Bestellhistorik kucken",
"value_label": "Bestellunge gesamt"
}
},
"orders": {
"title": "Bestellungen",
"order": "Bestellung",

View File

@@ -51,6 +51,7 @@ class OrderWidgetProvider:
) -> list[StorefrontDashboardCard]:
"""Provide the Orders card for the customer dashboard."""
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
from app.utils.i18n import translate
stats = (
db.query(CustomerOrderStats)
@@ -62,16 +63,17 @@ class OrderWidgetProvider:
)
total_orders = stats.total_orders if stats else 0
lang = context.language if context else None
return [
StorefrontDashboardCard(
key="orders.summary",
icon="shopping-bag",
title="Orders",
subtitle="View order history",
title=translate("orders.widget.summary.title", lang),
subtitle=translate("orders.widget.summary.subtitle", lang),
route="account/orders",
value=total_orders,
value_label="Total Orders",
value_label=translate("orders.widget.summary.value_label", lang),
order=10,
),
]

View File

@@ -540,7 +540,7 @@ function adminOrders() {
*/
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return '-';
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(I18n.locale, {
style: 'currency',
currency: currency || 'EUR'
}).format(price);

View File

@@ -53,6 +53,8 @@ from app.modules.tenancy.schemas.auth import (
LoginResponse,
LogoutResponse,
OwnedMerchantSummary,
PasswordResetConfirm,
PasswordResetRequest,
PasswordResetRequestResponse,
PasswordResetResponse,
PlatformSelectResponse,
@@ -153,6 +155,8 @@ __all__ = [
"LoginResponse",
"LogoutResponse",
"OwnedMerchantSummary",
"PasswordResetConfirm",
"PasswordResetRequest",
"PasswordResetRequestResponse",
"PasswordResetResponse",
"PlatformSelectResponse",

View File

@@ -183,12 +183,25 @@ class LogoutResponse(BaseModel):
message: str
class PasswordResetRequest(BaseModel):
"""Schema for password reset request body (customer / storefront forgot-password)."""
email: str
class PasswordResetRequestResponse(BaseModel):
"""Schema for password reset request response."""
message: str
class PasswordResetConfirm(BaseModel):
"""Schema for password reset confirm body (customer / storefront reset-password)."""
reset_token: str
new_password: str
class PasswordResetResponse(BaseModel):
"""Schema for password reset response."""

View File

@@ -257,7 +257,7 @@ function adminMerchants() {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
return date.toLocaleDateString(I18n.locale, {
year: 'numeric',
month: 'short',
day: 'numeric'

View File

@@ -157,18 +157,33 @@ function storeLogin() {
? `/platforms/${platformCode}/store/${this.storeCode}`
: `/store/${this.storeCode}`;
// Check for last visited page (saved before logout)
// Decide where to land after login. Precedence:
// 1. ?next=<path> — set by apiClient on 401 mid-session;
// must be a /store/{code}/... URL that doesn't loop back
// to login or onboarding.
// 2. store_last_visited_page localStorage — fallback for
// organic logins (preserves the store-relative sub-path
// even if the basePath prefix changed, e.g. domain swap).
// 3. {basePath}/dashboard — last-resort default.
const nextParam = new URLSearchParams(window.location.search).get('next');
const lastPage = localStorage.getItem('store_last_visited_page');
let redirectTo = `${basePath}/dashboard`;
const isSafeStoreUrl = (u) =>
u && u.includes(`/store/${this.storeCode}/`) &&
!u.includes('/login') && !u.includes('/onboarding');
if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
// Extract the store-relative path (strip any existing prefix)
let redirectTo = `${basePath}/dashboard`;
if (isSafeStoreUrl(nextParam)) {
redirectTo = nextParam;
} else if (lastPage && !lastPage.includes('/login') && !lastPage.includes('/onboarding')) {
// Preserve only the store-relative sub-path; basePath
// may have changed (subdomain ↔ /platforms/... etc.)
const storePathMatch = lastPage.match(/\/store\/[^/]+(\/.*)/);
if (storePathMatch) {
redirectTo = `${basePath}${storePathMatch[1]}`;
}
}
storeLoginLog.info('next param:', nextParam);
storeLoginLog.info('Last visited page:', lastPage);
storeLoginLog.info('Redirecting to:', redirectTo);

View File

@@ -8,12 +8,12 @@
<!-- Fonts: Local fallback + Google Fonts -->
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='admin/css/tailwind.output.css') }}" />
<!-- Flag Icons CSS (for language selector) with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flag-icons.min.css') }}';"
/>
<style>
[x-cloak] { display: none !important; }
@@ -181,16 +181,16 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("admin") }}';</script>
<!-- 1. Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<!-- 2. Icons -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- 3. Utils -->
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<!-- 4. API Client -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 with CDN fallback -->
<script>

View File

@@ -148,5 +148,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('static', path='admin/js/module-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='admin/js/module-config.js') }}"></script>
{% endblock %}

View File

@@ -270,5 +270,5 @@
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('static', path='admin/js/module-info.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='admin/js/module-info.js') }}"></script>
{% endblock %}

View File

@@ -8,7 +8,7 @@
<title>Select Platform - Admin Panel</title>
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='admin/css/tailwind.output.css') }}" />
<style>[x-cloak] { display: none !important; }</style>
</head>
<body x-cloak>
@@ -173,10 +173,10 @@
</div>
<!-- Scripts -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'tenancy_static', path='admin/js/select-platform.js') }}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
</body>

View File

@@ -9,12 +9,12 @@
<!-- Fonts: Local fallback + Google Fonts -->
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='merchant/css/tailwind.output.css') }}" />
<!-- Flag Icons CSS (for language selector) with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flag-icons.min.css') }}';"
/>
<style>
[x-cloak] { display: none !important; }
@@ -182,16 +182,16 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("merchant") }}';</script>
<!-- 1. Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<!-- 2. Icons -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- 3. Utils -->
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<!-- 4. API Client -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 with CDN fallback -->
<script>

View File

@@ -8,11 +8,11 @@
<title>Accept Invitation - {{ invitation.store_name if invitation else 'Store' }}</title>
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='store/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='store/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
<script src="{{ static_v(request, 'static', path='shared/js/dev-toolbar.js') }}"></script>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -140,9 +140,9 @@
<!-- Scripts -->
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<script>
(function() {
var script = document.createElement('script');

View File

@@ -8,13 +8,13 @@
<!-- Fonts: Local fallback + Google Fonts -->
<link href="{{ static_v(request, 'static', path='shared/fonts/inter.css') }}" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='store/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='store/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
<script src="{{ static_v(request, 'static', path='shared/js/dev-toolbar.js') }}"></script>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
@@ -212,16 +212,16 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
<!-- 1. Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<!-- 2. Icons -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- 3. Utils -->
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<!-- 4. API Client -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 with CDN fallback -->
<script>

View File

@@ -11,13 +11,13 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="{{ url_for('static', path='admin/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='admin/css/tailwind.output.css') }}" />
<!-- Flag Icons CSS (for language selector) with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flag-icons.min.css') }}';"
/>
<!-- Alpine Cloak -->
@@ -29,7 +29,7 @@
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/tom-select.default.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/tom-select.default.min.css') }}';"
/>
<!-- Tom Select Dark Mode Overrides -->
<style>
@@ -76,7 +76,7 @@
{% block quill_css %}{% endblock %}
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
<script src="{{ static_v(request, 'static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
@@ -104,19 +104,19 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("admin") }}';</script>
<!-- 1. FIRST: Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<!-- 2. SECOND: Icons (before Alpine.js) -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- 3. THIRD: Alpine.js Base Data -->
<script src="{{ static_v(request, 'core_static', path='admin/js/init-alpine.js') }}"></script>
<!-- 4. FOURTH: Utils (standalone utilities) -->
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<!-- 4b. i18n Support -->
<script defer src="{{ url_for('static', path='shared/js/i18n.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/i18n.js') }}"></script>
<script>
// Initialize i18n with current language and preload modules
// Wrapped in DOMContentLoaded so deferred i18n.js has loaded
@@ -127,7 +127,7 @@
</script>
<!-- 5. FIFTH: API Client (depends on Utils) -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<!-- 6. SIXTH: Tom Select with CDN fallback -->
<script>
@@ -167,6 +167,6 @@
<!-- 10. LAST: Alpine.js v3 (must be last defer script — auto-initializes on load) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ url_for('static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ static_v(request, 'static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
</body>
</html>

View File

@@ -11,13 +11,13 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='merchant/css/tailwind.output.css') }}" />
<!-- Flag Icons CSS (for language selector) with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flag-icons.min.css') }}';"
/>
<!-- Alpine Cloak -->
@@ -26,7 +26,7 @@
</style>
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
<script src="{{ static_v(request, 'static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
@@ -54,19 +54,19 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("merchant") }}';</script>
<!-- 1. FIRST: Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<!-- 2. SECOND: Icons (before Alpine.js) -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- 3. THIRD: Alpine.js Base Data -->
<script src="{{ static_v(request, 'core_static', path='merchant/js/init-alpine.js') }}"></script>
<!-- 4. FOURTH: Utils (standalone utilities) -->
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<!-- 4b. i18n Support -->
<script defer src="{{ url_for('static', path='shared/js/i18n.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/i18n.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const modules = {% block i18n_modules %}[]{% endblock %};
@@ -75,13 +75,13 @@
</script>
<!-- 5. FIFTH: API Client (depends on Utils) -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<!-- 6. Page-specific scripts (MUST load before Alpine.js) -->
{% block extra_scripts %}{% endblock %}
<!-- 7. LAST: Alpine.js v3 (must be last defer script — auto-initializes on load) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ url_for('static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ static_v(request, 'static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
</body>
</html>

View File

@@ -41,7 +41,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='platform/css/tailwind.output.css') }}">
{# Flag icons for language selector #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"/>

View File

@@ -59,7 +59,7 @@
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flatpickr.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flatpickr.min.css') }}';"
/>
{% endmacro %}

View File

@@ -12,13 +12,13 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="{{ url_for('static', path='store/css/tailwind.output.css') }}" />
<link rel="stylesheet" href="{{ static_v(request, 'static', path='store/css/tailwind.output.css') }}" />
<!-- Flag Icons for Language Selector with local fallback -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flag-icons.min.css') }}';"
/>
<!-- Alpine Cloak -->
@@ -27,7 +27,7 @@
</style>
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
<script src="{{ static_v(request, 'static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
@@ -58,7 +58,7 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("store") }}';</script>
<!-- 1. FIRST: Log Configuration -->
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
<!-- 1.5: Store Configuration (resolved via PlatformSettingsService) -->
<script>
@@ -72,16 +72,16 @@
</script>
<!-- 2. SECOND: Icons (before Alpine.js) -->
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
<!-- 3. THIRD: Alpine.js Base Data -->
<script src="{{ static_v(request, 'core_static', path='store/js/init-alpine.js') }}"></script>
<!-- 4. FOURTH: Utils (standalone utilities) -->
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
<!-- 4b. i18n Support -->
<script defer src="{{ url_for('static', path='shared/js/i18n.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/i18n.js') }}"></script>
<script>
// Initialize i18n with dashboard language and preload modules
// Wrapped in DOMContentLoaded so deferred i18n.js has loaded
@@ -92,7 +92,7 @@
</script>
<!-- 5. FIFTH: API Client (depends on Utils) -->
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
<!-- 6. SIXTH: Feature Store (depends on API Client, registers with Alpine) -->
<script defer src="{{ static_v(request, 'billing_static', path='shared/js/feature-store.js') }}"></script>
@@ -105,6 +105,6 @@
<!-- 9. LAST: Alpine.js v3 (must be last defer script — auto-initializes on load) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ url_for('static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ static_v(request, 'static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
</body>
</html>

View File

@@ -37,20 +37,20 @@
</style>
{# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
{# Flag Icons for Language Selector with local fallback #}
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css"
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/store/flag-icons.min.css') }}';"
onerror="this.onerror=null; this.href='{{ static_v(request, 'static', path='shared/css/store/flag-icons.min.css') }}';"
/>
{# Base Shop Styles #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/storefront.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/storefront.css') }}">
{# Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) #}
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
<script src="{{ static_v(request, 'static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
@@ -366,7 +366,7 @@
<script>window.FRONTEND_TYPE = '{{ frontend_type | default("storefront") }}';</script>
{# 1. Log Configuration (must load first) #}
<script defer src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/log-config.js') }}"></script>
{# 2. Global Shop Configuration (resolved via PlatformSettingsService) #}
<script>
@@ -378,16 +378,16 @@
</script>
{# 3. Icon System #}
<script defer src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/icons.js') }}"></script>
{# 4. Base Shop Layout (Alpine.js component - must load before Alpine) #}
<script defer src="{{ static_v(request, 'core_static', path='storefront/js/storefront-layout.js') }}"></script>
{# 5. Utilities #}
<script defer src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/utils.js') }}"></script>
{# 5b. i18n Support #}
<script defer src="{{ url_for('static', path='shared/js/i18n.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/i18n.js') }}"></script>
<script>
// Initialize i18n with storefront language and preload modules
// Wrapped in DOMContentLoaded so deferred i18n.js has loaded
@@ -398,14 +398,14 @@
</script>
{# 6. API Client #}
<script defer src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<script defer src="{{ static_v(request, 'static', path='shared/js/api-client.js') }}"></script>
{# 7. Page-specific JavaScript (MUST load before Alpine.js) #}
{% block extra_scripts %}{% endblock %}
{# 8. LAST: Alpine.js (must be last defer script — auto-initializes on load) #}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ url_for('static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
onerror="var s=document.createElement('script');s.defer=true;s.src='{{ static_v(request, 'static', path='shared/js/lib/alpine.min.js') }}';document.head.appendChild(s);"></script>
{# Toast notification container #}
<div id="toast-container" class="fixed bottom-4 right-4 z-50"></div>

View File

@@ -8,7 +8,7 @@
<title>{% block title %}{{ status_code }} - {{ status_name }}{% endblock %}{% if store %} | {{ store.name }}{% endif %}</title>
{# Tailwind CSS #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
{# Store theme colors via CSS variables #}
<style>

View File

@@ -8,7 +8,7 @@
<title>{{ title }}{% if store %} | {{ store.name }}{% endif %}</title>
{# Tailwind CSS #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/tailwind.output.css') }}">
<link rel="stylesheet" href="{{ static_v(request, 'static', path='storefront/css/tailwind.output.css') }}">
{# Theme colors (use store theme if available, fallback to purple gradient) #}
<style>

View File

@@ -284,6 +284,33 @@ async def invalid_token_handler(request, exc):
)
```
## Frontend Error Handling (apiClient)
When a request fails, the shared `apiClient` (`static/shared/js/api-client.js`) throws an `Error` with three structured properties populated from the JSON response, in addition to the `message`:
| Property | Source | Example |
|----------|--------|---------|
| `error.status` | HTTP status code | `429` |
| `error.errorCode` | Server `error_code` field | `"POINTS_COOLDOWN"` |
| `error.details` | Server `details` object (may be `null`) | `{ cooldown_minutes: 5, cooldown_ends: "..." }` |
This lets callers branch on a machine-readable code and localise the toast instead of rendering the raw English `message`. Pattern:
```js
try {
await apiClient.post('/store/loyalty/transactions', payload);
} catch (error) {
if (error.errorCode === 'POINTS_COOLDOWN' || error.errorCode === 'STAMP_COOLDOWN') {
const minutes = error.details?.cooldown_minutes ?? '';
Utils.showToast(I18n.t('loyalty.store.terminal.cooldown_wait_minutes', { minutes }), 'warning');
} else {
Utils.showToast(I18n.t('loyalty.store.terminal.transaction_failed', { message: error.message }), 'error');
}
}
```
Generic FastAPI errors that don't carry `error_code` / `details` will leave those properties `undefined` / `null`, so a `?.` chain is the right guard.
## Related Documentation
- [Authentication](authentication.md) - Authentication-related exceptions

View File

@@ -0,0 +1,119 @@
# Persona Template Consolidation
How to share Jinja templates and JS Alpine factories across the admin / merchant / store personas without losing per-frontend isolation.
## The Problem
Most management features (CRUD over loyalty programs, products, orders, etc.) need to be reachable from three frontends:
- **admin** — platform staff, cross-merchant view
- **merchant** — merchant owner, cross-store view scoped to their merchant
- **store** — store staff, scoped to one store
The naive approach maintains three separate Jinja templates per feature. Every change has to be made three times. They drift. Bug fixes only land in one. Eventually only one persona has the new column or the new modal.
## The Pattern
Two layers:
### 1. Thin per-persona wrapper (one per frontend, MUST stay separate)
Each persona has its own template that:
- `{% extends %}` the persona base (`admin/base.html` / `merchant/base.html` / `store/base.html`). This gives the right sidebar, header, auth widgets, and CSS scope. **Never collapse this to a single base** — the navigation/permissions framing differs by frontend, and merging risks accidentally rendering admin chrome to a store user.
- Renders the page header, any persona-specific intro alerts, and the script tags.
- Sets a handful of Jinja variables describing scope (e.g. `cards_api_prefix='/merchants/loyalty'`, `cards_base_url='/merchants/loyalty/cards'`, `show_store_filter=true`).
- `{% include %}` a shared body partial.
Target size: **2060 LOC**. If a wrapper grows much past that, something persona-unique is creeping in and you should extract it or accept it as a per-persona feature (see "Legit exceptions" below).
### 2. Shared body partial (one per feature, lives in `<module>/templates/<module>/shared/`)
Holds the actual CRUD body — filters, table, form, detail view. Reads the scope variables the wrapper set. Pure Jinja + Alpine bindings. No knowledge of which persona is rendering it.
Naming convention:
- `*-list.html` — table + filter UI for a collection (e.g., `cards-list.html`)
- `*-form.html` — create/edit form (e.g., `program-form.html`)
- `*-view.html` — read-only detail page (e.g., `card-detail-view.html`)
- `*-stats.html` — analytics panel (e.g., `analytics-stats.html`)
## The Scope Contract
Wrappers pass scope to shared partials via **simple Jinja include variables** — strings and booleans only. No object params, no `persona` enum.
**String variables** typically carry URLs:
| Variable | Example | Purpose |
| --- | --- | --- |
| `<feature>_api_prefix` | `'/store/loyalty'` | API base for AJAX |
| `<feature>_base_url` | `'/merchants/loyalty/cards'` | URL prefix for detail links |
| `cancel_url`, `back_url` | `'/admin/loyalty/programs'` | Where actions navigate back to |
**Boolean variables** toggle persona-specific UI:
| Variable | Convention |
| --- | --- |
| `show_X` | Render an optional column / filter / button. Default off. |
| `allow_Y` | Enable a permission-gated action. Default off. |
The pattern lets the shared partial render the right shape without inspecting `persona`. Adding a new persona is "set new vars and include" — never "add another `{% if persona == ... %}` branch".
Canonical example: `app/modules/loyalty/templates/loyalty/shared/cards-list.html`. The header comment block documents every variable the partial expects. Wrappers (`loyalty/admin/...`, `loyalty/merchant/cards.html`, `loyalty/store/cards.html`) each set the four variables and include.
## The Backend Mirror
The same pattern applies one layer down:
- **Services** (`app/modules/<module>/services/`) accept simple ids and stay scope-agnostic. `program_service.list_programs(db, skip, limit, ...)` — no `persona` argument.
- **Routes** (`app/modules/<module>/routes/api/admin.py`, `.../merchant.py`, `.../store.py`) inject scope via auth dependencies (`get_current_admin_api`, `get_merchant_for_current_user`, `get_current_store_api`) and pre-filter the query before calling the service.
- **Schemas** (`app/modules/<module>/schemas/`) return the same Pydantic response across all three personas. Optional fields are fine for admin-only enrichment (e.g., `merchant_name`).
So a feature spans:
```
app/modules/<m>/services/<f>_service.py # scope-agnostic
app/modules/<m>/routes/api/{admin,merchant,store}.py # scope-injecting
app/modules/<m>/schemas/<f>.py # same shape per persona
app/modules/<m>/templates/<m>/shared/<f>-list.html # shared body
app/modules/<m>/templates/<m>/{admin,merchant,store}/<f>.html # thin wrappers
app/modules/<m>/static/shared/js/<m>-<f>-list.js # shared Alpine factory
app/modules/<m>/static/{admin,merchant,store}/js/<m>-<f>.js # thin factory wrappers
```
## Shared Alpine Factory Pattern
JS mirrors the template split. A shared factory in `static/shared/js/` returns the Alpine component for the shared partial. Per-persona JS files are thin wrappers:
```js
// app/modules/loyalty/static/merchant/js/loyalty-cards.js
function merchantLoyaltyCards() {
return loyaltyCardsList({
apiPrefix: '/merchants/loyalty',
baseUrl: '/merchants/loyalty/cards',
showStoreFilter: true,
currentPage: 'cards',
});
}
```
The factory always exposes any state the shared partial might read, even if a particular caller won't render that bit. For example, `loyaltyCardDetailView` always exposes `txLabels: {}` and `pagination: {...}` — the store wrapper populates them, admin/merchant get the empty defaults and the corresponding template blocks don't render.
## Legit Exceptions (when to keep templates standalone)
The pattern shines for symmetric CRUD. It breaks down when a persona genuinely has a different *shape* of view, not just different *data*. Heuristic: **if pushing the shape into the shared partial would force an `{% if scope == 'admin' %}` branch in every row, it's a different view — keep it standalone.**
Real examples in the loyalty module:
- `loyalty/admin/programs.html` — multi-merchant aggregator table with cross-cutting stats and a create-with-merchant-search modal. Merchant/store views show one program each, not a table of many. Standalone.
- `loyalty/admin/merchant-detail.html` — admin-only tabbed view of one merchant's entire loyalty footprint. No merchant/store equivalent.
- `loyalty/store/terminal.html` — hardware POS terminal UI. Store-only feature.
- `loyalty/store/enroll.html`, storefront templates — customer-facing, different audience entirely.
Document the reason inline so future-you (or a contributor) doesn't try to "fix" it.
## Enforcement: `TPL-016`
The architecture validator's `TPL-016` rule (warning) flags any template under `app/modules/<m>/templates/<m>/{admin,merchant,store}/*.html` that exceeds **75 LOC** without `{% include %}`-ing a `*/shared/*` partial. This catches new persona templates that drift into copy-paste. Suppress with `{# noqa: TPL-016 #}` for the legit exceptions above.
The rule is intentionally a warning, not an error — it surfaces drift without breaking CI on first sight. Escalate to error once at least one non-loyalty module has been migrated and we're confident the pattern generalises.

View File

@@ -264,6 +264,41 @@ OpenSSH ALLOW Anywhere
443/tcp ALLOW Anywhere
```
!!! warning "Hetzner Cloud blocks outbound TCP 25 and 465 by default"
Hetzner Cloud applies an **upstream egress block on TCP ports 25 and 465** to every Cloud Server, as their published anti-spam policy. This block sits *above* UFW/iptables on the VM — `ufw status` won't show it, and `iptables -L OUTPUT` looks completely clean. The symptom is that SYN packets to those ports simply time out at the network layer while every other port (including 587) works.
If your monitoring stack (Step 19) or any other service needs to send via port 465 (SMTPS / implicit TLS), you must request the unblock from Hetzner support:
1. **Test first** — confirm it's actually the Hetzner block, not something on your VM:
```bash
timeout 5 nc -4 -zv <mail-host> 465 # silent timeout → likely Hetzner upstream
timeout 5 nc -4 -zv <mail-host> 587 # succeeds → general egress is fine, only 465 is blocked
```
2. **Submit unblock request** via [console.hetzner.cloud](https://console.hetzner.cloud) → Support → New ticket. Hetzner's docs invite this explicitly: *"Outgoing traffic to ports 25 and 465 are blocked by default on all Cloud Servers. Send us a request to unblock these ports."*
Sample ticket text:
```
Hi,
Please unblock outbound TCP port 465 for my Cloud server:
Project: <project name>
Server: <server name>
IPv4: <server IPv4>
Reason: legitimate SMTP submission via my mail provider's documented
SMTPS endpoint. Confirmed via UFW, iptables, nftables, and Hetzner
Cloud Firewall that no rule on my side blocks the port; the block
is upstream.
Volume: monitoring alert emails, ~10/day.
Thanks.
```
Hetzner usually auto-approves within minutes for legitimate use cases.
Real prod incident this caused: 5 hours of "is my SMTP password wrong?" debugging on 2026-05-30 before discovering the egress block. Don't repeat that — if you see a port-465 connection time out from a Cloud Server, suspect the upstream block first.
## Step 5: Harden SSH
!!! warning "Before doing this step"
@@ -559,7 +594,7 @@ docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}"
```
!!! note "After the reset"
`init_production.py` re-creates the four admin users with their **default** passwords (see `init_production.py:280-300`). Any admin-side configuration that lives in the `admin_settings` table (e.g. the manual SMTP overrides under `/admin/settings`) is wiped and must be re-applied. The `/health` endpoint reads `.build-info` which is only regenerated by `scripts/deploy.sh`, so after a manual reset it will report the **previous** commit; harmless but worth knowing.
`init_production.py` re-creates the four admin users with their **default** passwords (see `init_production.py:280-300`). Any admin-side configuration that lives in the `admin_settings` table (e.g. the manual SMTP overrides under `/admin/settings`) is wiped and must be re-applied. The `/health` endpoint reads `.build-info` which is only regenerated by `scripts/deploy.sh` or `scripts/deploy-api-only.sh` (see [Step 16.5](#165-manual-deploy)), so after a manual reset it will report the **previous** commit; harmless but worth knowing.
### Seeded Data Summary
@@ -1197,14 +1232,51 @@ deploy:
script: cd ${{ secrets.DEPLOY_PATH }} && bash scripts/deploy.sh
```
### 16.5 Manual Fallback
### 16.5 Manual Deploy
If CI is down, deploy manually:
Two manual paths, pick the right one for the change you're shipping.
#### 16.5a — Code-only fix (default for ad-hoc manual deploys)
For frontend / template / api-only changes that don't touch the Dockerfile,
requirements.txt, docker-compose.yml, or alembic migrations. Rebuilds and
restarts **only** the api container — db, redis, celery-worker, celery-beat,
flower stay running.
```bash
cd ~/apps/orion && bash scripts/deploy-api-only.sh
```
What it does (`scripts/deploy-api-only.sh`):
1. Refuses if working tree is dirty (no silent stash → no risk of pop conflicts).
2. `git pull --ff-only`.
3. **Writes `.build-info`** — this is the critical step that ensures the
`?v=<commit-sha>` cache-bust query on every shared JS/CSS URL flips to the
new SHA. Without this, browsers happily keep serving the previous
deploy's cached assets even though the new code is in the image.
4. `docker compose -f docker-compose.yml --profile full up -d --build api`.
5. Health-check with a 30s budget (tight, since the DB/Redis weren't touched).
Exit codes: `0` success, `1` git pull / dirty tree, `2` docker build/up
failed, `3` health check failed.
#### 16.5b — Full deploy (use when CI is down)
Use this when you've also changed migrations, the Dockerfile,
requirements.txt, or docker-compose.yml itself — anything that needs the
full restart-everything + migrate cycle that the CI runs. Restarts EVERY
service in the `full` profile (db, redis, api, celery-worker, celery-beat,
flower) and runs `alembic upgrade heads`.
```bash
cd ~/apps/orion && bash scripts/deploy.sh
```
Heavier — brief DB downtime, Redis is blown away (sessions / rate-limit
counters / cached anything), in-flight Celery tasks killed — so don't use
it for code-only fixes.
### 16.6 Verify
```bash
@@ -1441,11 +1513,11 @@ Prometheus + Grafana monitoring stack with host and container metrics.
| prometheus | 256 MB | Metrics storage (15-day retention, 2 GB max) |
| grafana | 192 MB | Dashboards (SQLite backend) |
| node-exporter | 64 MB | Host CPU/RAM/disk metrics |
| cadvisor | 128 MB | Per-container resource metrics |
| cadvisor | 192 MB | Per-container resource metrics |
| redis-exporter | 32 MB | Redis memory, connections, command stats |
| **Total new** | **672 MB** | |
| **Total new** | **736 MB** | |
Existing stack ~1.8 GB + 672 MB new = ~2.5 GB. Leaves ~1.6 GB for OS. If too tight, live-upgrade to CAX21 (8 GB/80 GB, ~7.50 EUR/mo) via **Cloud Console > Server > Rescale** (~2 min restart).
Existing stack ~1.8 GB + 736 MB new = ~2.5 GB. Leaves ~1.5 GB for OS. If too tight, rescale to CAX21 (4 vCPU / 8 GB, ~7.99 EUR/mo) — note this requires a brief **power-off** (it is not a live resize); see [Rescaling / Upgrading the Server](#rescaling-upgrading-the-server-cpu-ram) for the full procedure and the Arm-capacity caveat.
### 18.1 DNS Record
@@ -1594,6 +1666,40 @@ Alertmanager needs SMTP to send email notifications. SendGrid handles both trans
**Free trial**: 100 emails/day for 60 days. Covers alerting + transactional emails through launch. After 60 days, upgrade to a paid plan (Essentials starts at ~$20/mo for 50K emails/mo).
!!! info "Live prod uses mail1.myservices.hosting:465, not SendGrid"
The current prod env migrated away from SendGrid to the mailbox-hosting provider's SMTP relay (`mail1.myservices.hosting`) earlier in 2026. Both the app's `/admin/settings` SMTP block and `monitoring/alertmanager/alertmanager.yml` point at it. The SendGrid steps in this section are kept as a working reference for greenfield deploys; if you're rehydrating the existing prod, use the mailbox-hosting setup instead.
Quick summary of the live alertmanager SMTP block (don't commit the real password — `alertmanager.yml` is gitignored, only `.example` ships in repo):
```yaml
global:
smtp_smarthost: 'mail1.myservices.hosting:465' # implicit TLS, not 587
smtp_from: 'alerts@wizard.lu'
smtp_auth_username: 'support@wizard.lu'
smtp_auth_password: '<from /admin/settings SMTP block>'
smtp_require_tls: true
```
Two prerequisites for this to work:
1. **Hetzner outbound TCP 465 must be unblocked** (see warning in Step 4 — Cloud Servers block 25 and 465 by default; submit a one-paragraph ticket to lift it, auto-approved in minutes).
2. **Port 465 = implicit TLS** (TLS-on-connect, not STARTTLS). Alertmanager's email integration handles this natively when the smarthost port is `465`; you only need `smtp_require_tls: true`, no extra `smtp_tls_config` block.
Verification with swaks (redacts the credential automatically):
```bash
swaks --to admin@wizard.lu \
--from alerts@wizard.lu \
--server mail1.myservices.hosting:465 \
--auth PLAIN \
--auth-user support@wizard.lu \
--tls-on-connect \
--header "Subject: smoke test" \
2>&1 | sed -E 's/^( ~> [A-Za-z0-9+\/=]{12,})$/ ~> [REDACTED]/'
```
Expected: `235 Authentication successful` then `250 2.0.0 Ok: queued`. If you see `535 Authentication failed: The provided authorization grant is invalid, expired, or revoked` on port **587**, that's the provider's PLAIN backend being OAuth-wired — switch to port 465 instead, which routes through the password backend.
**1. Create SendGrid account:**
1. Sign up at [sendgrid.com](https://sendgrid.com/) (free plan)
@@ -2915,6 +3021,415 @@ cd ~/apps/orion && bash scripts/deploy.sh
The script handles stashing local changes, pulling, rebuilding containers, running migrations, and health checks.
### Rescaling / Upgrading the Server (CPU & RAM)
When the box runs short on RAM/CPU, rescale it to a larger plan. The disk is
handled separately (see [Disk Maintenance](#disk-maintenance-docker-pruning))
— rescaling for CPU/RAM does **not** grow the disk, and it usually doesn't need
to.
**When to rescale (the signals that justify it):**
- `free -h` shows swap heavily/fully used and almost no free RAM.
- Container memory limits are routinely maxed (`orion-alertmanager-1`,
`orion-cadvisor-1` near 100% of their `mem_limit` — visible in
`docker stats --no-stream`).
- `HostHighCpuUsage` fires repeatedly during CI bursts (Gitea Actions builds
eat ~half the host CPU on a 2-vCPU box).
**Two important constraints (shown on the Rescale screen):**
1. **Same architecture only.** Our servers are **Arm64 / Ampere (CAX series)**.
You can rescale only between CAX plans (CAX11 → CAX21 → CAX31 → CAX41). All
the x86 plans (CX / CPX / CCX) will be **greyed out** — that's expected, not
an error.
2. **"CPU and RAM only" vs "expand disk".** Always choose **CPU and RAM only**
unless you specifically need more disk. Expanding the disk is
**irreversible** — you can never downgrade to a plan with a smaller disk
afterwards. CPU+RAM-only rescales stay reversible.
**Procedure:**
```bash
# 1. On the server — stop the stack cleanly so containers shut down gracefully.
# (Do NOT force-off from the console; let docker stop properly.)
sudo poweroff # your SSH session will drop — expected
```
```text
# 2. In console.hetzner.cloud → Servers → ubuntu-4gb-nbg1-1 → Rescale:
# - wait until the server shows as "off"
# - select the target CAX plan (e.g. CAX21: 4 vCPU / 8 GB)
# - leave the option on "CPU and RAM only"
# - confirm the rescale (takes ~2 min)
# 3. Power the server back on.
```
```bash
# 4. After it boots (~1-2 min), SSH back in and verify:
nproc # vCPU count matches new plan
free -h # new RAM total; swap no longer maxed
docker compose -f docker-compose.yml --profile full ps # all containers healthy
docker stats --no-stream # real headroom now
```
Containers auto-start on boot via their restart policies, so you normally don't
need to bring anything up manually — just confirm they came back healthy and the
public sites return 200 (`curl -sI https://rewardflow.lu`).
!!! warning "Arm capacity can be unavailable in-datacenter"
Hetzner's Arm/Ampere (CAX) stock is limited and fluctuates per location. If
the target CAX plans are greyed out with a tooltip like *"not available,
please choose another location or type"*, it means there's **no Arm capacity
in nbg1 (Nuremberg) right now** — nothing is wrong on your end. **Power the
server back on immediately** (don't leave production down waiting) and
**retry the rescale later** — capacity comes and goes over hours. Rescaling
cannot change the datacenter; if nbg1 stays dry for days, the only
alternative is a snapshot → new server in another DC → restore (a full
migration — avoid unless truly necessary).
**Post-rescale follow-ups:** once on a bigger box, bump the monitoring
containers' memory limits in `docker-compose.yml` to give them headroom (commit
to the repo and `git pull` on the server — don't hand-edit, since the file is
git-tracked):
- `cadvisor` `mem_limit: 192m` → `256m` (chronic flapping/restarts at 192m
cause `TargetDown` floods)
- `alertmanager` `mem_limit: 32m` → `64m`
Then update the [Resource Budget](#resource-budget-4-gb-server) table to the new
totals, and lift any silences placed during the pre-rescale alert flood (see
Alertmanager UI at `http://localhost:9093/#/silences`).
### Disk Maintenance (Docker Pruning)
The root filesystem fills over time because **Gitea CI rebuilds images on every
run**, leaving behind dangling image layers and (more significantly) Docker
**build cache**. Left unchecked this trips the `HostHighDiskUsage` alert
(threshold 80%). On a typical incident the split was ~14 GB unused images +
~9 GB build cache reclaimable on a 38 GB disk.
**Emergency manual prune** (run on the server when `HostHighDiskUsage` fires):
```bash
df -h / # check current usage
docker builder prune -f # remove all reclaimable build cache (the big one)
docker image prune -af # remove all images not used by a running container
docker system df # confirm what was reclaimed
df -h / # verify usage dropped
```
This is non-destructive: running containers, volumes, and the database are
untouched. Pruned images are re-pulled/rebuilt on the next deploy.
**The proper (automated) way:** `scripts/deploy.sh` already prunes old images at
the end of every deploy:
```bash
docker image prune -f --filter "until=72h"
```
…but it does **not** prune build cache, which is the larger offender. To stop
unbounded growth, add a build-cache prune alongside it in
`scripts/deploy.sh` (keeps the last 7 days so CI stays fast):
```bash
# in scripts/deploy.sh, step 6 ("Clean up old Docker images"):
docker image prune -f --filter "until=72h" > /dev/null 2>&1 || true
docker builder prune -f --filter "until=168h" > /dev/null 2>&1 || true # <- add this
```
Because cleanup then runs on every deploy and lives in version control, there's
no host-level cron to remember. (A weekly `/etc/cron.weekly/docker-prune` is an
alternative, but the deploy-script approach is preferred — it's
version-controlled and scoped to this repo.)
### Offloading CI to a Separate Server (2a — recommended)
**Why:** the Gitea Actions runner (`act_runner`, systemd `gitea-runner.service`)
runs the CI jobs from `.gitea/workflows/ci.yml` — `ruff`, `pytest` (which spins
up its own postgres service container), and `validate` — **on the production
box**. Those jobs are the ~47% CPU spike on every push that trips
`HostHighCpuUsage` and competes with the app for RAM. Gitea *itself* (git
hosting) is light (~0% CPU, ~5% RAM); the **runner** is the resource hog.
Moving just the runner to a separate, cheap server eliminates the prod CPU
bursts with **no data migration, no DNS change, and no downtime** — often
removing the need for a rescale entirely. The runner box can be **x86** (it only
lints/tests; it doesn't need to match prod's Arm architecture) and stateless
(rebuildable in minutes), so a **CX22 (2 vCPU / 4 GB, ~3.79 EUR/mo)** is the
minimum and a **CX32 (4 vCPU / 8 GB, ~6.80 EUR/mo)** is comfortable for CI
bursts. x86 has no capacity-wait (see "Why x86 is more abundant" — Arm/Ampere is
a limited pool).
**Steps:**
1. **Provision + harden** a new x86 server (Ubuntu 24.04): follow Steps 26
(non-root user, SSH hardening, UFW, **Docker** — the runner executes jobs in
containers so Docker is required).
2. **Get a runner registration token** in Gitea: Site Administration → Actions →
Runners → *Create new Runner* → copy the token.
3. **Install act_runner** (amd64 build for x86), matching the version in
[Step 15](#step-15-gitea-actions-runner):
```bash
mkdir -p ~/gitea-runner && cd ~/gitea-runner
VERSION=0.2.13
wget -O act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v${VERSION}/act_runner-${VERSION}-linux-amd64"
chmod +x act_runner
```
4. **Register with the SAME labels** as the current runner — `ci.yml` uses
`runs-on: ubuntu-latest`, so the label mapping must be replicated or jobs
won't be picked up:
```bash
./act_runner register --no-interactive \
--instance https://git.wizard.lu \
--token <RUNNER_TOKEN> \
--name ci-runner-1 \
--labels 'ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04'
```
5. **Generate config + install as a systemd service** (mirror prod's
`gitea-runner.service`, adjusting `User`/paths for the new box):
```bash
./act_runner generate-config > config.yaml
sudo tee /etc/systemd/system/gitea-runner.service >/dev/null <<'UNIT'
[Unit]
Description=Gitea Actions Runner
After=network.target
[Service]
Type=simple
User=samir
WorkingDirectory=/home/samir/gitea-runner
ExecStart=/home/samir/gitea-runner/act_runner daemon --config /home/samir/gitea-runner/config.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload && sudo systemctl enable --now gitea-runner.service
```
6. **Verify** the new runner shows **online/idle** in Gitea's Runners list.
7. **Smoke-test:** push a trivial commit to `master` and confirm the jobs land on
`ci-runner-1` (not the prod runner), and the deploy still completes. The CD
deploy step uses `appleboy/ssh-action` with the SSH key stored in **Gitea
repo secrets** (not on the runner host), so the new runner picks it up
automatically — **no key to copy**.
8. **Decommission the prod runner** once the new one is proven:
```bash
# on the production box:
sudo systemctl disable --now gitea-runner.service
```
Optionally remove it from Gitea's Runners list. Watch prod `docker stats`
during the next CI run — the CPU burst should be gone.
!!! note "One smaller burst remains on prod"
The deploy job still runs `docker compose up -d --build` **on prod** (via
SSH), so the api image is still *built* on the production box — a smaller
burst than the full CI suite. To remove that too, build images on the runner
and have prod `pull` instead of `--build`: build → push to **Gitea's built-in
container registry** → change `deploy.sh` from `--build` to `pull`. That's a
larger CI rework (and the runner must build **arm64** images via
`buildx --platform linux/arm64` while prod stays Arm) — defer unless the
build burst alone is still a problem.
### Migrating Gitea to a Separate Server (2c)
**When:** after 2a, if you want full separation — production box = app only;
a separate box = Gitea + CI. Buys architectural cleanliness (a prod incident no
longer touches git/CI, and vice versa) and frees the `gitea` + `gitea-db`
containers off prod. **Trade-off:** it's a real data migration, and the new box
becomes **stateful and critical** (source of truth + — if the runner is
co-located — the deploy path to prod), so it must be backed up, monitored, and
hardened like prod. Do it in a **planned maintenance window** (Gitea + CI are
unavailable during cutover). Co-locate it on the **same box as the 2a runner**.
Current Gitea layout (for reference): `~/gitea/docker-compose.yml` defines two
containers — `gitea` (`gitea/gitea:latest`, web on `127.0.0.1:3000`, git SSH on
host `2222`) and `gitea-db` (`postgres:15`). Data lives in two named volumes:
`gitea_gitea-data` (repos, LFS, config, actions artifacts) and
`gitea_gitea-db-data` (the postgres DB). Backups are under `~/backups/gitea/`.
!!! note "Backup coverage & rollback — read before you cut over"
**What's already safe (code):** This Gitea instance hosts a *single* repo
(`sboulahtit/orion`) with **no** issues, PRs, releases, wikis, LFS, or
attachments — so a normal local clone is a **complete backup of all code
history**. Before migrating, run `git fetch --all --tags` on your laptop (or
keep a `git clone --mirror`) so every branch/tag is local. Worst case, you
could recreate the repo from your laptop and `git push` — zero code loss.
**The one thing a clone does NOT cover — the 4 CI secrets.** Gitea Actions
secrets are **write-only**: you cannot read their values back from the UI or
API. The four (from `.gitea/workflows/ci.yml` → the `deploy` job) are:
| Secret | Value | Sensitive? |
|---|---|---|
| `DEPLOY_HOST` | prod IP (`91.99.65.229`) | no — known |
| `DEPLOY_USER` | `samir` | no — known |
| `DEPLOY_PATH` | `~/apps/orion` | no — known |
| `DEPLOY_SSH_KEY` | **private** SSH deploy key | **yes** — the only real one |
So only `DEPLOY_SSH_KEY` matters, and its **public** half is already in
prod's `~/.ssh/authorized_keys`. Two ways it's covered:
1. **Automatic (primary path):** the proper restore preserves all four. The
encrypted values live in the `secret` table (captured by `pg_dump`) and
are decrypted by `SECRET_KEY` inside `app.ini` (which lives in the
`gitea-data` volume). **You must restore the DB *and* the `gitea-data`
volume from the *same* instance together** — the encrypted secrets are
useless without their matching `SECRET_KEY`. Never restore one without
the other.
2. **Belt-and-suspenders (manual):** before cutover, confirm you still hold
the `DEPLOY_SSH_KEY` *private* key off-box. If you ever rebuild from the
local clone alone, re-add the four under *new Gitea → repo → Settings →
Actions → Secrets*; the three known ones are trivial, and for the key
either reuse the private key you saved or **regenerate**:
`ssh-keygen -t ed25519 -f deploy_key`, append `deploy_key.pub` to prod's
`~/.ssh/authorized_keys`, then paste `deploy_key` as the new
`DEPLOY_SSH_KEY`.
**One-shot backup (recommended right before cutover):** run
`docker exec gitea gitea dump -t /tmp` and copy the resulting
`gitea-dump-*.zip` off the box. That single archive bundles repos + DB +
config (`app.ini`/`SECRET_KEY`), so it inherently includes the encrypted
secrets *and* the key to decrypt them — the cleanest restore artifact.
**Rollback:** the migration keeps the old volumes intact (step 12 uses
`docker compose down`, **not** `down -v`). If anything goes sideways,
re-point `git.wizard.lu` DNS back to the prod IP and `docker compose up -d`
the old stack — it's untouched. Keep the old volumes until the new box is
fully verified.
**Steps:**
1. **Stage the stack on the new box.** Copy `~/gitea/docker-compose.yml` over.
**Reuse the exact existing env values** (especially `GITEA__database__PASSWD`
/ `POSTGRES_PASSWORD` — copy them from the current file; do not regenerate, or
the restored DB won't authenticate). Keep `ROOT_URL`/`DOMAIN`/`SSH_DOMAIN`
as `git.wizard.lu`.
**Pin the Gitea image to the running version, not `latest`** — check it first
with `docker exec gitea gitea --version` and set e.g. `image: gitea/gitea:1.25.4`.
If the new box pulls a newer `latest`, Gitea runs unexpected DB migrations on
first start against your freshly-restored data. (`postgres:15` is already
pinned on the major, fine.)
2. **Announce downtime / stop writes** on the old Gitea.
3. **Dump the data on the old box:**
```bash
cd ~/gitea
docker exec gitea-db pg_dump -U gitea gitea > /tmp/gitea-db.sql
docker compose stop gitea # quiesce before copying the data volume
docker run --rm -v gitea_gitea-data:/data -v /tmp:/backup alpine \
tar czf /backup/gitea-data.tgz -C /data .
```
4. **Transfer** `/tmp/gitea-db.sql` + `/tmp/gitea-data.tgz` to the new box
(`scp`/`rsync`).
5. **Restore the DB** on the new box (the `postgres:15` container auto-creates an
empty `gitea` DB; restore into it. Add `-v ON_ERROR_STOP=1` so a bad restore
fails loudly instead of silently):
```bash
docker compose up -d gitea-db # wait until healthy
docker exec -i gitea-db psql -U gitea -d gitea -v ON_ERROR_STOP=1 < gitea-db.sql
# sanity-check counts match the source:
docker exec gitea-db psql -U gitea -d gitea -t \
-c "SELECT 'repos',count(*) FROM repository UNION ALL SELECT 'secrets',count(*) FROM secret;"
```
6. **Restore the data volume** on the new box:
```bash
docker run --rm -v gitea_gitea-data:/data -v $PWD:/backup alpine \
sh -c "tar xzf /backup/gitea-data.tgz -C /data"
```
7. **Start Gitea:** `docker compose up -d gitea` and check `docker compose logs
gitea`.
8. **Firewall:** open `2222/tcp` (git SSH) on the new box's UFW; keep `3000`
bound to localhost (Caddy proxies it).
9. **Reverse proxy + SSL** on the new box: install Caddy (Step 14) and add the
`git.wizard.lu` block (same as prod):
```caddy
git.wizard.lu {
tls { issuer acme }
reverse_proxy localhost:3000
}
```
10. **DNS cutover:** point `git.wizard.lu` A/AAAA at the new box's IP (TTL 300 →
~5 min). Once propagated, Caddy on the new box auto-issues the TLS cert.
11. **No remote/runner URL changes needed** — the hostname `git.wizard.lu`
stays the same (only the IP moved), so your `gitea` git remote and the
runner's `--instance https://git.wizard.lu` keep working after DNS flips.
Install the new runner per [2a](#offloading-ci-to-a-separate-server-2a-recommended).
⚠️ **Critical gotcha — the OLD runner registration travels in the migrated
DB.** Because the DB is copied wholesale, the old prod runner still exists in
`action_runner` and — since `git.wizard.lu` now resolves to the new box — it
can re-authenticate and grab jobs. You must BOTH (a) remove its registration
from the migrated DB and (b) stop its process on prod, or CI may still run on
the old box:
```bash
# (a) on the NEW box — drop the stale runner registration:
docker exec gitea-db psql -U gitea -d gitea \
-c "DELETE FROM action_runner WHERE name='<old-runner-name>';"
# (b) on PROD — stop the orphaned runner process:
sudo systemctl disable --now gitea-runner.service
```
(Generate the new runner's token with `docker exec -u git gitea gitea actions
generate-runner-token` — Gitea refuses to run that as root.)
12. **Decommission Gitea on prod** (keep volumes + backups for a rollback
window):
```bash
cd ~/gitea && docker compose down # leaves volumes intact
```
Remove the `git.wizard.lu` block from prod's Caddyfile and reload Caddy;
optionally close `2222/tcp` on prod's UFW.
13. **Set up backups on the new box** (Step 17) — it's now stateful/critical.
14. **Verify:** web UI loads with valid SSL, clone/push over SSH (`:2222`)
works, a push triggers CI, and repos/actions history are intact. (See the
"Backup coverage & rollback" callout above if anything needs reverting.)
#### Executed: 2026-06-06 (production run)
This migration was carried out on **2026-06-06**, moving Gitea + the CI runner
off the prod box (`91.99.65.229`, Nuremberg) — which had been suffering CPU
floods from CI running on it — to a dedicated box.
- **New box:** `gitea-ci-fsn1-1`, Falkenstein (`fsn1`), CX22 (2 vCPU / 4 GB x86,
Ubuntu 24.04, Hetzner backups on). IPv4 `167.233.28.95`, IPv6
`2a01:4f8:c015:b6cb::1`. ~5.29 EUR/mo.
- **Outcome:** Gitea `1.25.4` + runner `gitea-ci-fsn1-1` (act_runner v0.2.13) now
run on the new box; `git.wizard.lu` serves from it with a fresh Let's Encrypt
cert; CI runs off-prod (prod CPU stayed at its ~1.4 baseline during a CI run,
no burst). DB restore counts matched source exactly (1 repo, 2 users, 4
secrets). The git-SSH host key travelled in `gitea-data` → no host-key-changed
warnings on push.
- **Real-world notes / deviations from the generic steps above (now folded in):**
- Pinned `gitea/gitea:1.25.4` (step 1) — prod was on 1.25.4; avoid `latest`.
- Restore = `pg_dump` (plain SQL) + `gitea-data` volume tar; a `gitea dump`
archive was taken first as the one-shot safety net and pulled to the laptop.
- Had to delete the **old runner** from the migrated DB + stop its prod
service (step 11 gotcha) — otherwise it kept eligibility for jobs.
- On the new box, the `samir` user's sudo needs a password (not NOPASSWD), so
automated/admin commands were run as `root` over key-only SSH;
`PermitRootLogin prohibit-password` was kept during the migration (tighten
to `no` + give `samir` a sudo password afterward if desired).
- The `docker compose` warning *"volume gitea_gitea-data already exists but
was not created by Docker Compose"* is expected — the volume is pre-created
when you restore into it before first `up`. Harmless.
### View logs
```bash

View File

@@ -0,0 +1,109 @@
# Auth-Redirect Dev/Prod Symmetry
**Date:** 2026-05-31
**Status:** Proposed (deferred — low priority, dev-only impact)
**Owner:** TBD
**Effort:** ~12h including a local smoke pass
## Motivation
Commit `4423f0a5` ("fix(api-client): generalize 401 redirect from `/account/*`
to all 4 personas") made `redirectIfUnauthorized()` in
`static/shared/js/api-client.js` dispatch a mid-session 401 to the right
persona login page with a `?next=` return URL, killing the wrong-state UI flash
on token expiry. It is correct and complete **for production**, where every
persona is served from its bare path prefix:
| Persona | Prod path matched |
| --------- | ----------------------------------------- |
| customer | `/account/*` (not `/account/login`) |
| admin | `/admin/*` (not `/admin/login`) |
| merchant | `/merchants/*` (not `/merchants/login`) |
| store | `/store/{code}/*` (not `/store/{code}/login`) |
A follow-up verification (2026-05-31) confirmed there is **no production gap**:
the only authenticated storefront pages (`/account/loyalty`,
`/account/loyalty/history`) fall under the `/account/` branch, and the one
uncovered storefront page (`/loyalty/join`) makes exclusively public,
no-auth API calls (`/storefront/loyalty/program`, `/storefront/loyalty/enroll`,
`/storefront/cms/pages/{slug}`) that cannot return 401.
The remaining gap is **dev/path-based mode only**.
## The gap
In dev/localhost, personas are served behind a `/platforms/{...}/` prefix
instead of a subdomain (see `app/core/frontend_detector.py` and
`app/modules/core/utils/page_context.py`):
- tenancy store → `/platforms/{platform}/store/{code}/...`
- storefront → `/platforms/{code}/storefront/{slug}/...` (e.g. the dashboard
at `/platforms/loyalty/storefront/FASHIONHUB/account/loyalty`)
`redirectIfUnauthorized()` matches only the bare prefixes (`/store/`,
`/account/`, …), so a path-based-mode URL falls through to `return false`
the caller throws → the same wrong-state flash the commit fixed still happens.
Note the asymmetry the commit already introduced: the three `login.js` files
**do** normalize the `/platforms/{...}/` basePath when honouring `?next=`, but
the redirect helper that *sets* `?next=` does not — so in dev the two halves
disagree.
### Impact
Dev/localhost only. Cosmetic — a developer hitting an expired-session 401 on a
path-based page sees the old generic error instead of a clean redirect. No
production user is ever on a `/platforms/...` URL. This is why it is deferred,
not shipped with the original fix.
## Proposed change
Strip the dev prefix before the existing branch matching in
`redirectIfUnauthorized()`, so the same four branches handle both modes:
```js
redirectIfUnauthorized() {
// Dev/path-based mode serves personas behind /platforms/{...}/.
// Strip it so the prod-shaped branch matching below works for both.
const path = window.location.pathname.replace(/^\/platforms\/[^/]+/, '');
// ...existing /account, /admin, /merchants, /store branch matching...
}
```
Two details to settle during implementation:
1. **Storefront branch.** Today the helper has no storefront branch because in
prod the storefront mounts at `/` and authenticated pages already match
`/account/`. After stripping the dev prefix, a path-based storefront URL
becomes `/storefront/{slug}/account/loyalty` — still not `/account/*`. Decide
whether to add a `/storefront/` branch (mapping to the customer
`/account/login`) or to additionally strip the `/storefront/{slug}` segment
so it collapses to `/account/...`. The second is closer to how prod behaves.
2. **The `next` value.** `next` is built from `path + search`. Confirm it
should carry the *original* prefixed path (so the user lands back on the dev
URL) — the `login.js` basePath normalization already re-derives the prefix,
so passing the stripped path is likely cleaner and avoids double-prefixing.
Verify against `store/js/login.js`'s `storePathMatch` logic.
## Acceptance criteria
- On localhost, an expired-session 401 from an apiClient call on each of:
- `/platforms/{platform}/store/{code}/...`
- `/platforms/{code}/storefront/{slug}/account/loyalty`
redirects to the correct persona login with a working `?next=`, and re-auth
lands the user back on the originating page.
- No change to production behaviour (subdomain mode paths are unaffected by the
prefix strip — the regex is a no-op when there is no `/platforms/` prefix).
- No new architecture-validation findings.
## Out of scope
- The original production fix (done in `4423f0a5`).
- Any change to login.js basePath handling beyond what's needed to honour the
stripped/un-stripped `next` decision above.
## Verification log
- **2026-05-31** — Confirmed prod has no gap: storefront `/loyalty/join` uses
only public endpoints; authenticated storefront pages are under `/account/`.
Logged the dev-only gap; agreed to defer behind this proposal.

View File

@@ -222,6 +222,543 @@ subtitle conditional the same way as the title:
- **Test 3** — Staff stamps/points at the terminal (`/store/FASHIONHUB/loyalty/terminal`)
- Items #3 (Hetzner doc check), #4 (unit tests for the B1-F chain), #5 (prospecting `tasks/__init__.py` missing import), #6 (other-module email audit) still queued from the 2026-05-17 follow-up list.
## 2026-05-23 update — Test 3 done + cooldown bug + routing investigation
### Test 3 (staff stamps/points at terminal) — all 6 sub-steps verified
Lookup by card-number AND by email both work; phone + birthday show correctly on the card detail (B1-D regression check passed); points earning credits; cooldown rejection fires (after the fix below).
### Cooldown bug fixed (`93ab072f`)
`stamp_service.add_stamp` properly checks cooldown before crediting. The parallel `points_service.earn_points` wrote `card.last_points_at` but **never read it** — so the program's `cooldown_minutes` was silently ignored for points-based programs. Mirrored the stamp check in `points_service` after the row lock; added `PointsCooldownException` with error_code `POINTS_COOLDOWN`.
### Cooldown toast localised (`aa8ca594`)
After the cooldown fix shipped, the FR-locale toast still showed the raw English from the backend. Three small changes:
- `static/shared/js/api-client.js` — propagate `error.details` (alongside `errorCode`) so callers can render localised toasts.
- `loyalty-terminal.js:277` — in the transaction-dispatch catch, branch on `errorCode === 'POINTS_COOLDOWN' | 'STAMP_COOLDOWN'` and render `loyalty.store.terminal.cooldown_wait_minutes` with `{minutes}` from `error.details.cooldown_minutes`; toast type switches to `warning` since the rejection is soft.
- New `cooldown_wait_minutes` key in en/fr/de/lb under `loyalty.store.terminal.*`.
### Routing investigation — 4 distinct bugs in path/host handling (not yet fixed)
User hit a 404 on `https://fashionhub.rewardflow.lu/platforms/loyalty/store/fashionhub/dashboard` after login, then noticed several other oddities. Diagnostics found four distinct routing-implementation bugs, all from the same architectural drift (path-based dev → subdomain/custom-domain prod):
1. **Mount 1 store-resolution broken on subdomain**`/store/login` returns "Failed to load store information" even though the route is mounted at `main.py:449-458` and the host should resolve the store via middleware. Workaround: use Mount 2 `/store/{STORE_CODE}/login`.
2. **Server-side post-login redirect leaks dev prefix**`app/modules/tenancy/routes/pages/store.py:86` builds `/platforms/{platform_code}/store/{store_code}/dashboard` on a subdomain hit (should pick the `:88` branch). Same pattern as B1-B but for redirects.
3. **JS post-login redirect uses wrong heuristic**`app/modules/tenancy/static/store/js/login.js:155-158` treats "platform_code is set" as "we're in path-based mode" and prepends `/platforms/{code}/` always. Should check `window.location.pathname.startsWith('/platforms/')` instead.
4. **Sidebar URL builder uses code-bearing form on subdomain** — works (Mount 2 also matches) but inconsistent with the canonical platform-debug pattern; adds visible cruft to URLs.
### Why didn't tests catch this?
Ran the full middleware suite (185 tests, 29s, all green). Confirmed thorough coverage of **inbound** resolution (host → platform/store, request.state population). **Zero coverage on outbound URL construction** — no test asserts post-login Location header, sidebar URLs, or Mount 1 actually serving on subdomain. The bugs exist precisely because nothing red-flags them.
### Platform-debug enhancement scoped (not implemented)
User suggested enhancing `/admin/platform-debug` to test redirects. My scope: add a 5th panel called **"Redirect Trace"** alongside Platform Trace, Domain Health, Permissions Audit, Tenant Isolation. Auto-runs the 12-row (host × URL-pattern) matrix the page already enumerates, simulates each via `httpx.AsyncClient(transport=ASGITransport(app=app))`, asserts the redirect Location vs the expected canonical. The same backing endpoint becomes the harness for `tests/integration/test_redirect_trace.py` so the 4 routing bugs would surface in red.
### Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, **3 ✅** done. Tests 48 ahead.
### Carry over for next session
- **Test 4** — Cross-store redemption at FASHIONOUTLET with the card from Tests 1-3
- **Routing pass** (after Test 8 finishes so we don't churn mid-walkthrough): fix the 4 routing bugs in one focused commit, add the RedirectTrace admin tool + the corresponding integration test, update hetzner doc + user-journeys doc Case 3 to match the canonical platform-debug pattern.
- Existing follow-ups still queued: Hetzner doc check, B1-F unit tests, prospecting `tasks/__init__.py` missing import, other-module email audit.
## 2026-05-24 update — Test 4 done + storefront auth body-schema fix
### Test 4 (cross-store redemption) — verified
Card #5 has its full earning history at FASHIONHUB (`store_id=4`): welcome
bonus 50 + three `points_earned` totalling 218 = 268 total earned. Today's
`points_redeemed -100 @ store_id=5` (FASHIONOUTLET) succeeded cleanly,
producing the mixed-store transaction history the cross-location flow is
supposed to deliver. Balance = 168 pts.
### Storefront forgot/reset password endpoints now accept JSON body (`478c3a9c`)
Both `POST /api/v1/storefront/auth/forgot-password` and `.../reset-password`
were declared with bare `email: str` / `reset_token: str, new_password: str`
parameters. FastAPI treats unannotated str params as query parameters, so
the storefront's JSON request body was ignored and the endpoint 422'd
with `{"loc":["query","email"],"msg":"Field required"}`. The endpoint
docstrings even said "Request Body: email" — intent was clear, the
implementation drifted.
Added two body schemas in `app/modules/tenancy/schemas/auth.py`
(`PasswordResetRequest`, `PasswordResetConfirm`), re-exported via
`__init__.py`, and switched both endpoint signatures to `body: <Schema>`.
Surfaced when the user tried to test Test 5 (customer storefront login)
and needed to set a password on the customer that self-enrolled with just
email + name + birthday.
### Skill created: `/loyalty-wrap` (`d03b96da`)
Mechanises the end-of-day routine that's been manual every session. Lives
at `.claude/skills/loyalty-wrap/SKILL.md`. Triggers on phrases like "call
it a night", "save memory and docs", "wrap up", etc. Skills load at session
start, so the first session where the user can actually invoke it as
`/loyalty-wrap` is the next one after the one that committed it.
### Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅, **4 ✅**
done. Test 5 in progress (blocked tonight on password-reset flow; now
unblocked by the `478c3a9c` fix, verification pending next session).
### Carry over for next session
1. **Test 5 — password-reset end-to-end** (new top priority): with the
`478c3a9c` fix deployed, retry the forgot-password flow → confirm an
`email_logs` row appears with `template_code='password_reset'`,
`status='sent'` → click the link in the email → set a password → login
→ continue from step 5.3 (visit `/account/loyalty` dashboard + history).
2. **Transaction categories — permissions audit (new item raised by
user)**: today only admin can create transaction categories. Merchants
and store owners should be able to. Investigate the existing endpoint
in `app/modules/loyalty/services/category_service.py` +
`app/modules/loyalty/routes/api/admin.py`, decide the right scope
(merchant-level? store-level?), wire up the merchant + store UIs, add
the appropriate RBAC permissions.
3. **Routing pass** still queued (after Test 8): fix the 4 routing bugs
+ Redirect Trace admin tool + integration tests + doc updates.
4. **Existing follow-ups**: Hetzner doc check, B1-F unit tests,
prospecting `tasks/__init__.py` missing import, other-module email
audit.
## 2026-05-29 update — Test 5.0 storefront i18n sweep + FR/DE email accents
### Test 5.0 (forgot-password) surfaced 5 distinct issues, all fixed
The user retried Test 5 with the `478c3a9c` JSON-body fix in place. The
forgot-password POST succeeded, but five downstream issues showed up in
one walk-through. Triaged analysis-first per user request before
batching the fix, then shipped as four commits.
| # | Where | Fix |
|---|---|---|
| 1 | API `forgot-password` handler | Read `request.state.language` first; fall back to `customer.preferred_language` (which is now backfilled at loyalty self-enrollment for both new and returning customers so future emails respect storefront locale). |
| 2A | `customers/storefront/reset-password.html` icons | Replaced `$icon('x-mark' / 'spinner' / 'check')` with inline SVGs (matches `forgot-password.html` convention; standalone templates don't load `icons.js`). |
| 2B | Same template — full i18n | Added `lang` attribute, swapped every hardcoded string for `_()` (22 new `auth.*` keys × 4 locales), added language selector, threaded JS validation strings via `window.__resetPasswordI18n`. |
| 3 | login + forgot + reset CTAs | Renamed `auth.continue_shopping``auth.back_to_home` (loyalty storefronts have no catalog). 4-locale rename. |
| 5 | `/account/dashboard`, `/profile`, `/addresses` body | i18n sweep across all three customer-area templates (~80 new `customers.storefront.pages.{dashboard,profile,addresses}.*` keys × 4 locales). |
Issue 4 (login looked "strangely in FR" after the broken reset page) was
NOT a bug — login.html was correctly translated all along; the contrast
with the broken reset page just made it look weird.
### FR + DE password_reset email body restored to native orthography
The seeded FR template body had every accent stripped (`Envoye`, `recu`,
`demande`, `equipe`, `Reinitialiser`, etc.). Same pattern in DE
(`zurucksetzen`, `Schaltflache`, `lauft`, `konnen`, `Grussen`).
Both templates now read natively. As a bonus, signatures on all 4 locales
were changed from generic ("L'équipe" / "The Team" / "Das Team" /
"D'Team") to `{{ store_name }}`-interpolated ("L'équipe Fashion Hub" /
"The Fashion Hub Team" / etc.) using the auto-injected `store_name`
branding variable from `EmailService.get_branding`.
The seeder is idempotent (upserts on `(code, language)`), so re-running
`scripts/seed/seed_email_templates_core.py` updates existing rows in
place — no DB wipe needed.
### Alpine quoting bug surfaced and fixed downstream
The customer-dashboard unread-message line crashed Alpine with
`"expected expression, got '}'"` because the original sweep emitted
`{{ _('...')|tojson }}` directly inside `x-text="..."` — the JSON's
double quotes broke out of the double-quoted HTML attribute. Fix moved
the singular/plural strings onto `window.__accountDashboardI18n` and
referenced them by global path from `x-text`. The nested
`x-data="{ unreadCount: 0 }"` scope can't see the parent component's
`i18n` property, but `window.*` is always reachable.
The other auth templates using `|tojson` (language-selector blocks)
escape it via single-quoted outer attributes (`x-data='...'`), so the
collision was unique to the new dashboard code.
### Seed-script path bug surfaced during the prod reseed
`scripts/seed/seed_email_templates_core.py` had
`Path(__file__).parent.parent` which resolves to `scripts/`, not the
project root, so `from app.core.database import get_db` failed with
`ModuleNotFoundError: No module named 'app'`. The loyalty sibling had
`parent.parent.parent` already (correct). Fixed to match. The canonical
deploy command in `docs/deployment/hetzner-server-setup.md:549` sets
`PYTHONPATH=/app` and would have masked the bug anyway, but defence in
depth is cheap.
### Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅, 4 ✅,
**5.0 ✅** (forgot-password end-to-end on FR, including email
reception and accent correctness). Test 5 itself (login + dashboard +
history) is the next concrete step, gated on recreating the prod
`api` container to serve the i18n-swept HTML.
### Carry over for next session
1. **Recreate the prod `api` container** first thing tomorrow:
`docker compose --profile full up -d --force-recreate api`. The
Alpine fix (`1bade6e6`) is in the image built today but the
long-running container is still on the old image, so the dashboard
still throws the `x-text` error end-of-day. Verify the dashboard
renders cleanly after recreate.
2. **Continue Test 5** from step 5.1 (login as customer) → 5.2
(`/account/loyalty` dashboard, expect 168 pts) → 5.3
(`/account/loyalty/history`, expect cross-store transaction list).
3. **Static asset cache-busting gaps** (new item raised by user): the
`?v=<commit-sha>` system from the 2026-05-18 cache-busting work is
in place, but some JS/CSS still load without the `?v=` query param.
Audit which files miss it (likely standalone templates that bypass
the `static_v()` / `url_for` helpers). The `FE-024` arch rule was
supposed to guard this — check whether it's firing on these gaps.
4. **DE/LB email template quality sweep** — other DE templates likely
have the same missing-umlaut pattern as `password_reset`
(signup_welcome, order_confirmation, team_invite, etc.; ~11 codes ×
4 locales). LB has inconsistent accents too. Worth a single pass
with a native-speaker review.
5. **Transaction categories permissions audit** (carried from
2026-05-24).
6. **Routing pass** (carried — after Test 8).
7. **Existing backlog** (carried): Hetzner doc check, B1-F unit tests,
prospecting `tasks/__init__.py` missing import, other-module email
audit.
## 2026-05-30 update — Test 5 widget i18n + cache-bust sweep + 401 storefront redirect + critical prod-readiness findings
### Test 5 — customer dashboard surfaced 2 i18n defects
After Test 5.1 (customer login) succeeded, `/account/dashboard` showed
two issues on FR locale: the Loyalty Rewards card was hardcoded English
("Loyalty Rewards" / "View your points & rewards" / "Points Balance")
and the Account Summary section had a raw `customers.customer_number`
key.
Root cause for the card: `StorefrontDashboardCard` is populated by
widget providers (loyalty, orders), and the widget contract had no
language threading. Root cause for the raw key: the customers-module
locale JSON has a redundant top-level `"customers"` wrapper, so the
real resolvable path is `customers.customers.customer_number` (the
same double-prefix pattern as `loyalty.loyalty.wallet.apple`).
Fix in `5f359283`: added `language` field to `WidgetContext`, customer
dashboard route passes `request.state.language`, loyalty and orders
widget providers translate server-side via the new `widget.*` namespace
in their locale files (4 locales each). Fixed the 8 single-prefix
references to use the actual double-prefix path.
### Cache-busting audit — FE-024 had two real gaps
User flagged that `?v=<commit-sha>` was missing from many assets. Audit
traced it to two problems in the FE-024 architecture rule:
1. The anti-pattern only matched `url_for('<module>_static', ...)` mount
names — missed the bare `'static'` mount which is what every persona
`base.html` uses for shared JS / CSS / Tailwind output.
2. `base.html` files were in the rule's exception list — exactly the
files where most shared includes live.
Fix in `3ce94683`: swept 5 persona `base.html` files + 15 standalone
templates (login, register, forgot/reset password, error pages,
onboarding, invitation-accept, admin module-info/config, etc.) — 53
references for `.js`/`.css` files converted from raw `url_for('static',
...)` to `static_v(request, 'static', ...)`. Then tightened the FE-024
rule to add an anti-pattern for the bare `'static'` mount and dropped
`base.html` from the exception list (kept `partials/`). Validator
baseline unchanged at 126 warnings, 0 FE-024 hits.
### 401 → /account/login redirect on customer storefront
User saw the loyalty dashboard render the "Rejoignez notre programme"
CTA even though they were enrolled. Diagnosis: the page route accepts
the customer cookie; JS then calls `/api/v1/storefront/loyalty/card`
which requires the Bearer token from `localStorage.customer_token`. The
stored token was stale, server returned 401, JS swallowed it, the
template's `x-show="!loading && !card"` branch fired with the join
CTA.
Fix in `a0ae6388`: added `redirectIfCustomerAreaUnauthorized()` helper
to apiClient. On a `/account/*` page (and not on `/account/login`) it
sets `window.location.href = '/account/login?next=<encoded-path>'`.
Called from all three apiClient 401 handlers (request, requestFormData,
getBlob). Customer login now honours `?next=` (alongside the legacy
`?return=`). Also fixed `getToken()` and `clearTokens()` path detection
to recognise `/account/*` and `/api/v1/storefront/*` (was hardcoded to
`/shop/*` from before the migration to `/storefront`). Customer JWT
TTL is 30 minutes (`JWT_EXPIRE_MINUTES` env var,
`middleware/auth.py:75`).
Followed up with `856db328` — removed the dead `/shop/` predicates
entirely. Pure dead-code cleanup, no behaviour change.
### Loyalty redirect flicker — two-stage fix
User repro'd by deleting `localStorage.customer_token` and F5'ing
`/account/loyalty` — saw the "Rejoignez..." CTA flash for ~half a
second before the redirect landed. Stage 1 (`b04b36a2`): flipped
`loading: false``loading: true` initial state in `loyalty-dashboard.js`
and `loyalty-history.js` so the template's `x-show="loading"` spinner
covers the in-flight window. NOT enough on its own — the API throw
triggered the caller's `.finally(() => loading = false)` *before* the
browser actually navigated, so Alpine re-rendered with the wrong
state mid-redirect. Stage 2 (`6564f138`): in all three apiClient 401
handlers, return a never-resolving `new Promise(() => {})` instead of
throwing when the redirect helper returns true. Caller's `await` never
returns, `.finally` never fires, spinner stays up until navigation.
### Login JS i18n sweep
`bbb481aa` translated the "Welcome back to your shopping experience"
branding subtitle on `/account/login`. `c9fe7171` translated the three
remaining hardcoded Alpine toasts in the same template:
post-registration banner, post-login success toast, login-failure
fallback. Two new `auth.*` keys × 4 locales; the third reuses the
existing `auth.invalid_credentials`.
### `.build-info` stale → new `scripts/deploy-api-only.sh`
User repeatedly redeployed and refreshed but every redirect repro still
flickered. Eventually noticed in the browser console:
`loadCard https://.../js/loyalty-dashboard.js?v=acbe2eff:50` — the
`?v=` was yesterday's commit hash. Browser was serving cached pre-fix
JS because the cache-bust query never bumped.
Root cause: `?v=` is computed by `templates_config._asset_version()`
from `app/core/build_info.py`, which reads `.build-info`. That file is
bind-mounted from the host and is only written by `scripts/deploy.sh`
(line 4245). The manual `git pull && docker compose up --build api`
sequence everyone had been using never touched it, so `?v=` stayed
pinned at the last `deploy.sh` run's commit — even though every
intervening rebuild was correctly putting new code into the image.
Five hours of "is this even deployed?" debugging chased to root.
`deploy.sh` itself wasn't a substitute because it's a CI/CD script —
stashes the working tree, runs alembic, restarts every service in the
`full` profile (db, redis, api, celery-worker, celery-beat, flower),
60s health budget. Heavy and disruptive for an api-only hotfix; the
narrower manual pattern is correct, it was just missing the
`.build-info` write.
Built `scripts/deploy-api-only.sh` (`c13e8e29`) to fill the gap:
refuses if working tree is dirty, `git pull --ff-only`, writes
`.build-info`, `docker compose -f docker-compose.yml --profile full
up -d --build api` (api only — db/redis/celery untouched), tight 30s
health budget. Hetzner doc §16.5 split into 16.5a (code-only fix,
default to the new script) and 16.5b (full `deploy.sh` fallback for
migrations / Dockerfile / requirements changes).
### 🔴 Critical prod-readiness findings — SG credential in git + alertmanager misconfigured post-SMTP-migration
The new dirty-tree gate blocked the deploy because
`monitoring/alertmanager/alertmanager.yml` has local modifications on
prod. Diff inspection:
```diff
- smtp_auth_password: '' # TODO: Paste your SG.xxx API key here
+ smtp_auth_password: 'SG.xxxxxxxxx' # TODO: Paste your SG.xxx API key here
```
Three production-readiness problems surfaced in one finding:
1. **A SendGrid API key is pasted into a tracked git file on prod**, and
the in-repo template literally says "Paste your SG.xxx API key here"
next to the empty value — actively encouraging the anti-pattern.
2. **The `alertmanager` container has been Up 13 days**, started
*before* the credential was pasted (mtime 2026-05-29 01:09 UTC).
So the running alertmanager process is still using the old empty
`smtp_auth_password` from the file at container-start time. Any
alert that needs to send email today silently fails — alerting has
been broken for at least 13 days, probably longer.
3. **The SMTP migration earlier this year never touched
`alertmanager.yml`.** That migration only updated the app's
notification settings in the `email_settings` DB table; alertmanager
reads its own config from disk and was never updated. So even with
a properly-loaded credential, the config still points at SendGrid
instead of `mail1.myservices.hosting`.
User decided to defer today's loyalty deploy and tackle the
alertmanager work as the first thing tomorrow — production-readiness
gate ranks over incremental Test 5 progress, and fixing the root
cause (credential out of git + correct SMTP smarthost + alertmanager
reload) means the deploy will run clean without `--skip-worktree`
gymnastics.
### Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅, 4 ✅,
5.0 ✅, **5.1 in progress** (login + dashboard work, blocked on
prod deploy of today's fixes which are queued on `gitea/master` but
not yet served because of the unrelated alertmanager dirty-tree
blocker).
- New step surfaced — **alerting infrastructure is silently broken
in production** (13+ days). Should be tracked as a go-live blocker;
prod is currently flying blind on alerting.
### Carry over for next session
User explicitly chose tomorrow's order: prod-readiness items 1+2 BEFORE
continuing Test 5.
1. **Trace the SG credential paste origin** — user claims sole-developer
status but doesn't remember pasting. Grep shell history, check file
ownership, find when the credential was introduced. Understand the
path so it doesn't happen again.
2. **Update `alertmanager.yml`** for the SendGrid → SMTP migration that
never landed: `smtp_smarthost: 'mail1.myservices.hosting:587'`,
`smtp_auth_username: 'support@wizard.lu'`, the SMTP password from
`/admin/settings`. Then SIGHUP alertmanager to hot-reload
(`docker compose -f docker-compose.yml --profile full kill -s SIGHUP
alertmanager`). Verify with a synthetic alert that email delivery
actually works.
3. **Move credential out of git** — `git rm --cached
monitoring/alertmanager/alertmanager.yml`, add to `.gitignore`,
ship `monitoring/alertmanager/alertmanager.yml.example` as the
template (with empty placeholder + comment pointing at the deploy
doc for the real values). Closes the recurrence path.
4. **Deploy today's queued loyalty fixes** — with `alertmanager.yml`
gitignored, the working tree on prod is clean and `bash
scripts/deploy-api-only.sh` should run without the `--skip-worktree`
dance. Then verify `?v=c13e8e29` (or later) on rendered assets.
5. **Re-run the loyalty redirect repro** to confirm the flicker is
gone now that today's JS actually reaches the browser.
6. **Continue Test 5** from 5.1 → 5.2 (/account/loyalty, 168 pts) →
5.3 (/account/loyalty/history).
7. **Standing backlog** (lower priority): DE/LB email template quality
sweep, transaction categories permissions audit, routing pass,
Hetzner doc check, B1-F unit tests, `prospecting/tasks/__init__.py`,
other-module email audit.
## 2026-05-30 update (afternoon) — production-readiness items 1-3 resolved + alerting back online after 13+ days
Picked up the carry-over list from this morning's wrap and ran it
end-to-end. All four blockers are now closed and the loyalty queue
landed on prod for the first time today.
### SG-credential forensics + alertmanager.yml untracked
User-driven nano edits in past sessions (bash history lines 290-357
confirmed it). No rogue actor, just forgotten exploratory work that
was never committed. Resolution shipped as `e44f5c04`:
`git rm --cached monitoring/alertmanager/alertmanager.yml`,
`.gitignore` entry added, and `monitoring/alertmanager/alertmanager.yml.example`
ships in repo with the post-migration routing pre-filled
(`mail1.myservices.hosting:465`, `support@wizard.lu` auth,
`alerts@wizard.lu` From, only `smtp_auth_password: 'CHANGEME'` left
for prod-side fill-in).
### Per-host migration on prod + loyalty queue deployed
On the Hetzner box: backup → `git checkout` → pull `e44f5c04` →
copy template over the old file → fill in the SMTP password →
SIGHUP alertmanager. Then `bash scripts/deploy-api-only.sh` for the
first time ran cleanly (working tree no longer dirty), pulling the 9
queued loyalty commits from this morning. Verified `?v=e44f5c04` on
all rendered assets, ran the loyalty redirect repro (`localStorage`
delete + F5 on `/account/loyalty`): spinner straight through to login
redirect, no "Rejoignez..." CTA flash. Stage 1 + Stage 2 of the
flicker fix work as designed once the browser actually sees the new
JS.
### The alertmanager email delivery rabbit hole — and the answer
After the SMTP migration, alertmanager still couldn't send. The error
log was identical to before the migration: `*smtp.plainAuth auth: 535
Authentication failed: The provided authorization grant is invalid,
expired, or revoked` — verbatim OAuth 2.0 RFC 6749 §5.2
`invalid_grant` text. Multi-hour diagnosis chased through every
plausible layer:
1. Provider's port 587 `AUTH PLAIN` backend is OAuth-wired (returns
the OAuth-flavored 535 with a regular password). `AUTH LOGIN` on
the same port accepts the same credential cleanly. swaks proved
this.
2. alertmanager uses Go stdlib `smtp.PlainAuth`, which prefers PLAIN
whenever the server advertises it. No config knob to force LOGIN.
`smtp_auth_identity` tweak had no effect.
3. Provider's docs name port **465 SSL/TLS** as the official
submission endpoint — not 587. Switched to 465 → connection timed
out from prod AND from user's home laptop.
4. VM-side sweep (UFW outbound = allow, iptables OUTPUT = ACCEPT,
nftables empty, DOCKER-USER empty) cleared the local firewall as a
cause. Block had to be upstream.
5. Found Hetzner's documented anti-spam policy: *"Outgoing traffic to
ports 25 and 465 are blocked by default on all Cloud Servers. Send
us a request to unblock these ports."* The block is at the cloud
network layer, completely invisible from the VM.
6. Filed Hetzner unblock ticket via Cloud Console. **Auto-approved
within minutes** — Hetzner has tooling for legitimate SMTP unblock
requests.
7. Post-unblock: `nc -4 -zv mail1.myservices.hosting 465` succeeds.
swaks AUTH PLAIN on 465 succeeds (`235 Authentication
successful` + `250 2.0.0 Ok: queued`). One-line alertmanager change
from `:587` to `:465`, SIGHUP, watched tcpdump confirm implicit-TLS
handshake on port 465. Three pending alerts (TargetDown,
HostHighCpuUsage, HostHighDiskUsage) delivered to inbox within
minutes. **Alerting back online for the first time in 13+ days.**
Key finding worth documenting: alertmanager's email integration via
Go's stdlib `net/smtp` DOES handle implicit TLS on port 465 natively.
No `smtp_tls_config` block needed, no stunnel sidecar. Just set the
smarthost port to `465` + `smtp_require_tls: true` and reload. tcpdump
confirms the TLS-on-connect handshake completes correctly.
### SMTP password rotation (mid-flow)
User rotated the SMTP password mid-debugging because it leaked into
chat (swaks base64 line was redactable but not redacted on the
initial paste). New value propagated to `/admin/settings` SMTP block
AND `alertmanager.yml`. swaks verified with `--auth LOGIN` on 587
(the path the app uses) — `235 Authentication successful` followed by
`250 Ok: queued`. Test email landed.
### Hetzner doc 5h debug payback (`1227567d`)
Updated `docs/deployment/hetzner-server-setup.md`:
- **Step 4 (Firewall Configuration)** gets a warning admonition right
after the UFW status check, explaining that Hetzner Cloud blocks
outbound 25 and 465 at the network layer (invisible from the VM),
with the symptom signature and the auto-approved unblock ticket
template ready to paste.
- **Step 19.5 (Alertmanager SMTP Setup)** gets a "live prod uses
mail1.myservices.hosting:465, not SendGrid" callout reflecting the
reality that the SendGrid configuration documented in §19.5 is no
longer how this prod env is wired. Includes the live alertmanager
SMTP block (with `smtp_auth_password` kept gitignored, only
`.example` ships in repo), the two prerequisites (Hetzner 465
unblock + implicit-TLS-aware smarthost port), and the redacted
swaks verification command.
Saves the next person from repeating the same 5-hour detour.
### Status board delta
- Step 6 (web user-journey E2E tests) — Tests 1 ✅, 2 ✅, 3 ✅, 4 ✅,
5.0 ✅, 5.1 ✅. Test 5.2/5.3 are the next concrete browser steps
(loyalty dashboard + history with the 168-pt customer).
- Step 19 (alerting infrastructure) — **email delivery now works**,
remove the previously-flagged "silently broken in production" item.
- Step 6 implicit blockers — all cleared: prod is serving today's
i18n + redirect + flicker fixes, alertmanager email flows, no
outstanding deploy blockers.
### Carry over for next session
1. **Test 5.2** → login as `samir.boulahtit+17mayf@gmail.com`, visit
`/account/loyalty`, confirm 168 pts balance + cross-store rewards
render correctly.
2. **Test 5.3** → `/account/loyalty/history`, confirm the 5
transactions (50+143+43+32 earned at FASHIONHUB, 100 redeemed at
FASHIONOUTLET).
3. Standing backlog as before — DE/LB email template quality sweep,
transaction categories permissions audit, routing pass, B1-F unit
tests, `prospecting/tasks/__init__.py`, other-module email audit.
## Status board
| # | Pre-launch step | State | Notes |

View File

@@ -0,0 +1,248 @@
# Persona Template Consolidation — Cross-Module Audit
**Date:** 2026-05-24
**Status:** Proposed (audit + prioritized backlog)
**Motivation:** Loyalty has the persona-template pattern working (`docs/architecture/persona-template-consolidation.md`, enforced by `TPL-016`), and the rule surfaced ~110 warnings across the rest of the codebase. This audit walks every module's persona templates, classifies each cluster as a real consolidation candidate or a legit exception, and produces a prioritized migration backlog.
The goal is **reducing maintenance overhead and preventing scope/permission drift between personas**, not consolidation for its own sake. Several "duplicates" turn out on inspection to be genuinely different features — those stay separate with `{# noqa: TPL-016 #}`.
---
## Headline numbers
- **141 persona-scoped templates** across 9 multi-persona modules.
- **110 TPL-016 warnings** (templates >75 LOC without a shared partial). 0 in loyalty (already migrated), 47 in tenancy alone.
- **~27 candidate feature clusters** where the same feature appears in 2+ personas.
- **~3,1003,500 LOC of duplication** can be removed by migrating the top-10 candidates. Roughly 810 working days of focused work, spread across 3 waves.
- Backend services are **uniformly scope-agnostic** for every top-10 candidate — no service/route refactors needed before template work.
---
## Per-module summary
| Module | admin | merchant | store | Already shares via `shared/` | TPL-016 hits |
| --- | --- | --- | --- | --- | --- |
| loyalty | 11 | 9 | 8 | **8 shared partials** | **0** |
| tenancy | 27 | 6 | 7 | 0 | 47 |
| messaging | 4 | — | 3 | 0 | 7 |
| cms | 4 | — | 3 | 0 | 7 |
| billing | 4 | 3 | 1 | 0 | 5 |
| catalog | 4 | — | 2 | 0 | 4 |
| orders | 1 | — | 3 | 0 | 4 |
| inventory | 1 | — | 1 | 0 | 2 |
| customers | 1 | — | 2 | 0 | 0 |
Loyalty is the reference for the pattern; the other 8 modules are the migration surface.
---
## Cluster matrix
A **cluster** = the same logical feature appearing in two or more personas. Counted only clusters where the personas render the *same shape* of data, not just files that share a filename.
### Genuine consolidation candidates (YES + PARTIAL)
| Module | Feature | admin LOC | merchant LOC | store LOC | Verdict | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| messaging | messages (conversation list) | 339 | — | 282 | **YES** | Same chat table; only conversation_type filter enum differs. ~80% body shared. |
| messaging | notifications | 365 | — | 233 | **YES** | Same table + status badge; only scope filter differs. ~75% shared. |
| messaging | email-templates | 368 | — | 333 | **YES** | Same Tiptap editor + template list; admin has extra usage-stats cards (flag-gate). ~70% shared. |
| tenancy | my-account | 294 | 253 | 243 | **YES** | Identical layout: personal info form + password change + account metadata; only API prefix differs. ~80% shared, **three-persona win**. |
| tenancy | profile | — | 190 | 206 | **YES** | Two-persona near-duplicate. Same edit form, different scope. ~85% shared. |
| tenancy | team (members + invite) | — | 538 | 303 | **YES** | Same member table + invite modal; merchant has an extra audit-log tab (flag-gate). ~70% shared. |
| catalog | store-products / products (list) | 340 | — | 368 | **YES** | Same table + filters + stat cards. Admin has store selector + marketplace-source link (flags). ~70% shared. |
| billing | billing-history | 207 | 144 | — | **YES** | Same invoice table; admin shows settlement_id column. ~75% shared. |
| customers | customers (list) | 221 | — | 214 | **YES** | Same RFM breakdown + table; admin has cross-merchant scope + segment filter (flags). ~70% shared. |
| billing | subscriptions | 320 | 131 | — | **PARTIAL** | Admin shows tiers + multi-merchant aggregation, merchant shows own. Core list reusable; admin tier-management UI stays separate. ~65% shared. |
| catalog | store-product-detail | 358 | — | 174 | **PARTIAL** | Admin has cross-store override indicators + metadata; store has copy-buttons (inventory sync). Core ~60% reusable. |
| catalog | store-product-create / edit | 470503 | — | 174 | **PARTIAL** | Admin form has 4-language translation tabs + supplier cost + media picker; store form is single-language. Only ~50% reusable; recommend separate forms. |
| cms | content-pages | 205 | — | 339 | **PARTIAL** | Store version embeds media library tabs in the same template. Core table ~55% shared; media UI stays inline. |
| cms | content-page-edit | 705 | — | 337 | **PARTIAL** | Admin has versioning + audit trail; store is simpler with local drafts. Same rich-text editor (~40% shared). |
| orders | orders (list) | 584 | — | 334 | **PARTIAL** | Admin has settlement metadata + merchant column; store has fulfillment workflow (packing slips, bulk status). Core table ~50% shared. |
| tenancy | login | 215 | 216 | 252 | **PARTIAL** | All three share OAuth flow + form. Store has extra "select tenant" step. ~60% shared, **but security-sensitive — gate behind security review**. |
| inventory | inventory | 603 | — | 374 | **PARTIAL** | Both are heavy dashboards. Admin: cross-store stock + variance reports. Store: per-location stock + transfer queue. ~50% shared core. |
| tenancy | store-detail | 665 | 315 | — | **PARTIAL** | Admin has 4× more sections (domain mgmt, role matrix, audit log). Only ~35% shared. |
### Legit exceptions (NO)
These trip TPL-016 by line count but consolidation would force `{% if scope == admin %}` branches everywhere. Each should carry `{# noqa: TPL-016 #}` with a one-line reason.
| File | LOC | Why standalone |
| --- | --- | --- |
| `loyalty/admin/programs.html` | 347 | Multi-merchant aggregator; merchant/store show one program each. Different shape. *(already suppressed)* |
| `loyalty/admin/merchant-detail.html` | 432 | Admin-only tabbed view of an entire merchant's loyalty footprint. *(already suppressed)* |
| `loyalty/admin/merchant-settings.html` | 182 | Admin-only config aggregator. *(already suppressed)* |
| `loyalty/admin/wallet-debug.html` | 905 | Apple/Google wallet diagnostic tool. *(already suppressed)* |
| `loyalty/store/terminal.html` | 411 | POS hardware UI. *(already suppressed)* |
| `loyalty/store/enroll.html` | 175 | Counter-staff enrollment flow. *(already suppressed)* |
| `cms/admin/store-theme.html` | 463 | Admin theme editor — different feature from `cms/store/media.html` (asset library), same LOC is coincidence. |
| `tenancy/admin/merchants.html` + `merchant-create.html` + `merchant-detail.html` + `merchant-edit.html` | 261494 | Admin-only merchant CRUD. No merchant/store persona equivalents. |
| `tenancy/admin/admin-users.html` + detail/edit | 287357 | Platform-staff management — fundamentally an admin-only feature. |
| `marketplace/store/marketplace.html` | ~150 | CSV import workflow — different shape from `marketplace/admin/marketplace-products.html` (catalog browser). Not a duplicate, different feature. |
| `orders/store/order-detail.html` | 455 | Store-only fulfillment workflow view. No admin/merchant pair. |
| `orders/store/invoices.html` | 505 | Store-only invoice list + PDF generation. |
### Single-persona (N/A)
Counted for completeness; not consolidation work.
- `customers/store/customer-detail.html` (178) — store-only purchase history view.
- `billing/merchant/subscription-detail.html` (234) — merchant-only.
- `inventory` files where only admin or only store has a version.
- All `tenancy/admin/admin-*` files (admin user management).
- All `tenancy/admin/merchant-*` files (admin merchant CRUD).
---
## Prioritized backlog (top 10)
Ranked by `(LOC × duplication % × edit frequency) / effort × risk`. Each item gives a concrete recipe; backend is ready for all of them.
### Wave 1 — establish the pattern with quick wins (~2 days)
**1. messaging.messages**`S effort, L risk` → 480 LOC removed
- `messaging/templates/messaging/admin/messages.html` (339) + `store/messages.html` (282) → `shared/messages-list.html` + 2 thin wrappers.
- Only the conversation_type filter enum differs. Currently TPL-016 flagged.
- JS: factory `messagesList(config)` in `static/shared/js/`, 2 thin wrappers in `admin/js/` + `store/js/`.
**2. messaging.notifications**`S effort, L risk` → 450 LOC removed
- Same pattern as #1: `notifications-list.html` shared + 2 wrappers. Same factory shape.
**3. billing.billing-history**`S effort, L risk` → 260 LOC removed
- `billing/admin/billing-history.html` (207) + `merchant/billing-history.html` (144) → `invoices-list.html` shared + 2 wrappers.
- Admin's `settlement_id` column gates behind `show_settlement_column` flag.
**Total Wave 1: ~1,190 LOC removed, low risk, proves the pattern outside loyalty.**
### Wave 2 — three-persona wins and form sharing (~3 days)
**4. tenancy.my-account**`M effort, M risk` → 630 LOC removed
- `tenancy/admin/my-account.html` (294) + `merchant/my-account.html` (253) + `store/my-account.html` (243) → `account-profile.html` shared + 3 wrappers.
- **First three-persona shared template outside loyalty**, highest single-file payoff.
- Watch: token-claim handling differs slightly per persona; verify the password-change endpoint works on each.
**5. tenancy.profile**`S effort, L risk` → ~310 LOC removed
- `merchant/profile.html` (190) + `store/profile.html` (206) → `profile-form.html` shared + 2 wrappers.
- Cluster with #4 — they're sibling features in the same module; finish together so the `shared/` dir is one consistent set.
**6. messaging.email-templates**`M effort, M risk` → 490 LOC removed
- `messaging/admin/email-templates.html` (368) + `store/email-templates.html` (333) → `email-template-editor.html` shared + 2 wrappers.
- Both use Tiptap. Admin has extra usage-stats cards → `show_usage_stats=true` flag.
**Total Wave 2: ~1,430 LOC removed, includes the first 3-persona win.**
### Wave 3 — higher complexity (~3 days)
**7. tenancy.team**`M effort, M risk` → 590 LOC removed
- `merchant/team.html` (538) + `store/team.html` (303) → `team-list.html` + `invite-modal.html` shared.
- Merchant audit-log tab extracts to a separate panel with `show_audit_tab=true` flag.
**8. catalog.store-products (lists only)**`M effort, M risk` → ~495 LOC removed
- `catalog/admin/store-products.html` (340) + `store/products.html` (368) → `products-list.html` shared.
- Admin's store selector + marketplace-source link gate behind flags.
- **Forms stay separate** (different translation tab system, different field sets — see "Anti-candidates" below).
**9. customers.customers**`S effort, L risk` → 305 LOC removed
- `customers/admin/customers.html` (221) + `store/customers.html` (214) → `customer-list.html` shared.
- Admin: cross-merchant + segment filter (flag). Store: per-store scope.
**10. tenancy.login**`M effort, H risk` → ~410 LOC removed (**security review gate**)
- `tenancy/admin/login.html` (215) + `merchant/login.html` (216) + `store/login.html` (252) → `login-form.html` shared.
- Store has an extra "select tenant" step → `show_tenant_selector=true` flag.
- **Do this last** and only after a security review of the shared template; login templates handle session boundaries and CSRF, and a regression here is a auth incident, not a UX bug.
**Total Wave 3: ~1,800 LOC removed (or ~1,390 if login is deferred).**
---
## Anti-candidates (don't consolidate these)
These look like duplicates but should stay separate. The cost of forcing them into a shared partial is higher than the duplication cost.
| Cluster | Reason |
| --- | --- |
| **catalog product forms** (admin 470 LOC vs store 174 LOC) | Admin form has 4-language translation tabs + supplier-cost field + marketplace-source picker. Store form is single-language, simpler. Only ~30% structural overlap; consolidation would mean 7+ flags. Keep separate; `{# noqa: TPL-016 #}` on the admin file. |
| **marketplace admin vs store** | Admin = catalog browser (`marketplace-products.html`). Store = CSV import workflow (`marketplace.html`). Different mental models, not the same feature. |
| **orders detail** (store-only) | Store fulfillment workflow has no admin equivalent. Single-persona feature. |
| **tenancy.store-detail** (admin 665 vs merchant 315) | Admin has 4× more sections (domain mgmt, role matrix, audit log). Only ~35% shared — too much divergence for shared partial. |
| **cms.content-page-edit** | Admin has versioning + audit; store is simpler with local drafts. ~40% shared. Possible later; not a top-10 candidate. |
| **inventory** | Admin (cross-store dashboard) vs store (per-location stock + transfers) are different aggregation levels of the same data. Only ~50% shared. Possible later. |
| **`tenancy/admin/admin-users.html` vs `tenancy/admin/merchants.html`** | These are **two different entities in the same persona**, not cross-persona duplication. Their similarity is "list + filters + table" — that's what shared admin macros (`tables.html`, `pagination.html`) already cover. Consolidating into a feature-specific shared partial would be over-engineering. |
---
## Implementation notes
- **No backend work required** for any Wave 12 item. All checked services follow the scope-agnostic + auth-injected-scope pattern already.
- **i18n keys**: each migration follows loyalty's pattern — shared partials use `<module>.shared.<feature>.*` keys; persona-specific wrapper text (page titles, error messages) stays under `<module>.<persona>.<feature>.*`. Add missing keys to all 4 locales (en/fr/de/lb) when migrating.
- **JS factories**: every shared partial gets a paired Alpine factory in `static/shared/js/<module>-<feature>-list.js`. Persona JS files become 10-20 LOC wrappers calling the factory with config.
- **TPL-016 escalation**: once any non-loyalty module fully migrates, escalate the rule from `warning``error` for new persona templates over the threshold. This prevents the pattern from being re-broken.
## Timeline estimate
| Wave | Items | Effort | Risk | Cumulative LOC removed |
| --- | --- | --- | --- | --- |
| 1 | messages, notifications, billing-history | ~2 days | Low | ~1,190 |
| 2 | my-account, profile, email-templates | ~3 days | Medium | ~2,620 |
| 3 (no login) | team, catalog products list, customers | ~3 days | Medium | ~4,010 |
| 3 (with login) | + login | +1 day | High (security) | ~4,420 |
**~89 days of focused work** to clear the headline backlog. Doesn't include the anti-candidates or the inventory/cms remaining items (those can stay flagged with reasons).
---
## Open questions before starting
1. **Wave order**: ship in order (1 → 2 → 3) or pick highest-individual-ROI first regardless of risk?
2. **Login**: do the security review now (and include in Wave 3), or defer indefinitely?
3. **TPL-016 escalation timing**: after Wave 1, Wave 2, or only after the whole backlog clears?
4. **Per-migration commit cadence**: one PR per cluster (10 PRs), one PR per wave (3 PRs), or one big PR (1)? Loyalty's was a single PR which worked; for cross-module this is riskier.
---
## Out of scope
- Backend (services / routes / schemas) — already scope-agnostic; no work needed.
- Per-frontend base templates (`admin/base.html` / `merchant/base.html` / `store/base.html`) — these correctly stay separate.
- Shared macros under `app/templates/shared/macros/` — those are already shared infrastructure.
- The 5 already-suppressed loyalty exceptions — already documented inline.
- Storefront / customer-facing templates — different audience, not in scope.
---
## Lessons learned from the loyalty migration (post-audit notes)
These came out of in-prod testing on `rewardflow.lu/merchants/loyalty/cards/{id}` vs `/store/.../loyalty/cards/{id}` after the loyalty consolidation shipped. They sharpen the migration recipe for the rest of the backlog.
### 1. Template alignment ≠ data alignment
The shared partial guarantees the **markup** is the same across personas. It doesn't guarantee the **API response** is. Loyalty's `card-detail-view.html` had a `show_category_column` flag, but the column rendered empty on merchant + admin because only the **store** route enriched `tx.category_names` from `category_ids` via `category_service.validate_category_for_store`. Merchant + admin returned raw rows.
**Recipe for every migration**: after wiring up the shared partial, hit each persona's endpoint in a browser (or `curl` to the JSON) and **diff the response shapes**. Any optional/enriched field used by the shared template must be populated by every persona's route, or the shared template must gracefully render `-` for missing data. Fixed in commit `d32c1fd5`.
### 2. Locale-aware formatters are infrastructure, not per-feature
The same bug — hardcoded `'en-US'` in `toLocaleDateString` / `Intl.NumberFormat` — turned up in **27 places** across **20+ JS files** (5 loyalty shared factories, 8 loyalty persona files, 13 non-loyalty files + the shared `Utils` helper). All of them were silently rendering dates and numbers in English even when the dashboard language was French.
The fix landed as three swept commits + a new architecture rule:
| Commit | Scope |
| --- | --- |
| `dd1f9af8` | 5 loyalty shared factories + new `I18n.locale` getter on `static/shared/js/i18n.js` |
| `06e59f73` | 13 non-loyalty files (catalog, marketplace, orders, tenancy, inventory, monitoring, cms, storefront layout, shared Utils) |
| `bb4c4004` | 8 remaining loyalty persona files (admin/merchant/store/storefront) |
| `eaf180c6` | New `JS-016` architecture rule at **error severity** — CI rejects any future hardcoded locale tag |
**Recipe**: don't write `'en-US'` ever; use `I18n.locale`. The rule will reject the PR otherwise. Suppressible per-line with `// noqa: JS-016` for the genuine US-only formatter case.
### 3. Sweep + rule, not just sweep
Sweeping the codebase clean is necessary but not sufficient — without a rule, the next contributor reintroduces the pattern. Every consolidation-style cleanup in this audit should land with a matching architecture rule (warning or error) so the work doesn't decay. Current rules guarding this surface area:
| Rule | Severity | What it blocks |
| --- | --- | --- |
| `TPL-016` | warning | Persona templates >75 LOC that don't include a `*/shared/*` partial |
| `FE-024` | warning | Raw `url_for()` on JS/CSS instead of `static_v()` |
| `JS-016` | **error** | Hardcoded `'en-US'` in `toLocale*` / `Intl.*` calls |
When a Wave 1-3 migration lands, consider whether it deserves a new rule (e.g., "messaging shared factories must accept a `config` arg") — small, focused rules that prevent regression are cheap and high-value.

View File

@@ -0,0 +1,49 @@
# Persona Template Consolidation
**Date:** 2026-05-23
**Implemented:** 2026-05-23
**Status:** Done
**Motivation:** Maintainers were editing the "same" admin / merchant / store templates three times for every feature change, with predictable drift between personas. The user wanted to know whether the codebase could move to a model where the shared CRUD body lives once and per-persona wrappers handle only the parts that genuinely differ.
## Discovery
Loyalty turned out to be a partial success story already: `app/modules/loyalty/templates/loyalty/shared/` held 7 reusable partials (program-form, program-view, cards-list, transactions-list, pins-list, devices-list, analytics-stats), and merchant/admin wrappers were already thin includes. The pattern was working but undocumented, unenforced, and **not consistently applied to store/** — store had inlined two big features (cards.html 171 LOC, card-detail.html 205 LOC) instead of using the shared partials.
So this turned into a finish + codify + guard job, not a rewrite.
## Implementation Summary
- **Phase A (loyalty cleanup).** Migrated `store/cards.html` to use `shared/cards-list.html` (171 → 56 LOC) and `store/card-detail.html` to use `shared/card-detail-view.html` (205 → 55 LOC). JS factories collapsed similarly (166 → 18 and 152 → 20). The shared `card-detail-view.html` partial gained three boolean flags (`show_copy_buttons`, `show_category_column`, `show_pagination`) and the shared `loyaltyCardDetailView` factory gained optional pagination + `txLabels`/`txNotes` config so store could preserve its enhancements. Added `loyalty.shared.card_detail.col_category` to en/fr/de/lb locale files. Fixed a latent bug in the shared factory's `formatDateTime` (was calling `toLocaleDateString` with hour/minute opts that get silently ignored).
- **Phase B (codify).** Wrote `docs/architecture/persona-template-consolidation.md` describing the pattern, the scope contract, the backend mirror, and the legit-exception heuristic. This doc is now the reference for any contributor adding a new CRUD feature.
- **Phase C (guard).** Added architecture rule `TPL-016` (warning) that flags any persona template `> 75 LOC` that doesn't include a `*/shared/*` partial. Wired both check sites in `scripts/validate/validate_architecture.py`. Suppressible with `{# noqa: TPL-016 #}` for the legit exceptions (admin programs aggregator, merchant-detail, store terminal, etc.).
## Decisions Made
| # | Decision | Rationale |
| --- | --- | --- |
| 1 | Loyalty cleanup + codify pattern + arch rule | Scope-limited; the pattern is the real deliverable. |
| 2 | Leave `admin/programs.html` standalone | Multi-merchant aggregator + create-with-search modal is fundamentally a different shape from the merchant/store single-program views. Forcing it into shared would mean `if scope == admin` in every row. |
| 3 | JS/CSS variables only (no macro objects, no `persona` enum) | Existing loyalty pattern proven to work; macro objects bloat call sites and `persona` branching defeats the purpose of the partial. |
| 4 | `TPL-016` severity = warning | Lets the rule ship without breaking CI on day one. Escalate to error after at least one other module is migrated. |
## Files Touched
- **Templates:** `loyalty/templates/loyalty/store/cards.html`, `loyalty/templates/loyalty/store/card-detail.html`, `loyalty/templates/loyalty/shared/card-detail-view.html` (added flags).
- **JS:** `loyalty/static/store/js/loyalty-cards.js`, `loyalty/static/store/js/loyalty-card-detail.js`, `loyalty/static/shared/js/loyalty-card-detail-view.js` (added config options + pagination).
- **i18n:** `loyalty/locales/{en,fr,de,lb}.json` (added `shared.card_detail.col_category`).
- **Docs:** new `docs/architecture/persona-template-consolidation.md`, this proposal, `mkdocs.yml` nav.
- **Arch rule:** `.architecture-rules/frontend.yaml` (TPL-016), `scripts/validate/validate_architecture.py` (check function + 2 wire sites).
## Verification
- `python scripts/validate/validate_architecture.py` — 16 baseline warnings, no new findings.
- `mkdocs build --strict` — clean.
- Smoke test: store/cards and store/card-detail render identically to the pre-migration version (filters, search, pagination, copy buttons, category column, translated transaction labels all preserved).
- Pre-commit hooks (architecture, security, performance, audit, ruff) all green.
## Out of Scope (Deferred)
- Applying the pattern to other modules (catalog, billing, etc.). The doc + rule make this a follow-up any contributor can pick up.
- Escalating `TPL-016` from warning to error — wait until ≥1 other module migrates.
- Consolidating the three `analytics.html` wrappers further — they're already minimal given each persona has materially different content (admin wallet status, store advanced charts).
- Settings consolidation — `merchant/settings.html` and `store/settings.html` are different features (read-only PIN/permissions display vs editable program form), not duplicates.

View File

@@ -61,6 +61,7 @@ nav:
- Media Architecture: architecture/media-architecture.md
- Metrics Provider Pattern: architecture/metrics-provider-pattern.md
- Multi-Platform CMS: architecture/multi-platform-cms.md
- Persona Template Consolidation: architecture/persona-template-consolidation.md
- Tenancy Module Migration: architecture/tenancy-module-migration.md
- Widget Provider Pattern: architecture/widget-provider-pattern.md
- Architecture Violations Status: architecture/architecture-violations-status.md
@@ -328,6 +329,7 @@ nav:
- Module Dependency Redesign: proposals/SESSION_NOTE_2026-02-03_module-dependency-redesign.md
- Import-002 Cross-Module Deps: proposals/SESSION_NOTE_2026-02-09_import002-cross-module-deps.md
- Android Terminal Implementation: proposals/android-terminal-implementation.md
- Auth-Redirect Dev/Prod Symmetry: proposals/auth-redirect-dev-prod-symmetry.md
- Backward Compatibility Cleanup: proposals/backward-compatibility-cleanup.md
- CMS Redesign Alignment: proposals/cms-redesign-alignment.md
- Decouple Modules: proposals/decouple-modules.md
@@ -339,6 +341,8 @@ nav:
- Hosting Cascade Delete: proposals/hosting-cascade-delete.md
- Hosting Site Creation Fix: proposals/hosting-site-creation-fix.md
- Loyalty Go-Live Readiness: proposals/loyalty-go-live-readiness.md
- Persona Template Consolidation: proposals/persona-template-consolidation.md
- Persona Template Consolidation Audit: proposals/persona-template-consolidation-audit.md
- Loyalty Phase 2 Interfaces: proposals/loyalty-phase2-interfaces-plan.md
- Loyalty Program Analysis: proposals/loyalty-program-analysis.md
- Merchant Intake Checklist: proposals/merchant-intake-checklist.md

View File

@@ -1,58 +0,0 @@
# Alertmanager Configuration for Orion Platform
# Docs: https://prometheus.io/docs/alerting/latest/configuration/
global:
resolve_timeout: 5m
# ─── SMTP Configuration (SendGrid) ──────────────────────────────────
# Sign up at sendgrid.com, create an API key, authenticate wizard.lu domain
# Username is literally the string "apikey", password is your SG.xxx API key
smtp_smarthost: 'smtp.sendgrid.net:587' # SendGrid SMTP relay
smtp_from: 'alerts@wizard.lu' # Must match authenticated domain
smtp_auth_username: 'apikey' # Always "apikey" for SendGrid
smtp_auth_password: '' # TODO: Paste your SG.xxx API key here
smtp_require_tls: true
route:
# Group alerts by name and severity
group_by: ['alertname', 'severity']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'email-warnings'
routes:
# Critical alerts: repeat every 1 hour
- match:
severity: critical
receiver: 'email-critical'
repeat_interval: 1h
# Warning alerts: repeat every 4 hours
- match:
severity: warning
receiver: 'email-warnings'
repeat_interval: 4h
receivers:
- name: 'email-critical'
email_configs:
- to: 'admin@wizard.lu' # TODO: Replace with your alert recipient
send_resolved: true
headers:
Subject: '[CRITICAL] Orion: {{ .GroupLabels.alertname }}'
- name: 'email-warnings'
email_configs:
- to: 'admin@wizard.lu' # TODO: Replace with your alert recipient
send_resolved: true
headers:
Subject: '[WARNING] Orion: {{ .GroupLabels.alertname }}'
# Inhibition rules — suppress warnings when critical is already firing
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'instance']

View File

@@ -0,0 +1,71 @@
# Alertmanager Configuration for Orion Platform — TEMPLATE
# Docs: https://prometheus.io/docs/alerting/latest/configuration/
#
# This is the IN-REPO TEMPLATE. The real file on each host lives at
# monitoring/alertmanager/alertmanager.yml (gitignored, never committed).
# Copy this file to that path and fill in the CHANGEME values per
# docs/deployment/hetzner-server-setup.md.
global:
resolve_timeout: 5m
# ─── SMTP Configuration (mail1.myservices.hosting relay) ────────────
# Migrated from SendGrid to mail1.myservices.hosting on 2026-??-?? —
# same SMTP backend the app uses (see /admin/settings).
#
# smtp_from is set to alerts@wizard.lu for inbox routing clarity. Most
# SMTP relays allow the From: header to differ from the authenticated
# user, BUT some require them to match. If you see "550 sender not
# authorized" in the alertmanager logs after a reload, either:
# 1. Configure alerts@wizard.lu as a send-as alias on the support@
# mailbox in your mail hosting control panel, or
# 2. Change smtp_from to 'support@wizard.lu' (less clear in inbox).
smtp_smarthost: 'mail1.myservices.hosting:587'
smtp_from: 'alerts@wizard.lu'
smtp_auth_username: 'support@wizard.lu'
smtp_auth_password: 'CHANGEME' # The /admin/settings SMTP password. NEVER commit a real value.
smtp_require_tls: true
route:
# Group alerts by name and severity
group_by: ['alertname', 'severity']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'email-warnings'
routes:
# Critical alerts: repeat every 1 hour
- match:
severity: critical
receiver: 'email-critical'
repeat_interval: 1h
# Warning alerts: repeat every 4 hours
- match:
severity: warning
receiver: 'email-warnings'
repeat_interval: 4h
receivers:
- name: 'email-critical'
email_configs:
- to: 'admin@wizard.lu' # Recipient mailbox for critical alerts
send_resolved: true
headers:
Subject: '[CRITICAL] Orion: {{ .GroupLabels.alertname }}'
- name: 'email-warnings'
email_configs:
- to: 'admin@wizard.lu' # Recipient mailbox for warning alerts
send_resolved: true
headers:
Subject: '[WARNING] Orion: {{ .GroupLabels.alertname }}'
# Inhibition rules — suppress warnings when critical is already firing
inhibit_rules:
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'instance']

Some files were not shown because too many files have changed in this diff Show More