Compare commits
41 Commits
93ab072f55
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 223650a52b | |||
| c93346f8ff | |||
| 64c8a0ec2c | |||
| ac7850b880 | |||
| 4423f0a5ed | |||
| 947ca43c7b | |||
| 1227567d08 | |||
| e44f5c0458 | |||
| cff0b3f911 | |||
| c13e8e29b5 | |||
| c9fe717184 | |||
| bbb481aad4 | |||
| 6564f13898 | |||
| b04b36a232 | |||
| 856db328b5 | |||
| a0ae638821 | |||
| 3ce9468397 | |||
| 5f359283bc | |||
| acbe2eff1a | |||
| 213a60533c | |||
| 7e3a1cb93f | |||
| 1bade6e6b2 | |||
| 36fd37813b | |||
| b463c6bf48 | |||
| 10a99f98fe | |||
| f04cbb8ca2 | |||
| eaf180c64f | |||
| bb4c400436 | |||
| 06e59f73b3 | |||
| a21dbbcddf | |||
| 478c3a9c50 | |||
| dd1f9af811 | |||
| d32c1fd545 | |||
| 58a9e3f740 | |||
| 82939c0005 | |||
| d03b96da1c | |||
| 78621cb7bb | |||
| aa8ca59493 | |||
| f9a15deed7 | |||
| f82dce30ca | |||
| c661c1e394 |
@@ -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/"
|
||||
|
||||
# ============================================================================
|
||||
|
||||
158
.claude/skills/wrap/SKILL.md
Normal file
158
.claude/skills/wrap/SKILL.md
Normal 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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }}">
|
||||
← {{ _("auth.continue_shopping") }}
|
||||
← {{ _("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;
|
||||
}
|
||||
|
||||
@@ -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 }}">
|
||||
← {{ _("auth.continue_shopping") }}
|
||||
← {{ _("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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
← {{ _("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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}) "
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"widget": {
|
||||
"summary": {
|
||||
"title": "Bestellungen",
|
||||
"subtitle": "Bestellverlauf anzeigen",
|
||||
"value_label": "Bestellungen gesamt"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "Bestellungen",
|
||||
"order": "Bestellung",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"widget": {
|
||||
"summary": {
|
||||
"title": "Orders",
|
||||
"subtitle": "View order history",
|
||||
"value_label": "Total Orders"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "Orders",
|
||||
"order": "Order",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"widget": {
|
||||
"summary": {
|
||||
"title": "Commandes",
|
||||
"subtitle": "Voir l'historique des commandes",
|
||||
"value_label": "Total des commandes"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "Commandes",
|
||||
"order": "Commande",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"widget": {
|
||||
"summary": {
|
||||
"title": "Bestellungen",
|
||||
"subtitle": "Bestellhistorik kucken",
|
||||
"value_label": "Bestellunge gesamt"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"title": "Bestellungen",
|
||||
"order": "Bestellung",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
119
docs/architecture/persona-template-consolidation.md
Normal file
119
docs/architecture/persona-template-consolidation.md
Normal 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: **20–60 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.
|
||||
@@ -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 2–6
|
||||
(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
|
||||
|
||||
109
docs/proposals/auth-redirect-dev-prod-symmetry.md
Normal file
109
docs/proposals/auth-redirect-dev-prod-symmetry.md
Normal 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:** ~1–2h 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.
|
||||
@@ -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 4–8 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 42–45). 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 |
|
||||
|
||||
248
docs/proposals/persona-template-consolidation-audit.md
Normal file
248
docs/proposals/persona-template-consolidation-audit.md
Normal 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,100–3,500 LOC of duplication** can be removed by migrating the top-10 candidates. Roughly 8–10 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 | 470–503 | — | 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` | 261–494 | Admin-only merchant CRUD. No merchant/store persona equivalents. |
|
||||
| `tenancy/admin/admin-users.html` + detail/edit | 287–357 | 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 1–2 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 |
|
||||
|
||||
**~8–9 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.
|
||||
49
docs/proposals/persona-template-consolidation.md
Normal file
49
docs/proposals/persona-template-consolidation.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
71
monitoring/alertmanager/alertmanager.yml.example
Normal file
71
monitoring/alertmanager/alertmanager.yml.example
Normal 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
Reference in New Issue
Block a user