Compare commits

...

80 Commits

Author SHA1 Message Date
3ade1b9354 docs(loyalty): rewrite launch plan with step-by-step pre-launch checklist
Some checks failed
CI / pytest (push) Failing after 2h31m6s
CI / validate (push) Successful in 29s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 13s
Replace the old effort/critical-path sections with current status:
all dev phases 0-8 marked DONE with dates. Added a clear 8-step
pre-launch checklist (seed templates, deploy wallet certs, migrations,
translations, permissions, E2E testing, device test, go live) and a
post-launch roadmap table (Apple Wallet, marketing module, coverage,
trash UI, bulk PINs, cross-location enforcement).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:34:57 +02:00
b5bb9415f6 feat(cms): Phase A — page type selector, translation UI, SEO cleanup
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Content page editor improvements:
- Page type selector: Content Page / Landing Page dropdown (sets template)
- Title language tabs: translate page titles per language (same pattern as sections)
- Content language tabs: translate page content per language
- Meta description language tabs: translatable SEO descriptions
- Template-driven section palette: template defines which sections are available
  (store landing pages hide Pricing, platform homepages show all)
- Hide content editor when Landing Page selected, hide sections when Content Page

Schema changes (migration cms_003):
- Add meta_description_translations column (JSON) to content_pages
- Drop meta_keywords column (obsolete, ignored by all search engines since 2009)
- Remove meta keywords tag from storefront and platform base templates

API + service updates:
- title_translations, content_translations, meta_description_translations
  added to create/update schemas, route handlers, and service methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 22:30:55 +02:00
bb3d6f0012 fix(loyalty): card detail — enrolled store name + copy buttons
Some checks failed
CI / pytest (push) Failing after 2h22m22s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 12s
- Fix "Enrolled at: Unknown" by resolving enrolled_at_store_name from
  the store service and adding it to CardDetailResponse schema.
- Add clipboard-copy buttons next to card number, customer name,
  email, and phone fields using the shared Utils.copyToClipboard()
  utility with toast feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:31:53 +02:00
c92fe1261b fix(loyalty): use full pagination macro on card detail (match cards list)
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Has started running
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Switch from pagination_simple to pagination — the same macro used on
the cards list page, with page number buttons and "Showing X-Y of Z".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:25:29 +02:00
ca152cd544 fix(loyalty): use shared pagination macro on card detail transactions
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Replace custom pagination with the shared pagination_simple macro
to match the cards list page pattern. Always shows "Showing X-Y of Z"
with Previous/Next — no longer hidden when only 1 page. Uses standard
Alpine.js pagination interface (pagination.page, totalPages, startIndex,
endIndex, pageNumbers, previousPage, nextPage, goToPage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:18:59 +02:00
914967edcc feat(loyalty): add paginated transaction history to card detail
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
The store card detail page now shows paginated transaction history
instead of a flat list of 50. Uses PlatformSettings.getRowsPerPage()
for the page size (default 20), with Previous/Next navigation and
"Page X of Y" indicator using server-rendered i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:13:00 +02:00
64fe58c171 fix(loyalty): normalize card id field, fix terminal redeem bug
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
The terminal redeem failed with "card not found: unknown" because
CardLookupResponse used card_id while CardDetailResponse (from
refreshCard) used id. After refresh, selectedCard.card_id was
undefined.

Fix: standardize on 'id' everywhere (the universal convention):
- CardLookupResponse: card_id → id
- _build_card_lookup_response: card_id= → id=
- loyalty-terminal.js: selectedCard.card_id → selectedCard.id (5 refs)
- Removed the card_id/model_validator approach as unnecessary

Also fixes Chart.js recursion error on analytics page (inline CDN
script instead of optional-libs.html include which caused infinite
template recursion in test context).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:01:26 +02:00
3044490a3e feat(storefront): section-based homepages, header action partials, fixes
Phase 1 — Section-based store homepages:
- Store defaults use template="full" with per-platform sections JSON
- OMS: shop hero + features + CTA; Loyalty: rewards hero + features + CTA
- Hosting: services hero + features + CTA
- Deep placeholder resolution for {{store_name}} inside sections JSON
- landing-full.html uses resolved page_sections from context

Phase 2 — Module-contributed header actions:
- header_template field on MenuItemDefinition + DiscoveredMenuItem
- Catalog provides header-search.html partial
- Cart provides header-cart.html partial with badge
- Base template iterates storefront_nav.actions with {% include %}
- Generic icon fallback for actions without a template

Fixes:
- Store theme API: get_store_by_code → get_store_by_code_or_subdomain

Docs:
- CMS redesign proposal: menu restructure, page types, translations UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:33:06 +02:00
adc36246b8 feat(storefront): homepage, module gating, widget protocol, i18n fixes
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 2h32m45s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Storefront homepage & module gating:
- CMS owns storefront GET / (slug="home" with 3-tier resolution)
- Catalog loses GET / (keeps /products only)
- Store root redirect (GET / → /store/dashboard or /store/login)
- Route gating: non-core modules return 404 when disabled for platform
- Seed store default homepages per platform

Widget protocol for customer dashboard:
- StorefrontDashboardCard contract in widgets.py
- Widget aggregator get_storefront_dashboard_cards()
- Orders and Loyalty module widget providers
- Dashboard template renders contributed cards (no module names)

Landing template module-agnostic:
- CTAs driven by storefront_nav (not hardcoded module names)
- Header actions check nav item IDs (not enabled_modules)
- Remove hardcoded "Add Product" sidebar button
- Remove all enabled_modules checks from storefront templates

i18n fixes:
- Title placeholder resolution ({{store_name}}) for store default pages
- Storefront nav label_keys prefixed with module code
- Add storefront.account.* keys to 6 modules (en/fr/de/lb)
- Header/footer CMS pages use get_translated_title(current_language)
- Footer labels use i18n keys instead of hardcoded English

Icon cleanup:
- Standardize on map-pin (remove location-marker alias)
- Replace all location-marker references across templates and docs

Docs:
- Storefront builder vision proposal (6 phases)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:53:17 +02:00
dd9dc04328 feat(loyalty): add Chart.js visualizations to analytics page
Some checks failed
CI / pytest (push) Failing after 2h21m5s
CI / ruff (push) Successful in 13s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Wire the Phase 7 analytics API endpoints into the store analytics
page with interactive visualizations:

- Revenue chart (Chart.js bar+line combo): monthly points earned as
  bars + active customers as line overlay with dual Y axes.
- At-risk members panel: ranked list of churning cards showing
  customer name and days inactive, with count badge.
- Cohort retention table: enrollment month rows × M0-M5 retention
  columns with color-coded percentage cells (green >60%, yellow
  >30%, red <30%).

Chart.js loaded on-demand via existing CDN loader with local fallback.
Data fetched in parallel via Promise.all for the 3 analytics endpoints.
All sections gracefully degrade to "not enough data" message when empty.

7 new i18n keys (EN only — FR/DE/LB translations to be added).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:30:36 +02:00
4a60d75a13 docs(loyalty): Phase 8 — runbooks, monitoring, OpenAPI tags, plan update
Some checks failed
CI / ruff (push) Successful in 12s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
Final phase of the production launch plan:

- Runbook: wallet certificate management (Google + Apple rotation,
  expiry monitoring, rollback procedure)
- Runbook: point expiration task (manual execution, partial failure,
  per-merchant re-run, point restore via admin API)
- Runbook: wallet sync task (failed_card_ids interpretation, manual
  re-sync, retry behavior table)
- Monitoring: alert definitions (P0/P1/P2), key metrics, log events,
  dashboard suggestions
- OpenAPI: added tags=["Loyalty - Store"] and tags=["Loyalty - Admin"]
  to route groups for /docs discoverability
- Production launch plan: all phases 0-8 marked DONE

Coverage note: loyalty services at 70-85%, tasks at 16-29%.
Target 80% enforcement deferred — current 342 tests provide good
functional coverage. Task-level coverage requires Celery mocking
infrastructure (future sprint).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:07:50 +02:00
e98eddc168 feat(loyalty): Phase 7 — advanced analytics (cohort, churn, revenue)
New analytics_service.py with three analytics features:

- Cohort retention: groups cards by enrollment month, tracks % with
  any transaction in each subsequent month. Returns matrix suitable
  for Chart.js heatmap. GET /analytics/cohorts?months_back=6
- Churn detection: flags cards as "at risk" when inactive > 2x their
  average inter-transaction interval (default 60d for new cards).
  Returns ranked list. GET /analytics/churn?limit=50
- Revenue attribution: monthly and per-store aggregation of point-
  earning transactions. GET /analytics/revenue?months_back=6

Endpoints added to both admin API (/admin/loyalty/merchants/{id}/
analytics/*) and store API (/store/loyalty/analytics/*) so merchants
can see their own analytics.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:57:23 +02:00
8cd09f3f89 feat(loyalty): Phase 6 — admin GDPR, bulk ops, point restore, cascade
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running
Admin operations for production management:

- GDPR anonymization: DELETE /admin/loyalty/cards/customer/{id}
  Nulls customer_id, deactivates cards, scrubs PII from transaction
  notes. Keeps aggregate data for reporting.
- Bulk deactivate: POST /admin/loyalty/merchants/{id}/cards/bulk/deactivate
  and POST /store/loyalty/cards/bulk/deactivate (merchant_owner only).
  Deactivates multiple cards with audit trail.
- Point restore: POST /admin/loyalty/cards/{id}/restore-points
  Creates ADMIN_ADJUSTMENT transaction with positive delta. Reuses
  existing adjust_points service method.
- Cascade restore: POST /admin/loyalty/merchants/{id}/restore-deleted
  Restores all soft-deleted programs and cards for a merchant.

Service methods: anonymize_cards_for_customer, bulk_deactivate_cards,
restore_deleted_cards, restore_deleted_programs.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:40:34 +02:00
4c1608f78a feat(loyalty): Phase 4.1 — T&C via CMS integration
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Add support for linking a loyalty program's Terms & Conditions to a
CMS page, replacing the simple terms_text textarea with a scalable
content source that supports rich HTML, multi-language, and store
overrides.

- Migration loyalty_006: adds terms_cms_page_slug column to
  loyalty_programs (nullable, String 200).
- Model + schemas: new field on LoyaltyProgram, ProgramCreate,
  ProgramUpdate, ProgramResponse.
- Program form: new "CMS Page Slug" input field with hint text,
  placed above the legacy terms_text (now labeled as "fallback").
- Enrollment page: when terms_cms_page_slug is set, JS fetches the
  CMS page content via /storefront/cms/pages/{slug} and displays
  rendered HTML in the modal. Falls back to terms_text when no slug.
- i18n: 3 new keys in 4 locales (terms_cms_page, terms_cms_page_hint,
  terms_fallback_hint).

Legacy terms_text field preserved as fallback for existing programs.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:26:22 +02:00
24219e4d9a a11y(loyalty): Phase 4.2 — accessibility audit fixes
Fix 15 accessibility issues across loyalty templates:

Modals (4 fixes):
- storefront/dashboard.html: barcode modal — add role="dialog",
  aria-modal, aria-labelledby, @keydown.escape
- storefront/enroll.html: terms modal — add role="dialog",
  aria-modal, aria-labelledby, aria-label on close button
- store/enroll.html: success modal — add role="dialog",
  aria-modal, aria-labelledby, @keydown.escape
- store/terminal.html: PIN entry — add aria-live="polite" on
  digit display with role="status" for screen reader announcements

Icon-only buttons (10 fixes):
- shared/pins-list.html: edit, delete, unlock — add aria-label
- admin/programs.html: view, edit, delete, activate/deactivate —
  add aria-label (dynamic for toggle state)
- store/terminal.html: clear customer, backspace — add aria-label

All buttons also get explicit type="button" where missing.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:14:03 +02:00
fde58bea06 perf(loyalty): Phase 3 — batched expiration + wallet sync backoff
Some checks failed
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / ruff (push) Successful in 12s
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has started running
Phase 3 of the production launch plan: task reliability improvements
to prevent DB lock issues at scale and handle transient wallet API
failures gracefully.

- 3.1 Batched point expiration: rewrite per-card Python loop to chunked
  processing (LIMIT 500 FOR UPDATE SKIP LOCKED). Each chunk commits
  independently, releasing row locks before processing the next batch.
  Notifications sent after commit (outside lock window). Warning emails
  also chunked with same pattern.
- 3.2 Wallet sync exponential backoff: replace time.sleep(2) single
  retry with 4 attempts using [1s, 4s, 16s] backoff delays. Per-card
  try/except ensures one failing card doesn't block the batch.
  Failed card IDs logged for observability.

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:55:39 +02:00
52b78ce346 feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
CI / ruff (push) Successful in 13s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Add async email notifications for 5 loyalty lifecycle events, using
the existing messaging module infrastructure (EmailService, EmailLog,
store template overrides).

- New seed script: scripts/seed/seed_email_templates_loyalty.py
  Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent.
  Renamed existing script to seed_email_templates_core.py.
- Celery task: loyalty.send_notification_email — async dispatch with
  3 retries and 60s backoff. Opens own DB session.
- Notification service: LoyaltyNotificationService with 5 methods
  that resolve customer/card/program into template variables and
  enqueue via Celery (never blocks request handlers).
- Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if
  bonus > 0) after card creation commit.
- Stamps: sends loyalty_reward_ready when stamp target reached.
- Expiration task: sends loyalty_points_expiring 14 days before expiry
  (tracked via new last_expiration_warning_at column to prevent dupes),
  and loyalty_points_expired after points are zeroed.
- Migration loyalty_005: adds last_expiration_warning_at to cards.
- 8 new unit tests for notification service dispatch.
- Fix: rate limiter autouse fixture in integration tests to prevent
  state bleed between tests.

Templates: loyalty_enrollment, loyalty_welcome_bonus,
loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready.
All support store-level overrides via the existing email template UI.

Birthday + re-engagement emails deferred to future marketing module
(cross-platform: OMS, loyalty, hosting).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:11:56 +02:00
f804ff8442 fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Fix duplicate card creation when the same email enrolls at different
stores under the same merchant, and implement cross-location-aware
enrollment behavior.

- Cross-location enabled (default): one card per customer per merchant.
  Re-enrolling at another store returns the existing card with a
  "works at all our locations" message + store list.
- Cross-location disabled: one card per customer per store. Enrolling
  at a different store creates a separate card for that store.

Changes:
- Migration loyalty_004: replace (merchant_id, customer_id) unique
  index with (enrolled_at_store_id, customer_id). Per-merchant
  uniqueness enforced at application layer when cross-location enabled.
- card_service.resolve_customer_id: cross-store email lookup via
  merchant_id param to find existing cardholders at other stores.
- card_service.enroll_customer: branch duplicate check on
  allow_cross_location_redemption setting.
- card_service.search_card_for_store: cross-store email search when
  cross-location enabled so staff at store2 can find cards from store1.
- card_service.get_card_by_customer_and_store: new service method.
- storefront enrollment: catch LoyaltyCardAlreadyExistsException,
  return existing card with already_enrolled flag, locations, and
  cross-location context. Server-rendered i18n via Jinja2 tojson.
- enroll-success.html: conditional cross-store/single-store messaging,
  server-rendered translations and context, i18n_modules block added.
- dashboard.html, history.html: replace $t() with server-side _() to
  fix i18n flicker across all storefront templates.
- Fix device-mobile icon → phone icon.
- 4 new i18n keys in 4 locales (en, fr, de, lb).
- Docs: updated data-model, business-logic, production-launch-plan,
  user-journeys with cross-location behavior and E2E test checklist.
- 12 new unit tests + 3 new integration tests (334 total pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:28:19 +02:00
d9abb275a5 feat(dev_tools): expand SQL query tool presets and fix column headers
Add 45 new preset queries covering all database tables, reorganize into
platform-aligned sections (Infrastructure, Core, OMS, Loyalty, Hosting,
Internal) with search/filter input. Fix column headers not appearing on
SELECT * queries by capturing result.keys() before fetchmany().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:28:57 +02:00
4b56eb7ab1 feat(loyalty): Phase 1 production launch hardening
Some checks failed
CI / ruff (push) Successful in 18s
CI / pytest (push) Failing after 2h37m39s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Phase 1 of the loyalty production launch plan: config & security
hardening, dropped-data fix, DB integrity guards, rate limiting, and
constant-time auth compare. 362 tests pass.

- 1.4 Persist customer birth_date (new column + migration). Enrollment
  form was collecting it but the value was silently dropped because
  create_customer_for_enrollment never received it. Backfills existing
  customers without overwriting.
- 1.1 LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON validated at startup (file
  must exist and be readable; ~ expanded). Adds is_google_wallet_enabled
  and is_apple_wallet_enabled derived flags. Prod path documented as
  ~/apps/orion/google-wallet-sa.json.
- 1.5 CHECK constraints on loyalty_cards (points_balance, stamp_count
  non-negative) and loyalty_programs (min_purchase, points_per_euro,
  welcome_bonus non-negative; stamps_target >= 1). Mirrored as
  CheckConstraint in models. Pre-flight scan showed zero violations.
- 1.3 @rate_limit on store mutating endpoints: stamp 60/min,
  redeem/points-earn 30-60/min, void/adjust 20/min, pin unlock 10/min.
- 1.2 Constant-time hmac.compare_digest for Apple Wallet auth token
  (pulled forward from Phase 9 — code is safe whenever Apple ships).

See app/modules/loyalty/docs/production-launch-plan.md for the full
launch plan and remaining phases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:36:34 +02:00
27ac7f3e28 docs: add nav fix to POC content mapping proposal
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Failing after 2h40m46s
CI / validate (push) Successful in 32s
CI / dependency-scanning (push) Successful in 37s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
E-commerce nav (Products, Cart, Account) shows on hosting POC sites.
Preview mode should render only CMS pages (Services, Projects, Contact)
in the nav, not module-defined e-commerce items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:32:15 +02:00
dfd42c1b10 docs: add proposal for POC content mapping (scraped → template)
Some checks failed
CI / ruff (push) Successful in 16s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
Details the gap between scraped content (21 paragraphs, 30 headings,
13 images) and what appears on POC pages (only placeholder fields).

Phase 1 plan: programmatic mapping of scraped headings/paragraphs/
images into template sections (hero subtitle, gallery, about text).
Phase 2: AI-powered content enhancement (deferred, provider TBD).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:14:17 +02:00
297b8a8d5a fix(hosting): preview link rewriting prepends storefront base_url
CTA buttons in section macros use short paths like /contact which
resolve to site root instead of the storefront path. The preview
JS now detects short paths (not starting with /platforms/, /storefront/,
etc.) and prepends the store's base_url before adding _preview token.

Example: /contact → /platforms/hosting/storefront/batirenovation-strasbourg/contact?_preview=...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:03:04 +02:00
91fb4b0757 fix(hosting): propagate preview token in nav links + enrich pages with scraped content
Preview token propagation:
- JavaScript in storefront base.html appends _preview query param to
  all internal links when in preview mode, so clicking nav items
  (Services, Contact, etc.) preserves the preview bypass

Scraped content enrichment:
- POC builder now appends first 5 scraped paragraphs to about/services/
  projects pages, so the POC shows actual content from the prospect's
  site instead of just generic template text
- Extracts tagline from second scraped heading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 19:55:19 +02:00
f4386e97ee fix(cms): testimonials dict.items() collision in section macro
testimonials.items in Jinja2 calls dict.items() method instead of
accessing the 'items' key when sections are raw JSON dicts (POC builder
pages). Fixed by using .get('items', []) with mapping check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:54:36 +02:00
e8c9fc7e7d fix(hosting): template buttons use 'text' to match CMS section macros
The hero and CTA section macros expect button.text.translations but
template JSONs used button.label.translations. Changed all 5 template
homepage files: label → text in button objects.

Also fixed existing CMS pages in DB (page 56) to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:51:19 +02:00
d591200df8 fix(cms): storefront renders sections for landing pages + fix nav URLs
Two critical fixes for POC site rendering:

1. Storefront content page route now selects template based on
   page.template field: 'full' → landing-full.html (section-based),
   'modern'/'minimal' → their respective templates. Default stays
   content-page.html (plain HTML). Previously always used content-page
   which ignores page.sections JSON.

2. Storefront base_url uses store.subdomain (lowercase, hyphens)
   instead of store.store_code (uppercase, underscores) for URL
   building. Nav links now point to correct paths that the store
   context middleware can resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:42:45 +02:00
83af32eb88 fix(hosting): POC builder works with existing sites
The Build POC button on site detail now passes site_id to the POC
builder, which populates the existing site's store with CMS content
instead of trying to create a new site (which failed with duplicate
slug error).

- poc_builder_service.build_poc() accepts optional site_id param
- If site_id given: uses existing site, skips hosted_site_service.create()
- If not given: creates new site (standalone POC build)
- API schema: added site_id to BuildPocRequest
- Frontend: passes this.site.id in the build request

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:10:39 +02:00
2a49e3d30f fix(hosting): fix Build POC button visibility
Same issue as Create Site button — bg-teal-600 not in Tailwind purge.
Switched to bg-purple-600 and removed $icon('sparkles') which may
not exist in the icon set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:57:55 +02:00
6e40e16017 fix(hosting): cascade soft-delete Store when deleting HostedSite
When deleting a hosted site, the associated Store is now soft-deleted
(sets deleted_at). This frees the subdomain for reuse via the partial
unique index (WHERE deleted_at IS NULL).

Previously the Store was orphaned, blocking subdomain reuse.
Closes docs/proposals/hosting-cascade-delete.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:49:40 +02:00
dd09bcaeec docs: add proposal for HostedSite → Store cascade delete
All checks were successful
CI / ruff (push) Successful in 33s
CI / pytest (push) Successful in 2h46m24s
CI / validate (push) Successful in 30s
CI / dependency-scanning (push) Successful in 31s
CI / docs (push) Successful in 49s
CI / deploy (push) Successful in 2m55s
Deleting a HostedSite leaves the Store orphaned, blocking subdomain
reuse. Proposal: cascade delete the Store when deleting the site.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:31:31 +02:00
013eafd775 fix(hosting): fix Create Site button visibility
Button was invisible — likely due to bg-teal-600 not being in the
Tailwind CSS purge or $icon rendering issue. Switched to bg-purple-600
(known to work) and simplified button content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:28:23 +02:00
07cd66a0e3 feat(hosting): add Build POC button with template selector on site detail
Draft sites with a linked prospect show a "Build POC from Template"
section with:
- Template dropdown (generic, restaurant, construction, auto-parts,
  professional-services) loaded from /admin/hosting/sites/templates API
- "Build POC" button that calls POST /admin/hosting/sites/poc/build
- Loading state + success message with pages created count
- Auto-refreshes site detail after build (status changes to POC_READY)

Visible only for draft sites with a prospect_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:24:19 +02:00
73d453d78a feat(hosting): prospect search dropdown on site creation form
- Replace raw ID inputs with a live search dropdown that queries
  /admin/prospecting/prospects?search= as you type
- Shows matching prospects with business name, domain, and ID
- Clicking a prospect auto-fills business name, email, phone
- Selected prospect shown as badge with clear button
- Optional merchant ID field for existing merchants
- Remove stale "Create from Prospect" link on sites list (was just
  a link to the prospects page)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:14:47 +02:00
d4e9fed719 fix(hosting): fix site creation form + add delete to sites list
Site creation form:
- Replace old "Create from Prospect" button (called removed endpoint)
  with inline prospect_id + merchant_id fields
- Schema requires at least one — form validates before submit
- Clean payload (strip nulls/empty strings before POST)

Sites list:
- Add trash icon delete button with confirmation dialog
- Calls DELETE /admin/hosting/sites/{id} (existing endpoint)
- Reloads list after successful deletion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:10:48 +02:00
3e93f64c6b fix(prospecting): clarify opportunity score UI
- Rename "Score Breakdown" → "Opportunity Score" with subtitle
  "Higher = more issues = better sales opportunity"
- "No issues detected" at 0 points shows green "✓ No issues found —
  low opportunity" instead of ambiguous gray text
- Explains why Technical Health 0/40 is actually good (no problems)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:05:59 +02:00
377d2d3ae8 feat(prospecting): add delete button to prospects list
- Trash icon button in Actions column with confirmation dialog
- Calls DELETE /admin/prospecting/prospects/{id} (existing endpoint)
- Reloads list after successful deletion
- Toast notification on success/failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:59:12 +02:00
b51f9e8e30 fix(hosting): smart slug generation with fallback chain
Slugify now handles both domains and business names gracefully:
- Domain: strip protocol/www/TLD → batirenovation-strasbourg
- Business name: take first 3 meaningful words, skip filler
  (le, la, du, des, the, and) → boulangerie-coin
- Cap at 30 chars

Clients without a domain get clean slugs from their business name
instead of the full title truncated mid-word.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:56:28 +02:00
d380437594 fix(hosting): site detail null guard + cleaner preview URLs
- Site detail template: x-show → x-if to prevent Alpine evaluating
  expressions when site is null during async loading
- Slugify: prefer domain_name over business_name for subdomain
  generation (batirenovation-strasbourg vs bati-rnovation-strasbourg-
  peinture-ravalement-dans). Cap at 30 chars. Strip protocol/TLD.
- POC builder passes domain_name for clean slugs
- Remove .lu/.fr/.com TLD from slugs automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:49:32 +02:00
cff0af31be feat(hosting): signed preview URLs for POC sites
Replace the standalone POC viewer (duplicate rendering) with signed
JWT preview tokens that bypass StorefrontAccessMiddleware:

Architecture:
1. Admin clicks Preview → route generates signed JWT
2. Redirects to /storefront/{subdomain}/homepage?_preview=token
3. Middleware validates token signature + expiry + store_id
4. Sets request.state.is_preview = True, skips subscription check
5. Full storefront renders with HostWizard preview banner injected

New files:
- app/core/preview_token.py: create_preview_token/verify_preview_token

Changes:
- middleware/storefront_access.py: preview token bypass before sub check
- storefront/base.html: preview banner injection via is_preview state
- hosting/routes/pages/public.py: redirect with signed token (was direct render)
- hosting/routes/api/admin_sites.py: GET /sites/{id}/preview-url endpoint

Removed:
- hosting/templates/hosting/public/poc-viewer.html (replaced by storefront)

Benefits: one rendering path, all section types work, shareable 24h links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:41:34 +02:00
e492e5f71c fix(hosting): render POC preview directly instead of iframe
The POC viewer was loading the storefront in an iframe, which hit the
StorefrontAccessMiddleware subscription check (POC sites don't have
subscriptions yet). Fixed by rendering CMS sections directly in the
preview template:
- Load ContentPages and StoreTheme from DB
- Render hero, features, testimonials, CTA sections inline
- Apply template colors/fonts via Tailwind CSS config
- HostWizard preview banner with nav links
- Footer with contact info
- No iframe, no subscription check needed

Also fixed Jinja2 dict.items collision (dict.items is the method,
not the 'items' key — use dict.get('items') instead).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:15:11 +02:00
9a5b7dd061 fix: register hosting public preview route + suppress SSL warnings
- Register hosting public page router in main.py (POC preview at
  /hosting/sites/{id}/preview was returning 404 because the
  public_page_router was set on module definition but never mounted)
- Suppress urllib3 InsecureRequestWarning in enrichment service
  (intentional verify=False for prospect site scanning)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:01:55 +02:00
b3051b423a feat(cms): add testimonials, gallery, contact_info section types (3D)
New section partials for hosting templates:
- _testimonials.html: customer review cards with star ratings, avatars
- _gallery.html: responsive image grid with hover captions
- _contact_info.html: phone/email/address cards with icons + hours

Updated renderers:
- Platform homepage-default.html: imports + renders new section types
- Storefront landing-full.html: added section-based rendering path
  that takes over when page.sections is set (POC builder pages),
  falls back to hardcoded HTML layout for non-section pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:54:15 +02:00
bc951a36d9 feat(hosting): implement POC builder service (Workstream 3C)
One-click POC site generation from prospect data + industry template:

PocBuilderService.build_poc():
1. Loads prospect (scraped content, contacts, business info)
2. Loads industry template (pages, theme, sections)
3. Creates HostedSite + Store via hosted_site_service
4. Populates CMS ContentPages from template, replacing {{placeholders}}
   (business_name, city, phone, email, address, meta_description,
   about_paragraph) with prospect data
5. Applies StoreTheme (colors, fonts, layout) from template
6. Auto-transitions to POC_READY status

API: POST /admin/hosting/sites/poc/build
Body: {prospect_id, template_id, merchant_id?}

Tested: prospect 1 (batirenovation-strasbourg.fr) + "construction"
template → 4 pages created, theme applied, subdomain assigned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:46:59 +02:00
2e043260eb feat(hosting): add industry template infrastructure (Workstream 3B)
5 industry templates as JSON presets, each with theme + multi-page content:
- generic: clean minimal (homepage, about, contact)
- restaurant: warm tones, Playfair Display (homepage, about, menu, contact)
- construction: amber/earth tones, Montserrat (homepage, services, projects, contact)
- auto-parts: red/bold, parts-focused (homepage, catalog, contact)
- professional-services: navy/blue, Merriweather (homepage, services, team, contact)

Each template has:
- meta.json (name, description, tags, languages)
- theme.json (colors, fonts, layout, header style)
- pages/*.json (section-based homepage + content pages with i18n)
- {{placeholder}} variables for prospect data injection

TemplateService loads from templates_library/ directory with caching.
GET /admin/hosting/sites/templates endpoint to list available templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:41:33 +02:00
1828ac85eb feat(prospecting): add content scraping for POC builder (Workstream 3A)
- New scrape_content() method in enrichment_service: extracts meta
  description, H1/H2 headings, paragraphs, images (filtered for size),
  social links, service items, and detected languages using BeautifulSoup
- Scans 6 pages per prospect: /, /about, /a-propos, /services,
  /nos-services, /contact
- Results stored as JSON in prospect.scraped_content_json
- New endpoints: POST /content-scrape/{id} and /content-scrape/batch
- Added to full_enrichment pipeline (Step 5, before security audit)
- CONTENT_SCRAPE job type for scan-jobs tracking
- "Content Scrape" batch button on scan-jobs page
- Add beautifulsoup4 to requirements.txt

Tested on batirenovation-strasbourg.fr: extracted 30 headings,
21 paragraphs, 13 images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:26:56 +02:00
50a4fc38a7 feat(prospecting): add batch delay + fix Celery error_message field
- Add PROSPECTING_BATCH_DELAY_SECONDS config (default 1.0s) — polite
  delay between prospects in batch scans to avoid rate limiting
- Apply delay to all 5 batch API endpoints and all Celery tasks
- Fix Celery tasks: error_message → error_log (matches model field)
- Add batch-scanning.md docs with rate limiting guide, scaling estimates
  for 70k+ URL imports, and pipeline order recommendations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:55:24 +02:00
30f3dae5a3 feat(prospecting): add security audit report generation (Workstream 2B)
- SecurityReportService generates standalone branded HTML reports from
  stored audit data (grade badge, simulated hacked site, detailed
  findings, business impact, call-to-action with contact info)
- GET /security-audit/report/{prospect_id} returns HTMLResponse
- "Generate Report" button on prospect detail security tab opens
  report in new browser tab (printable to PDF)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:41:40 +02:00
4c750f0268 feat(prospecting): implement security audit pipeline (Workstream 2A)
Complete security audit integration into the enrichment pipeline:

Backend:
- SecurityAuditService with 7 passive checks: HTTPS, SSL cert, security
  headers, exposed files, cookies, server info, technology detection
- Constants file with SECURITY_HEADERS, EXPOSED_PATHS, SEVERITY_SCORES
- SecurityAuditResponse schema with JSON field validators + aliases
- Endpoints: POST /security-audit/{id}, POST /security-audit/batch
- Added to full_enrichment pipeline (Step 5, before scoring)
- get_pending_security_audit() query in prospect_service

Frontend:
- Security tab on prospect detail page with grade badge (A+ to F),
  score/100, severity counts, HTTPS/SSL status, missing headers,
  exposed files, technologies, and full findings list
- "Run Security Audit" button with loading state
- "Security Audit" batch button on scan-jobs page

Tested on batirenovation-strasbourg.fr: Grade D (50/100), 11 issues
found (missing headers, exposed wp-login, server version disclosure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:58:11 +02:00
59b0d8977a fix(hosting): require merchant or prospect for site creation
- Schema: add merchant_id/prospect_id with model_validator requiring
  at least one. Remove from-prospect endpoint (unified into POST /sites)
- Service: rewrite create() — if merchant_id use it directly, if
  prospect_id auto-create merchant from prospect data. Remove system
  merchant hack entirely. Extract _create_merchant_from_prospect helper.
- Simplify accept_proposal() — merchant already exists at creation,
  only creates subscription and marks prospect converted
- Tests: update all create calls with merchant_id, replace from-prospect
  tests with prospect_id + validation tests

Closes docs/proposals/hosting-site-creation-fix.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:14:47 +02:00
2bc03ed97c docs: add end-to-end plan from prospecting to live site
Master plan covering 4 workstreams:
1. Fix hosting foundation (merchant/prospect required)
2. Security audit pipeline + report + live demo
3. POC builder with industry templates (restaurant, construction,
   auto-parts, professional-services, generic)
4. AI content enhancement (deferred, provider TBD)

Target: 10-step journey from prospect discovery to live website.
Steps 1-3 work today, steps 4-10 need the work described.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:05:21 +02:00
91963f3b87 docs: architecture decision — hosting sites reuse CMS + Store + StoreDomain
Hosted sites leverage existing CMS module (ContentPage, StoreTheme,
MediaFile) instead of building a separate site rendering system. Industry
templates (restaurant, construction, auto-parts, professional-services,
generic) are JSON presets that populate CMS entities for a new Store.

POC phase uses subdomain routing (acme.hostwizard.lu), go-live adds
custom domain via StoreDomain (acme.lu). All routing handled by existing
StoreContextMiddleware + Caddy wildcards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:42:10 +02:00
3ae0b579d3 docs: add security audit + demo + POC builder proposal
4-phase plan for integrating scripts/security-audit/ into the
prospecting module: security audit pipeline, report generation,
live demo server, and POC site builder architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:27:59 +02:00
972ee1e5d0 feat(prospecting): add ProspectSecurityAudit model (Phase 1 foundation)
- New model: ProspectSecurityAudit with score, grade, findings_json,
  severity counts, has_https, has_valid_ssl, missing_headers, exposed
  files, technologies, scan_error
- Add last_security_audit_at timestamp to Prospect model
- Add security_audit 1:1 relationship on Prospect

Part of Phase 1: Security Audit in Enrichment Pipeline. Service,
constants, migration, endpoints, and frontend to follow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:23:38 +02:00
70f2803dd3 fix(prospecting): handle PageSpeed API errors and improve performance card
- Detect API-level errors (quota exceeded, invalid URL) in response JSON
  and store in scan_error instead of silently writing zeros
- Show scan error message on the performance card when present
- Show "No performance data — configure PAGESPEED_API_KEY" when all
  scores are 0 and no error was recorded
- Add accessibility and best practices scores to performance card

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:41:37 +02:00
a247622d23 feat(tenancy): add delete button on table + add-to-store in edit modal
All checks were successful
CI / ruff (push) Successful in 13s
CI / pytest (push) Successful in 2h33m59s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 35s
CI / docs (push) Successful in 49s
CI / deploy (push) Successful in 1m13s
Table actions now show view + edit + delete (trash icon) for non-owner
members. Delete opens the existing remove-from-all-stores modal.

Edit modal enhanced with "Add to another store" section:
- Shows a dashed-border card with store dropdown + role dropdown + add button
- Only appears when the member is not yet in all merchant stores
- Uses the existing invite API to add the member to the selected store

i18n: 2 new keys (add_to_store, select_store) in 4 locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:36:42 +02:00
50d50fcbd0 feat(prospecting): show per-category score breakdown on prospect detail
- Restructure score_breakdown from flat dict to grouped by category:
  {technical_health: {flag: pts}, modernity: {...}, ...}
- Each category row shows score/max with progress bar + per-flag detail
  (e.g. Technical Health 15/40 → "very slow: 15 pts")
- Color-coded: green for positive flags, orange for issues
- "No issues detected" shown for clean categories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:35:06 +02:00
b306a5e8f4 fix(prospecting): inline isPositiveFlag check to avoid scope issue
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:27:58 +02:00
28b08580c8 feat(prospecting): improve prospect detail with score details and tech badge
- Score Breakdown: show point-by-point contributions from score_breakdown
  dict, sorted by value, color-coded green (positive) vs red (negative)
- Tech Profile: prominent CMS badge (WordPress, Shopify, etc.) with
  e-commerce platform tag, "Custom / Unknown CMS" fallback
- Add SSL issuer and expiry date to tech profile card

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:24:19 +02:00
754bfca87d fix(prospecting): fix contact scraper and add address extraction
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / ruff (push) Successful in 13s
CI / pytest (push) Has been cancelled
- Fix contact_type column: Enum(ContactType) → String(20) to match the
  migration (fixes "type contacttype does not exist" on insert)
- Rewrite scrape_contacts with structured-first approach:
  Phase 1: tel:/mailto: href extraction (high confidence)
  Phase 2: regex fallback with SVG/script stripping, international phone
           pattern (requires + prefix, min 10 digits)
  Phase 3: address extraction from Schema.org JSON-LD, <address> tags,
           and European street address regex (FR/DE/EN street keywords)
- URL-decode email values, strip tags to plain text for cross-element
  address matching
- Add /mentions-legales to scanned paths

Tested on batirenovation-strasbourg.fr: finds 3 contacts (email, phone,
address) vs 120+ false positives and a crash before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:18:43 +02:00
1decb4572c fix(tenancy): show role dropdown for pending store memberships too
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
The role dropdown was hidden for pending stores (x-show="!store.is_pending").
Pending members already have an assigned role that should be changeable
before acceptance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:16:52 +02:00
d685341b04 refactor(tenancy): simplify team table + move actions to edit modal
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Reverts the expandable sub-row design back to a clean one-row-per-member
table. All per-store management now happens inside the edit modal.

Table: simple 4-column layout (Member | Stores & Roles | Status | Actions)
with view + edit buttons. Store badges show orange for pending stores.

Edit modal enhanced with per-store cards showing:
- Store name, code, and status badge (Active/Pending)
- Role dropdown + Update button (for active stores)
- Resend invitation button (for pending stores)
- Remove from store button
- "Remove from all stores" link at bottom

Removed: expandedMembers, flattenedRows, toggleMemberExpand,
resendStoreInvitation, resendInvitation (member-level).
Added: resendForStore, removeFromStore (work inside edit modal).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:08:36 +02:00
0c6d8409c7 fix(tenancy): fix table column alignment with flattened row approach
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
The nested tbody approach caused browsers to collapse all cells into
one column. Replaced with a single flat x-for loop over flattenedRows
(computed property that interleaves member rows and store sub-rows).

Each row is a single <tr> with 4 proper <td> cells, using x-if to
conditionally render member-level or store-level content per column.
Sub-rows are hidden/shown via expandedMembers array.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:27:47 +02:00
f81851445e fix(tenancy): align columns and actions in merchant team table
All checks were successful
CI / ruff (push) Successful in 15s
CI / pytest (push) Successful in 2h41m40s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Successful in 48s
CI / deploy (push) Successful in 1m5s
- Fixed header/column alignment: Member | Role | Status | Actions
- Store count + chevron moved inline with member name (not a separate column)
- Role column shows single role, "Owner", or "Multiple roles" on main row
- Actions use fixed 4-slot grid (resend | view | edit | remove) ensuring
  icons always align vertically between main rows and sub-rows
- Empty slots render as blank space to maintain alignment

i18n: added multiple_roles key in 4 locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:39:31 +02:00
4748368809 feat(tenancy): expandable per-store rows in merchant team table
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Member rows now show a store count with expand/collapse chevron.
Clicking expands sub-rows showing each store with:
- Store name and code
- Per-store role badge
- Per-store status (active/pending independently)
- Per-store actions: resend invitation (pending), remove from store

This fixes the issue where a member active on one store but pending
on another showed misleading combined status and actions.

Member-level actions (view, edit profile) stay on the main row.
Store-level actions (resend, remove) are on each sub-row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:32:47 +02:00
f310363f7c fix(prospecting): fix scan-jobs batch endpoints and add job tracking
- Reorder routes: batch endpoints before /{prospect_id} to fix FastAPI
  route matching (was parsing "batch" as prospect_id → 422)
- Add scan job tracking via stats_service.create_job/complete_job so
  the scan-jobs table gets populated after each batch run
- Add contact scrape batch endpoint (POST /contacts/batch) with
  get_pending_contact_scrape query
- Fix scan-jobs.js: explicit route map instead of naive replace
- Normalize domain_name on create/update (strip protocol, www, slash)
- Add domain_name to ProspectUpdate schema
- Add proposal for contact scraper enum + regex fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:31:33 +02:00
95f0eac079 fix(tenancy): prioritize active over pending in member status display
Some checks failed
CI / ruff (push) Successful in 14s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / deploy (push) Has been cancelled
getMemberStatus() showed "pending" if ANY store had a pending invitation,
even if the member was already active in another store. Now checks for
active stores first — a member who is active in at least one store
shows as "active", not "pending".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:24:16 +02:00
11dcfdad73 feat(tenancy): add team invitation acceptance page
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
New standalone page at /store/{store_code}/invitation/accept?token=xxx
where invited team members can:
- Review their name and email (pre-filled from invitation)
- Set their password
- Accept the invitation

Page handles all routing modes (dev path, platform path, prod subdomain,
custom domain) via store context middleware. After acceptance, redirects
to the platform-aware store login page.

New service method get_invitation_info() validates the token and returns
invitation details without modifying anything.

Error states: expired token, already accepted, invalid token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:05:23 +02:00
01f7add8dd test(tenancy): add integration tests for resend invitation
Some checks failed
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
2 new tests in TestResendInvitation:
- test_resend_invitation_for_pending_member: verifies token regeneration
  and invitation_sent_at update
- test_resend_invitation_nonexistent_user: verifies 404

Total: 17 store team member integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:47:10 +02:00
0d1007282a feat(config): add APP_BASE_URL setting for outbound link construction
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Adds app_base_url config (default http://localhost:8000) used for all
outbound URLs: invitation emails, billing checkout redirects, signup
login links, portal return URLs.

Replaces hardcoded https://{main_domain} and localhost:8000 patterns.
Configurable per environment via APP_BASE_URL env var:
- Dev: http://localhost:8000 (or http://acme.localhost:9999)
- Prod: https://wizard.lu

main_domain is preserved for subdomain resolution and cookie config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:43:36 +02:00
2a15c14ee8 fix(tenancy): use is_production() for invitation URL instead of debug flag
All checks were successful
CI / ruff (push) Successful in 15s
CI / pytest (push) Successful in 2h52m50s
CI / validate (push) Successful in 28s
CI / dependency-scanning (push) Successful in 34s
CI / docs (push) Successful in 51s
CI / deploy (push) Successful in 1m19s
Using debug flag for environment detection is unreliable — if left
True in prod, links would point to localhost. Now uses the proper
is_production() from environment module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:49:31 +02:00
bc5e227d81 fix(tenancy): use correct base URL for invitation link in dev vs prod
Some checks failed
CI / ruff (push) Successful in 15s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
Dev (debug=True): http://localhost:8000/store/{store_code}/invitation/...
Prod: https://{subdomain}.{main_domain}/invitation/...

Previously used main_domain directly which pointed to the prod domain
even in dev environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:44:02 +02:00
8a70259445 fix(tenancy): use absolute URL in team invitation email link
Some checks failed
CI / ruff (push) Successful in 15s
CI / pytest (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
Email clients need absolute URLs to make links clickable. The
acceptance_link was a relative path (/store/invitation/accept?token=...)
which rendered as plain text. Now prepends the platform domain with
the correct protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:39:46 +02:00
823935c016 feat(tenancy): add resend invitation for pending team members
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 14s
New resend_invitation() service method regenerates the token and
resends the invitation email for pending members.

Available on all frontends:
- Merchant: POST /merchants/account/team/stores/{sid}/members/{uid}/resend
- Store: POST /store/team/members/{uid}/resend

UI: paper-airplane icon appears on pending members in both merchant
and store team pages.

i18n: resend_invitation + invitation_resent keys in 4 locales.
Also translated previously untranslated invitation_sent_successfully
in fr/de/lb.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:48:12 +02:00
dab5560de8 fix(tenancy): use x-if instead of x-show for edit modal
Some checks failed
CI / ruff (push) Successful in 13s
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / pytest (push) Has been cancelled
x-model bindings crash when selectedMember is null because x-show
keeps DOM elements alive. x-if removes them entirely, preventing
the "can't access property of null" errors on page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:12:53 +02:00
157b4c6ec3 feat(tenancy): add profile editing in merchant team edit modal
Some checks failed
CI / ruff (push) Successful in 16s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled
Edit modal now has editable first_name, last_name, email fields with
a "Save Profile" button, alongside the existing per-store role management.

New:
- PUT /merchants/account/team/members/{user_id}/profile endpoint
- MerchantTeamProfileUpdate schema
- update_team_member_profile() service method with ownership validation
- 2 new i18n keys across 4 locales (personal_info, save_profile)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:31:23 +02:00
211c46ebbc feat(tenancy): add member detail modal + fix invite name saving
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Merchant team page:
- Consistent member display (full_name + email on every row)
- New view button (eye icon) on all members including owner
- View modal shows account info (username, role, email verified,
  last login, account created) and store memberships with roles
- API enriched with user metadata (username, role, is_email_verified,
  last_login, created_at)

Invite fix (both merchant and store routes):
- first_name and last_name from invite form were never passed to the
  service that creates the User account. Now passed through correctly.

i18n: 6 new keys across 4 locales (view_member, account_information,
username, email_verified, last_login, account_created).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:23:20 +02:00
d81e9a3fa4 fix(tests): fix 7 pre-existing test failures
Some checks failed
CI / ruff (push) Successful in 14s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
Menu tests (6): Tests expected merchant menu item id "loyalty-program"
but the actual definition in loyalty/definition.py uses "program".
Updated assertions to match the actual menu item IDs.

Wallet test (1): test_enrollment_succeeds_without_wallet_config didn't
mock the Google Wallet config, so is_configured returned True when
GOOGLE_ISSUER_ID is set in .env. Added @patch to mock config as
unconfigured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:34:26 +02:00
fd0de714a4 fix(loyalty): update delete tests for soft-delete behavior
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
Delete program tests now verify soft-delete (deleted_at set, record
hidden from normal queries) instead of expecting hard deletion.
Uses db.query() instead of db.get() since the soft-delete filter
only applies to ORM queries, not identity map lookups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:28:27 +02:00
c6b155520c docs: add security hardening plan from 360 audit
18 prioritized findings (6 HIGH, 6 MEDIUM, 6 LOW) filtered against
what is already deployed on Hetzner. Covers app-layer issues like
login rate limiting, GraphQL injection, SSRF, GDPR/Sentry PII,
dependency pinning, and CSP headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:17:48 +01:00
249 changed files with 16358 additions and 4718 deletions

View File

@@ -72,6 +72,11 @@ LOG_FILE=logs/app.log
# Your main platform domain
MAIN_DOMAIN=wizard.lu
# Full base URL for outbound links (emails, billing redirects, etc.)
# Must include protocol and port if non-standard
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
APP_BASE_URL=http://localhost:8000
# Custom domain features
# Enable/disable custom domains
ALLOW_CUSTOM_DOMAINS=True
@@ -223,7 +228,10 @@ R2_BACKUP_BUCKET=orion-backups
# See docs/deployment/hetzner-server-setup.md Step 25 for setup guide
# Get Issuer ID from https://pay.google.com/business/console
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
# Production convention: ~/apps/orion/google-wallet-sa.json (app user, mode 600).
# Path is validated at startup — file must exist and be readable, otherwise
# the app fails fast at import time.
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=~/apps/orion/google-wallet-sa.json
# LOYALTY_GOOGLE_WALLET_ORIGINS=["https://yourdomain.com"]
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png

View File

@@ -1744,3 +1744,39 @@ def get_current_customer_optional(
except Exception:
# Invalid token, store mismatch, or other error
return None
# =============================================================================
# STOREFRONT MODULE GATING
# =============================================================================
def make_storefront_module_gate(module_code: str):
"""
Create a FastAPI dependency that gates storefront routes by module enablement.
Used by main.py at route registration time: each non-core module's storefront
router gets this dependency injected automatically. The framework already knows
which module owns each route via RouteInfo.module_code — no hardcoded path map.
Args:
module_code: The module code to check (e.g. "catalog", "orders", "loyalty")
Returns:
A FastAPI dependency function
"""
async def _check_module_enabled(
request: Request,
db: Session = Depends(get_db),
) -> None:
from app.modules.service import module_service
platform = getattr(request.state, "platform", None)
if not platform:
return # No platform context — let other middleware handle it
if not module_service.is_module_enabled(db, platform.id, module_code):
raise HTTPException(status_code=404, detail="Page not found")
return _check_module_enabled

View File

@@ -98,6 +98,11 @@ class Settings(BaseSettings):
# =============================================================================
main_domain: str = "wizard.lu"
# Full base URL for outbound links (emails, redirects, etc.)
# Must include protocol and port if non-standard.
# Examples: http://localhost:8000, http://acme.localhost:9999, https://wizard.lu
app_base_url: str = "http://localhost:8000"
# Custom domain features
allow_custom_domains: bool = True
require_domain_verification: bool = True

54
app/core/preview_token.py Normal file
View File

@@ -0,0 +1,54 @@
# app/core/preview_token.py
"""
Signed preview tokens for POC site previews.
Generates time-limited JWT tokens that allow viewing storefront pages
for stores without active subscriptions (POC sites). The token is
validated by StorefrontAccessMiddleware to bypass the subscription gate.
"""
import logging
from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt
from app.core.config import settings
logger = logging.getLogger(__name__)
PREVIEW_TOKEN_HOURS = 24
ALGORITHM = "HS256"
def create_preview_token(store_id: int, store_code: str, site_id: int) -> str:
"""Create a signed preview token for a POC site.
Token is valid for PREVIEW_TOKEN_HOURS (default 24h) and is tied
to a specific store_id. Shareable with clients for preview access.
"""
payload = {
"sub": f"preview:{store_id}",
"store_id": store_id,
"store_code": store_code,
"site_id": site_id,
"preview": True,
"exp": datetime.now(UTC) + timedelta(hours=PREVIEW_TOKEN_HOURS),
"iat": datetime.now(UTC),
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=ALGORITHM)
def verify_preview_token(token: str, store_id: int) -> bool:
"""Verify a preview token is valid and matches the store.
Returns True if:
- Token signature is valid
- Token has not expired
- Token has preview=True claim
- Token store_id matches the requested store
"""
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[ALGORITHM])
return payload.get("preview") is True and payload.get("store_id") == store_id
except JWTError:
return False

View File

@@ -143,7 +143,7 @@
</div>
<div class="flex items-center">
<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('location-marker', 'w-6 h-6')"></span>
<span x-html="$icon('map-pin', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="formatNumber(analytics.inventory?.total_locations || 0)"></p>

View File

@@ -95,6 +95,7 @@ class MenuItemDefinition:
requires_permission: str | None = None
badge_source: str | None = None
is_super_admin_only: bool = False
header_template: str | None = None # Optional partial for custom header rendering
@dataclass

View File

@@ -144,7 +144,7 @@ def purchase_addon(
store = billing_service.get_store(db, store_id)
# Build URLs
base_url = f"https://{settings.main_domain}"
base_url = settings.app_base_url.rstrip("/")
success_url = f"{base_url}/store/{store.store_code}/billing?addon_success=true"
cancel_url = f"{base_url}/store/{store.store_code}/billing?addon_cancelled=true"

View File

@@ -59,7 +59,7 @@ def create_checkout_session(
store_code = subscription_service.get_store_code(db, store_id)
base_url = f"https://{settings.main_domain}"
base_url = settings.app_base_url.rstrip("/")
success_url = f"{base_url}/store/{store_code}/billing?success=true"
cancel_url = f"{base_url}/store/{store_code}/billing?cancelled=true"
@@ -87,7 +87,7 @@ def create_portal_session(
merchant_id, platform_id = subscription_service.resolve_store_to_merchant(db, store_id, current_user.token_platform_id)
store_code = subscription_service.get_store_code(db, store_id)
return_url = f"https://{settings.main_domain}/store/{store_code}/billing"
return_url = f"{settings.app_base_url.rstrip('/')}/store/{store_code}/billing"
result = billing_service.create_portal_session(db, merchant_id, platform_id, return_url)

View File

@@ -617,7 +617,7 @@ class SignupService:
# Build login URL
login_url = (
f"https://{settings.main_domain}"
f"{settings.app_base_url.rstrip('/')}"
f"/store/{store.store_code}/dashboard"
)

View File

@@ -68,10 +68,11 @@ cart_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="cart",
label_key="storefront.actions.cart",
label_key="cart.storefront.actions.cart",
icon="shopping-cart",
route="cart",
order=20,
header_template="cart/storefront/partials/header-cart.html",
),
],
),

View File

@@ -44,5 +44,10 @@
"view_desc": "Warenkörbe der Kunden anzeigen",
"manage": "Warenkörbe verwalten",
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
},
"storefront": {
"actions": {
"cart": "Warenkorb"
}
}
}

View File

@@ -1,48 +1,53 @@
{
"title": "Shopping Cart",
"description": "Shopping cart management for customers",
"cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"empty_subtitle": "Add items to start shopping",
"continue_shopping": "Continue Shopping",
"proceed_to_checkout": "Proceed to Checkout"
},
"item": {
"product": "Product",
"quantity": "Quantity",
"price": "Price",
"total": "Total",
"remove": "Remove",
"update": "Update"
},
"summary": {
"title": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"estimated_shipping": "Calculated at checkout",
"tax": "Tax",
"total": "Total"
},
"validation": {
"invalid_quantity": "Invalid quantity",
"min_quantity": "Minimum quantity is {min}",
"max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available"
},
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",
"item_removed": "Item removed from cart",
"cart_cleared": "Cart cleared",
"product_not_available": "Product not available",
"error_adding": "Error adding item to cart",
"error_updating": "Error updating cart"
}
"title": "Shopping Cart",
"description": "Shopping cart management for customers",
"cart": {
"title": "Your Cart",
"empty": "Your cart is empty",
"empty_subtitle": "Add items to start shopping",
"continue_shopping": "Continue Shopping",
"proceed_to_checkout": "Proceed to Checkout"
},
"item": {
"product": "Product",
"quantity": "Quantity",
"price": "Price",
"total": "Total",
"remove": "Remove",
"update": "Update"
},
"summary": {
"title": "Order Summary",
"subtotal": "Subtotal",
"shipping": "Shipping",
"estimated_shipping": "Calculated at checkout",
"tax": "Tax",
"total": "Total"
},
"validation": {
"invalid_quantity": "Invalid quantity",
"min_quantity": "Minimum quantity is {min}",
"max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available"
},
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",
"item_removed": "Item removed from cart",
"cart_cleared": "Cart cleared",
"product_not_available": "Product not available",
"error_adding": "Error adding item to cart",
"error_updating": "Error updating cart"
},
"storefront": {
"actions": {
"cart": "Cart"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Voir les paniers des clients",
"manage": "Gérer les paniers",
"manage_desc": "Modifier et gérer les paniers des clients"
},
"storefront": {
"actions": {
"cart": "Panier"
}
}
}

View File

@@ -44,5 +44,10 @@
"view_desc": "Clientekuerf kucken",
"manage": "Kuerf verwalten",
"manage_desc": "Clientekuerf änneren a verwalten"
},
"storefront": {
"actions": {
"cart": "Kuerf"
}
}
}

View File

@@ -0,0 +1,10 @@
{# cart/storefront/partials/header-cart.html #}
{# Cart icon with badge for storefront header — provided by cart module #}
<a href="{{ base_url }}cart" class="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-5 h-5" x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
<span x-show="cartCount > 0"
x-text="cartCount"
class="absolute -top-1 -right-1 bg-accent text-white text-xs rounded-full h-5 w-5 flex items-center justify-center"
style="background-color: var(--color-accent)">
</span>
</a>

View File

@@ -134,7 +134,7 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="products",
label_key="storefront.nav.products",
label_key="catalog.storefront.nav.products",
icon="shopping-bag",
route="products",
order=10,
@@ -148,10 +148,11 @@ catalog_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="search",
label_key="storefront.actions.search",
label_key="catalog.storefront.actions.search",
icon="search",
route="",
order=10,
header_template="catalog/storefront/partials/header-search.html",
),
],
),

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport von Produkten",
"products_export": "Produkte exportieren",
"products_export_desc": "Produktdaten exportieren"
},
"storefront": {
"nav": {
"products": "Produkte"
},
"actions": {
"search": "Suchen"
}
}
}

View File

@@ -107,5 +107,13 @@
"menu": {
"products_inventory": "Products & Inventory",
"all_products": "All Products"
},
"storefront": {
"nav": {
"products": "Products"
},
"actions": {
"search": "Search"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Importation en masse de produits",
"products_export": "Exporter les produits",
"products_export_desc": "Exporter les données produits"
},
"storefront": {
"nav": {
"products": "Produits"
},
"actions": {
"search": "Rechercher"
}
}
}

View File

@@ -89,5 +89,13 @@
"products_import_desc": "Massenimport vu Produiten",
"products_export": "Produiten exportéieren",
"products_export_desc": "Produitdaten exportéieren"
},
"storefront": {
"nav": {
"products": "Produkter"
},
"actions": {
"search": "Sichen"
}
}
}

View File

@@ -30,11 +30,10 @@ router = APIRouter()
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/products", response_class=HTMLResponse, include_in_schema=False)
async def shop_products_page(request: Request, db: Session = Depends(get_db)):
"""
Render shop homepage / product catalog.
Render product catalog listing.
Shows featured products and categories.
"""
logger.debug(

View File

@@ -0,0 +1,5 @@
{# catalog/storefront/partials/header-search.html #}
{# Search button for storefront header — provided by catalog module #}
<button @click="openSearch()" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="w-5 h-5" x-html="$icon('search', 'w-5 h-5')"></span>
</button>

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sind Sie sicher, dass Sie diese Datei löschen möchten? Dies kann nicht rückgängig gemacht werden."
},
"storefront": {
"my_account": "Mein Konto",
"learn_more": "Mehr erfahren",
"explore": "Entdecken",
"quick_links": "Schnellzugriff",
"information": "Informationen",
"about": "Über uns",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
"content_pages": "Content Pages",
"store_themes": "Store Themes",
"media_library": "Media Library"
},
"storefront": {
"my_account": "My Account",
"learn_more": "Learn More",
"explore": "Explore",
"quick_links": "Quick Links",
"information": "Information",
"about": "About Us",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Êtes-vous sûr de vouloir supprimer ce fichier ? Cette action est irréversible."
},
"storefront": {
"my_account": "Mon Compte",
"learn_more": "En savoir plus",
"explore": "Découvrir",
"quick_links": "Liens rapides",
"information": "Informations",
"about": "À propos",
"contact": "Contact",
"faq": "FAQ"
}
}

View File

@@ -388,5 +388,15 @@
},
"confirmations": {
"delete_file": "Sidd Dir sécher datt Dir dëse Fichier läsche wëllt? Dat kann net réckgängeg gemaach ginn."
},
"storefront": {
"my_account": "Mäi Kont",
"learn_more": "Méi gewuer ginn",
"explore": "Entdecken",
"quick_links": "Schnellzougrëff",
"information": "Informatiounen",
"about": "Iwwer eis",
"contact": "Kontakt",
"faq": "FAQ"
}
}

View File

@@ -0,0 +1,36 @@
"""add meta_description_translations and drop meta_keywords from content_pages
Revision ID: cms_003
Revises: cms_002
Create Date: 2026-04-15
"""
import sqlalchemy as sa
from alembic import op
revision = "cms_003"
down_revision = "cms_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"content_pages",
sa.Column(
"meta_description_translations",
sa.JSON(),
nullable=True,
comment="Language-keyed meta description dict for multi-language SEO",
),
)
op.drop_column("content_pages", "meta_keywords")
def downgrade() -> None:
op.add_column(
"content_pages",
sa.Column("meta_keywords", sa.String(300), nullable=True),
)
op.drop_column("content_pages", "meta_description_translations")

View File

@@ -135,7 +135,12 @@ class ContentPage(Base):
# SEO
meta_description = Column(String(300), nullable=True)
meta_keywords = Column(String(300), nullable=True)
meta_description_translations = Column(
JSON,
nullable=True,
default=None,
comment="Language-keyed meta description dict for multi-language SEO",
)
# Publishing
is_published = Column(Boolean, default=False, nullable=False)
@@ -230,6 +235,16 @@ class ContentPage(Base):
)
return self.content
def get_translated_meta_description(self, lang: str, default_lang: str = "fr") -> str:
"""Get meta description in the given language, falling back to default_lang then self.meta_description."""
if self.meta_description_translations:
return (
self.meta_description_translations.get(lang)
or self.meta_description_translations.get(default_lang)
or self.meta_description or ""
)
return self.meta_description or ""
def to_dict(self):
"""Convert to dictionary for API responses."""
return {
@@ -248,7 +263,7 @@ class ContentPage(Base):
"template": self.template,
"sections": self.sections,
"meta_description": self.meta_description,
"meta_keywords": self.meta_keywords,
"meta_description_translations": self.meta_description_translations,
"is_published": self.is_published,
"published_at": (
self.published_at.isoformat() if self.published_at else None

View File

@@ -73,7 +73,7 @@ def create_platform_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -117,7 +117,7 @@ def create_store_page(
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -177,11 +177,13 @@ def update_page(
db,
page_id=page_id,
title=page_data.title,
title_translations=page_data.title_translations,
content=page_data.content,
content_translations=page_data.content_translations,
content_format=page_data.content_format,
template=page_data.template,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=page_data.meta_description_translations,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -207,7 +207,7 @@ def create_store_page(
store_id=current_user.token_store_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
@@ -241,7 +241,7 @@ def update_store_page(
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
meta_description_translations=getattr(page_data, "meta_description_translations", None),
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,

View File

@@ -28,6 +28,79 @@ ROUTE_CONFIG = {
}
# ============================================================================
# STOREFRONT HOMEPAGE
# ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def storefront_homepage(
request: Request,
db: Session = Depends(get_db),
):
"""
Storefront homepage handler.
Looks for a CMS page with slug="home" (store override → store default),
and renders the appropriate landing template. Falls back to the default
landing template when no CMS homepage exists.
"""
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
store_id = store.id if store else None
if not platform:
raise HTTPException(status_code=400, detail="Platform context required")
# Try to load a homepage from CMS (store override → store default)
page = content_page_service.get_page_for_store(
db,
platform_id=platform.id,
slug="home",
store_id=store_id,
include_unpublished=False,
)
# Resolve placeholders for store default pages (title, content, sections)
page_content = None
page_title = None
page_sections = None
if page:
page_content = page.content
page_title = page.title
page_sections = page.sections
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(
page.content, store
)
page_title = content_page_service.resolve_placeholders(
page.title, store
)
if page_sections:
page_sections = content_page_service.resolve_placeholders_deep(
page_sections, store
)
context = get_storefront_context(request, db=db, page=page)
if page_content:
context["page_content"] = page_content
if page_title:
context["page_title"] = page_title
if page_sections:
context["page_sections"] = page_sections
# Select template based on page.template field (or default)
template_map = {
"full": "cms/storefront/landing-full.html",
"modern": "cms/storefront/landing-modern.html",
"minimal": "cms/storefront/landing-minimal.html",
}
template_name = "cms/storefront/landing-default.html"
if page and page.template:
template_name = template_map.get(page.template, template_name)
return templates.TemplateResponse(template_name, context)
# ============================================================================
# DYNAMIC CONTENT PAGES (CMS)
# ============================================================================
@@ -103,14 +176,25 @@ async def generic_content_page(
# Resolve placeholders in store default pages ({{store_name}}, etc.)
page_content = page.content
page_title = page.title
if page.is_store_default and store:
page_content = content_page_service.resolve_placeholders(page.content, store)
page_title = content_page_service.resolve_placeholders(page.title, store)
context = get_storefront_context(request, db=db, page=page)
context["page_title"] = page_title
context["page_content"] = page_content
# Select template based on page.template field
template_map = {
"full": "cms/storefront/landing-full.html",
"modern": "cms/storefront/landing-modern.html",
"minimal": "cms/storefront/landing-minimal.html",
}
template_name = template_map.get(page.template, "cms/storefront/content-page.html")
return templates.TemplateResponse(
"cms/storefront/content-page.html",
template_name,
context,
)

View File

@@ -24,19 +24,27 @@ class ContentPageCreate(BaseModel):
description="URL-safe identifier (about, faq, contact, etc.)",
)
title: str = Field(..., max_length=200, description="Page title")
title_translations: dict[str, str] | None = Field(
None, description="Title translations keyed by language code"
)
content: str = Field(..., description="HTML or Markdown content")
content_translations: dict[str, str] | None = Field(
None, description="Content translations keyed by language code"
)
content_format: str = Field(
default="html", description="Content format: html or markdown"
)
template: str = Field(
default="default",
max_length=50,
description="Template name (default, minimal, modern)",
description="Template name (default, minimal, modern, full)",
)
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
meta_description_translations: dict[str, str] | None = Field(
None, description="Meta description translations keyed by language code"
)
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -53,11 +61,13 @@ class ContentPageUpdate(BaseModel):
"""Schema for updating a content page (admin)."""
title: str | None = Field(None, max_length=200)
title_translations: dict[str, str] | None = None
content: str | None = None
content_translations: dict[str, str] | None = None
content_format: str | None = None
template: str | None = Field(None, max_length=50)
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
meta_description_translations: dict[str, str] | None = None
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -78,11 +88,13 @@ class ContentPageResponse(BaseModel):
store_name: str | None
slug: str
title: str
title_translations: dict[str, str] | None = None
content: str
content_translations: dict[str, str] | None = None
content_format: str
template: str | None = None
meta_description: str | None
meta_keywords: str | None
meta_description_translations: dict[str, str] | None = None
is_published: bool
published_at: str | None
display_order: int
@@ -135,7 +147,6 @@ class StoreContentPageCreate(BaseModel):
meta_description: str | None = Field(
None, max_length=300, description="SEO meta description"
)
meta_keywords: str | None = Field(None, max_length=300, description="SEO keywords")
is_published: bool = Field(default=False, description="Publish immediately")
show_in_footer: bool = Field(default=True, description="Show in footer navigation")
show_in_header: bool = Field(default=False, description="Show in header navigation")
@@ -152,7 +163,6 @@ class StoreContentPageUpdate(BaseModel):
content: str | None = None
content_format: str | None = None
meta_description: str | None = Field(None, max_length=300)
meta_keywords: str | None = Field(None, max_length=300)
is_published: bool | None = None
show_in_footer: bool | None = None
show_in_header: bool | None = None
@@ -187,7 +197,6 @@ class PublicContentPageResponse(BaseModel):
content: str
content_format: str
meta_description: str | None
meta_keywords: str | None
published_at: str | None

View File

@@ -24,6 +24,7 @@ Lookup Strategy for Store Storefronts:
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_
from sqlalchemy.orm import Session
@@ -472,7 +473,7 @@ class ContentPageService:
content_format: str = "html",
template: str = "default",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -494,7 +495,7 @@ class ContentPageService:
content_format: "html" or "markdown"
template: Template name for landing pages
meta_description: SEO description
meta_keywords: SEO keywords
meta_description_translations: Meta description translations dict
is_published: Publish immediately
show_in_footer: Show in footer navigation
show_in_header: Show in header navigation
@@ -515,7 +516,7 @@ class ContentPageService:
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
published_at=datetime.now(UTC) if is_published else None,
show_in_footer=show_in_footer,
@@ -541,11 +542,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -573,16 +576,20 @@ class ContentPageService:
# Update fields if provided
if title is not None:
page.title = title
if title_translations is not None:
page.title_translations = title_translations
if content is not None:
page.content = content
if content_translations is not None:
page.content_translations = content_translations
if content_format is not None:
page.content_format = content_format
if template is not None:
page.template = template
if meta_description is not None:
page.meta_description = meta_description
if meta_keywords is not None:
page.meta_keywords = meta_keywords
if meta_description_translations is not None:
page.meta_description_translations = meta_description_translations
if is_published is not None:
page.is_published = is_published
if is_published and not page.published_at:
@@ -698,7 +705,7 @@ class ContentPageService:
content: str | None = None,
content_format: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -725,7 +732,7 @@ class ContentPageService:
content=content,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -760,7 +767,7 @@ class ContentPageService:
content: str,
content_format: str = "html",
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool = False,
show_in_footer: bool = True,
show_in_header: bool = False,
@@ -791,7 +798,7 @@ class ContentPageService:
is_platform_page=False,
content_format=content_format,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -913,11 +920,13 @@ class ContentPageService:
db: Session,
page_id: int,
title: str | None = None,
title_translations: dict[str, str] | None = None,
content: str | None = None,
content_translations: dict[str, str] | None = None,
content_format: str | None = None,
template: str | None = None,
meta_description: str | None = None,
meta_keywords: str | None = None,
meta_description_translations: str | None = None,
is_published: bool | None = None,
show_in_footer: bool | None = None,
show_in_header: bool | None = None,
@@ -935,11 +944,13 @@ class ContentPageService:
db,
page_id=page_id,
title=title,
title_translations=title_translations,
content=content,
content_translations=content_translations,
content_format=content_format,
template=template,
meta_description=meta_description,
meta_keywords=meta_keywords,
meta_description_translations=meta_description_translations,
is_published=is_published,
show_in_footer=show_in_footer,
show_in_header=show_in_header,
@@ -991,6 +1002,28 @@ class ContentPageService:
content = content.replace(placeholder, value)
return content
@staticmethod
def resolve_placeholders_deep(data, store) -> Any:
"""
Recursively resolve {{store_name}} etc. in a nested data structure
(dicts, lists, strings). Used for sections JSON in store default pages.
"""
if not data or not store:
return data
if isinstance(data, str):
return ContentPageService.resolve_placeholders(data, store)
if isinstance(data, dict):
return {
k: ContentPageService.resolve_placeholders_deep(v, store)
for k, v in data.items()
}
if isinstance(data, list):
return [
ContentPageService.resolve_placeholders_deep(item, store)
for item in data
]
return data
# =========================================================================
# Homepage Sections Management
# =========================================================================

View File

@@ -70,7 +70,7 @@ class StoreThemeService:
"""
from app.modules.tenancy.services.store_service import store_service
store = store_service.get_store_by_code(db, store_code)
store = store_service.get_store_by_code_or_subdomain(db, store_code)
if not store:
self.logger.warning(f"Store not found: {store_code}")

View File

@@ -20,11 +20,13 @@ function contentPageEditor(pageId) {
form: {
slug: '',
title: '',
title_translations: {},
content: '',
content_translations: {},
content_format: 'html',
template: 'default',
meta_description: '',
meta_keywords: '',
meta_description_translations: {},
is_published: false,
show_in_header: false,
show_in_footer: true,
@@ -42,6 +44,12 @@ function contentPageEditor(pageId) {
error: null,
successMessage: null,
// Page type: 'content' or 'landing'
pageType: 'content',
// Translation language for title/content
titleContentLang: 'fr',
// ========================================
// HOMEPAGE SECTIONS STATE
// ========================================
@@ -56,6 +64,13 @@ function contentPageEditor(pageId) {
de: 'Deutsch',
lb: 'Lëtzebuergesch'
},
// Template-driven section palette
sectionPalette: {
'default': ['hero', 'features', 'products', 'pricing', 'testimonials', 'gallery', 'contact_info', 'cta'],
'full': ['hero', 'features', 'testimonials', 'gallery', 'contact_info', 'cta'],
},
sections: {
hero: {
enabled: true,
@@ -108,8 +123,8 @@ function contentPageEditor(pageId) {
await this.loadPage();
contentPageEditLog.groupEnd();
// Load sections if this is a homepage
if (this.form.slug === 'home') {
// Load sections if this is a landing page
if (this.pageType === 'landing') {
await this.loadSections();
}
} else {
@@ -120,14 +135,86 @@ function contentPageEditor(pageId) {
contentPageEditLog.info('=== CONTENT PAGE EDITOR INITIALIZATION COMPLETE ===');
},
// Check if we should show section editor (property, not getter for Alpine compatibility)
// Check if we should show section editor
isHomepage: false,
// Update isHomepage when slug changes
// Is a section available for the current template?
isSectionAvailable(sectionName) {
const palette = this.sectionPalette[this.form.template] || this.sectionPalette['full'];
return palette.includes(sectionName);
},
// Update homepage state
updateIsHomepage() {
this.isHomepage = this.form.slug === 'home';
},
// Update template when page type changes
updatePageType() {
if (this.pageType === 'landing') {
this.form.template = 'full';
// Load sections if editing and not yet loaded
if (this.pageId && !this.sectionsLoaded) {
this.loadSections();
}
} else {
this.form.template = 'default';
}
this.updateIsHomepage();
},
// ========================================
// TITLE/CONTENT TRANSLATION HELPERS
// ========================================
getTranslatedTitle() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.title;
}
return (this.form.title_translations || {})[this.titleContentLang] || '';
},
setTranslatedTitle(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.title = value;
} else {
if (!this.form.title_translations) this.form.title_translations = {};
this.form.title_translations[this.titleContentLang] = value;
}
},
getTranslatedContent() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.content;
}
return (this.form.content_translations || {})[this.titleContentLang] || '';
},
setTranslatedContent(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.content = value;
} else {
if (!this.form.content_translations) this.form.content_translations = {};
this.form.content_translations[this.titleContentLang] = value;
}
},
getTranslatedMetaDescription() {
if (this.titleContentLang === this.defaultLanguage) {
return this.form.meta_description;
}
return (this.form.meta_description_translations || {})[this.titleContentLang] || '';
},
setTranslatedMetaDescription(value) {
if (this.titleContentLang === this.defaultLanguage) {
this.form.meta_description = value;
} else {
if (!this.form.meta_description_translations) this.form.meta_description_translations = {};
this.form.meta_description_translations[this.titleContentLang] = value;
}
},
// Load platforms for dropdown
async loadPlatforms() {
this.loadingPlatforms = true;
@@ -188,11 +275,13 @@ function contentPageEditor(pageId) {
this.form = {
slug: page.slug || '',
title: page.title || '',
title_translations: page.title_translations || {},
content: page.content || '',
content_translations: page.content_translations || {},
content_format: page.content_format || 'html',
template: page.template || 'default',
meta_description: page.meta_description || '',
meta_keywords: page.meta_keywords || '',
meta_description_translations: page.meta_description_translations || {},
is_published: page.is_published || false,
show_in_header: page.show_in_header || false,
show_in_footer: page.show_in_footer !== undefined ? page.show_in_footer : true,
@@ -202,6 +291,9 @@ function contentPageEditor(pageId) {
store_id: page.store_id
};
// Set page type from template
this.pageType = (this.form.template === 'full') ? 'landing' : 'content';
contentPageEditLog.info('Page loaded successfully');
// Update computed properties after loading
@@ -240,24 +332,25 @@ function contentPageEditor(pageId) {
},
// ========================================
// HOMEPAGE SECTIONS METHODS
// SECTIONS METHODS
// ========================================
// Load sections for homepage
// Load sections for landing pages
async loadSections() {
if (!this.pageId || this.form.slug !== 'home') {
contentPageEditLog.debug('Skipping section load - not a homepage');
if (!this.pageId || this.pageType !== 'landing') {
contentPageEditLog.debug('Skipping section load - not a landing page');
return;
}
try {
contentPageEditLog.info('Loading homepage sections...');
contentPageEditLog.info('Loading sections...');
const response = await apiClient.get(`/admin/content-pages/${this.pageId}/sections`);
const data = response.data || response;
this.supportedLanguages = data.supported_languages || ['fr', 'de', 'en'];
this.defaultLanguage = data.default_language || 'fr';
this.currentLang = this.defaultLanguage;
this.titleContentLang = this.defaultLanguage;
if (data.sections) {
this.sections = this.mergeWithDefaults(data.sections);
@@ -277,12 +370,18 @@ function contentPageEditor(pageId) {
mergeWithDefaults(loadedSections) {
const defaults = this.getDefaultSectionStructure();
// Deep merge each section
for (const key of ['hero', 'features', 'pricing', 'cta']) {
// Deep merge each section that exists in defaults
for (const key of Object.keys(defaults)) {
if (loadedSections[key]) {
defaults[key] = { ...defaults[key], ...loadedSections[key] };
}
}
// Also preserve any extra sections from loaded data
for (const key of Object.keys(loadedSections)) {
if (!defaults[key]) {
defaults[key] = loadedSections[key];
}
}
return defaults;
},
@@ -375,7 +474,7 @@ function contentPageEditor(pageId) {
// Save sections
async saveSections() {
if (!this.pageId || !this.isHomepage) return;
if (!this.pageId || this.pageType !== 'landing') return;
try {
contentPageEditLog.info('Saving sections...');
@@ -401,11 +500,13 @@ function contentPageEditor(pageId) {
const payload = {
slug: this.form.slug,
title: this.form.title,
title_translations: this.form.title_translations,
content: this.form.content,
content_translations: this.form.content_translations,
content_format: this.form.content_format,
template: this.form.template,
meta_description: this.form.meta_description,
meta_keywords: this.form.meta_keywords,
meta_description_translations: this.form.meta_description_translations,
is_published: this.form.is_published,
show_in_header: this.form.show_in_header,
show_in_footer: this.form.show_in_footer,
@@ -422,8 +523,8 @@ function contentPageEditor(pageId) {
// Update existing page
response = await apiClient.put(`/admin/content-pages/${this.pageId}`, payload);
// Also save sections if this is a homepage
if (this.isHomepage && this.sectionsLoaded) {
// Also save sections if this is a landing page
if (this.pageType === 'landing' && this.sectionsLoaded) {
await this.saveSections();
}

View File

@@ -57,19 +57,23 @@
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Page Title -->
<div class="md:col-span-2">
<!-- Page Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Page Title <span class="text-red-500">*</span>
Page Type
</label>
<input
type="text"
x-model="form.title"
required
maxlength="200"
<select
x-model="pageType"
@change="updatePageType()"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="About Us"
>
<option value="content">Content Page</option>
<option value="landing">Landing Page</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-show="pageType === 'content'">Standard page with rich text content (About, FAQ, Privacy...)</span>
<span x-show="pageType === 'landing'">Section-based page with hero, features, CTA blocks</span>
</p>
</div>
<!-- Slug -->
@@ -133,10 +137,54 @@
</div>
</div>
<!-- Content -->
<!-- Title with Language Tabs -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Title
<span class="text-sm font-normal text-gray-500 ml-2">(Multi-language)</span>
</h3>
<!-- Language Tabs for Title/Content -->
<div class="mb-4">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px space-x-4">
<template x-for="lang in supportedLanguages" :key="'tc-' + lang">
<button
type="button"
@click="titleContentLang = lang"
:class="titleContentLang === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'"
class="py-2 px-4 border-b-2 font-medium text-sm transition-colors"
>
<span x-text="languageNames[lang] || lang.toUpperCase()"></span>
<span x-show="lang === defaultLanguage" class="ml-1 text-xs text-gray-400">(default)</span>
</button>
</template>
</nav>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title <span class="text-red-500">*</span>
<span class="font-normal text-gray-400 ml-1" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</label>
<input
type="text"
:value="getTranslatedTitle()"
@input="setTranslatedTitle($event.target.value)"
required
maxlength="200"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
:placeholder="'Page title in ' + (languageNames[titleContentLang] || titleContentLang)"
>
</div>
</div>
<!-- Content (only for Content Page type) -->
<div x-show="pageType === 'content'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Page Content
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<!-- Content Format -->
@@ -219,9 +267,9 @@
</div>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- HOMEPAGE SECTIONS EDITOR (only for slug='home') -->
<!-- SECTIONS EDITOR (for Landing Page type) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<div x-show="isHomepage" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div x-show="pageType === 'landing'" x-cloak class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Homepage Sections
@@ -258,7 +306,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('hero')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'hero' ? null : 'hero'"
@@ -341,7 +389,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FEATURES SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('features')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'features' ? null : 'features'"
@@ -410,7 +458,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PRICING SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('pricing')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'pricing' ? null : 'pricing'"
@@ -448,7 +496,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CTA SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div x-show="isSectionAvailable('cta')" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
type="button"
@click="openSection = openSection === 'cta' ? null : 'cta'"
@@ -525,6 +573,7 @@
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
SEO & Metadata
<span class="text-sm font-normal text-gray-500 ml-2" x-text="'(' + (languageNames[titleContentLang] || titleContentLang) + ')'"></span>
</h3>
<div class="space-y-4">
@@ -534,30 +583,17 @@
Meta Description
</label>
<textarea
x-model="form.meta_description"
:value="getTranslatedMetaDescription()"
@input="setTranslatedMetaDescription($event.target.value)"
rows="2"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="A brief description for search engines"
:placeholder="'Meta description in ' + (languageNames[titleContentLang] || titleContentLang)"
></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span x-text="(form.meta_description || '').length"></span>/300 characters (150-160 recommended)
150-160 characters recommended for search engines
</p>
</div>
<!-- Meta Keywords -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Meta Keywords
</label>
<input
type="text"
x-model="form.meta_keywords"
maxlength="300"
class="w-full px-3 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:border-purple-500 dark:bg-gray-700"
placeholder="keyword1, keyword2, keyword3"
>
</div>
</div>
</div>

View File

@@ -7,6 +7,9 @@
{% from 'cms/platform/sections/_products.html' import render_products %}
{% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_pricing.html' import render_pricing with context %}
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
{% from 'cms/platform/sections/_cta.html' import render_cta %}
{% block title %}
@@ -51,6 +54,21 @@
{{ render_pricing(page.sections.pricing, lang, default_lang, tiers) }}
{% endif %}
{# Testimonials Section #}
{% if page.sections.testimonials %}
{{ render_testimonials(page.sections.testimonials, lang, default_lang) }}
{% endif %}
{# Gallery Section #}
{% if page.sections.gallery %}
{{ render_gallery(page.sections.gallery, lang, default_lang) }}
{% endif %}
{# Contact Info Section #}
{% if page.sections.contact_info %}
{{ render_contact_info(page.sections.contact_info, lang, default_lang) }}
{% endif %}
{# CTA Section #}
{% if page.sections.cta %}
{{ render_cta(page.sections.cta, lang, default_lang) }}

View File

@@ -0,0 +1,66 @@
{# Section partial: Contact Information #}
{#
Parameters:
- contact_info: dict with enabled, title, email, phone, address, hours, map_embed_url
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_contact_info(contact_info, lang, default_lang) %}
{% if contact_info and contact_info.enabled %}
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
{% set title = contact_info.title.translations.get(lang) or contact_info.title.translations.get(default_lang) or 'Contact' %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
{% if contact_info.phone %}
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-purple-600 dark:text-purple-300 text-xl">&#128222;</span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Phone</h3>
<a href="tel:{{ contact_info.phone }}" class="text-purple-600 dark:text-purple-400 hover:underline">
{{ contact_info.phone }}
</a>
</div>
{% endif %}
{% if contact_info.email %}
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-purple-600 dark:text-purple-300 text-xl">&#128231;</span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Email</h3>
<a href="mailto:{{ contact_info.email }}" class="text-purple-600 dark:text-purple-400 hover:underline">
{{ contact_info.email }}
</a>
</div>
{% endif %}
{% if contact_info.address %}
<div class="text-center p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
<span class="text-purple-600 dark:text-purple-300 text-xl">&#128205;</span>
</div>
<h3 class="font-semibold text-gray-900 dark:text-white mb-2">Address</h3>
<p class="text-gray-600 dark:text-gray-400">{{ contact_info.address }}</p>
</div>
{% endif %}
</div>
{% if contact_info.hours %}
<div class="mt-8 text-center">
<p class="text-gray-600 dark:text-gray-400">
<span class="font-semibold">Hours:</span> {{ contact_info.hours }}
</p>
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,44 @@
{# Section partial: Image Gallery #}
{#
Parameters:
- gallery: dict with enabled, title, images (list of {src, alt, caption})
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_gallery(gallery, lang, default_lang) %}
{% if gallery and gallery.enabled %}
<section class="py-16 lg:py-24 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section header #}
<div class="text-center mb-12">
{% set title = gallery.title.translations.get(lang) or gallery.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% endif %}
</div>
{# Image grid #}
{% if gallery.images %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for image in gallery.images %}
<div class="relative group overflow-hidden rounded-lg aspect-square">
<img src="{{ image.src }}"
alt="{{ image.alt or '' }}"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy">
{% if image.caption %}
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity">
<p class="text-sm text-white">{{ image.caption }}</p>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,73 @@
{# Section partial: Testimonials #}
{#
Parameters:
- testimonials: dict with enabled, title, subtitle, items
- lang: Current language code
- default_lang: Fallback language
#}
{% macro render_testimonials(testimonials, lang, default_lang) %}
{% if testimonials and testimonials.enabled %}
<section class="py-16 lg:py-24 bg-gray-50 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{# Section header #}
<div class="text-center mb-12">
{% set title = testimonials.title.translations.get(lang) or testimonials.title.translations.get(default_lang) or '' %}
{% if title %}
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ title }}
</h2>
{% endif %}
</div>
{# Testimonial cards — use .get() to avoid dict.items() method collision with JSON dicts #}
{% set testimonial_items = testimonials.get('items', []) if testimonials is mapping else [] %}
{% if testimonial_items %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{% for item in testimonial_items %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm border border-gray-100 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex text-yellow-400">
{% for _ in range(5) %}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
{% endfor %}
</div>
</div>
{% set content = item.content %}
{% if content is mapping %}
{% set content = content.translations.get(lang) or content.translations.get(default_lang) or '' %}
{% endif %}
<p class="text-gray-600 dark:text-gray-300 mb-6 italic">"{{ content }}"</p>
<div class="flex items-center">
{% if item.avatar %}
<img src="{{ item.avatar }}" alt="" class="w-10 h-10 rounded-full mr-3">
{% else %}
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-3">
<span class="text-sm font-bold text-purple-600 dark:text-purple-300">
{% set author = item.author %}
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '?' %}{% endif %}
{{ author[0]|upper if author else '?' }}
</span>
</div>
{% endif %}
<div>
{% set author = item.author %}
{% if author is mapping %}{% set author = author.translations.get(lang) or author.translations.get(default_lang) or '' %}{% endif %}
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ author }}</p>
{% set role = item.role %}
{% if role is mapping %}{% set role = role.translations.get(lang) or role.translations.get(default_lang) or '' %}{% endif %}
{% if role %}
<p class="text-xs text-gray-500 dark:text-gray-400">{{ role }}</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-center text-gray-400 dark:text-gray-500">Coming soon</p>
{% endif %}
</div>
</section>
{% endif %}
{% endmacro %}

View File

@@ -3,7 +3,7 @@
{% extends "storefront/base.html" %}
{# Dynamic title from CMS #}
{% block title %}{{ page.title }}{% endblock %}
{% block title %}{{ page_title or page.title }}{% endblock %}
{# SEO from CMS #}
{% block meta_description %}{{ page.meta_description or page.title }}{% endblock %}
@@ -16,13 +16,13 @@
<div class="breadcrumb mb-6">
<a href="{{ base_url }}" class="hover:text-primary">Home</a>
<span>/</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page.title }}</span>
<span class="text-gray-900 dark:text-gray-200 font-medium">{{ page_title or page.title }}</span>
</div>
{# Page Header #}
<div class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 dark:text-gray-200 mb-4">
{{ page.title }}
{{ page_title or page.title }}
</h1>
{# Optional: Show store override badge for debugging #}

View File

@@ -1,10 +1,9 @@
{# app/templates/store/landing-default.html #}
{# standalone #}
{# app/modules/cms/templates/cms/storefront/landing-default.html #}
{# Default/Minimal Landing Page Template #}
{% extends "storefront/base.html" %}
{% block title %}{{ store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name }}{% endblock %}
{% block meta_description %}{{ page.meta_description or store.description or store.name if page else store.description or store.name }}{% endblock %}
{% block content %}
<div class="min-h-screen">
@@ -24,7 +23,7 @@
{# Title #}
<h1 class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-6">
{{ page.title or store.name }}
{{ page_title or store.name }}
</h1>
{# Tagline #}
@@ -34,18 +33,31 @@
</p>
{% endif %}
{# CTA Button #}
{# CTA Buttons — driven by storefront_nav (module-agnostic) #}
{% set nav_items = storefront_nav.get('nav', []) %}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ base_url }}"
{% if nav_items %}
{# Primary CTA: first nav item from enabled modules #}
<a href="{{ base_url }}{{ nav_items[0].route }}"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
Browse Our Shop
{{ _(nav_items[0].label_key) }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% if page.content %}
{% else %}
{# Fallback: account link when no module nav items #}
<a href="{{ base_url }}account/login"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-white bg-primary hover:bg-primary-dark transition-colors shadow-lg hover:shadow-xl"
style="background-color: var(--color-primary)">
{{ _('cms.storefront.my_account') }}
<span class="w-5 h-5 ml-2" x-html="$icon('arrow-right', 'w-5 h-5')"></span>
</a>
{% endif %}
{% if page and page.content %}
<a href="#about"
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-2 border-gray-200 dark:border-gray-600">
Learn More
{{ _('cms.storefront.learn_more') }}
</a>
{% endif %}
</div>
@@ -54,73 +66,65 @@
</section>
{# Content Section (if provided) #}
{% if page.content %}
{% if page_content %}
<section id="about" class="py-16 bg-white dark:bg-gray-900">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}{# sanitized: CMS content #}
{{ page_content | safe }}{# sanitized: CMS content #}
</div>
</div>
</section>
{% endif %}
{# Quick Links Section #}
{# Quick Links Section — driven by nav items and CMS pages #}
{% set account_items = storefront_nav.get('account', []) %}
{% set all_links = nav_items + account_items %}
{% if all_links or header_pages %}
<section class="py-16 bg-gray-50 dark:bg-gray-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-12">
Explore
{{ _('cms.storefront.explore') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a href="{{ base_url }}products"
{# Module nav items (products, loyalty, etc.) #}
{% for item in all_links[:3] %}
<a href="{{ base_url }}{{ item.route }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">🛍️</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('{{ item.icon }}', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Shop Products
{{ _(item.label_key) }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
Browse our complete catalog
</p>
</a>
{% endfor %}
{% if header_pages %}
{% for page in header_pages[:2] %}
{# Fill remaining slots with CMS header pages #}
{% set remaining = 3 - all_links[:3]|length %}
{% if remaining > 0 and header_pages %}
{% for page in header_pages[:remaining] %}
<a href="{{ base_url }}{{ page.slug }}"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📄</div>
<div class="mb-4">
<span class="h-10 w-10 text-primary mx-auto" style="color: var(--color-primary)"
x-html="$icon('document-text', 'h-10 w-10 mx-auto')"></span>
</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
{{ page.title }}
</h3>
{% if page.meta_description %}
<p class="text-gray-600 dark:text-gray-400">
{{ page.meta_description or 'Learn more' }}
{{ page.meta_description }}
</p>
{% endif %}
</a>
{% endfor %}
{% else %}
<a href="{{ base_url }}about"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4"></div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
About Us
</h3>
<p class="text-gray-600 dark:text-gray-400">
Learn about our story
</p>
</a>
<a href="{{ base_url }}contact"
class="block p-8 bg-white dark:bg-gray-900 rounded-lg shadow-md hover:shadow-xl transition-shadow text-center group">
<div class="text-4xl mb-4">📧</div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-primary">
Contact
</h3>
<p class="text-gray-600 dark:text-gray-400">
Get in touch with us
</p>
</a>
{% endif %}
</div>
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

@@ -10,6 +10,35 @@
{% block alpine_data %}storefrontLayoutData(){% endblock %}
{% block content %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# SECTION-BASED RENDERING (when page.sections is configured) #}
{# Used by POC builder templates — takes priority over hardcoded HTML #}
{# ═══════════════════════════════════════════════════════════════════ #}
{% set sections = page_sections if page_sections is defined and page_sections else (page.sections if page else none) %}
{% if sections %}
{% from 'cms/platform/sections/_hero.html' import render_hero %}
{% from 'cms/platform/sections/_features.html' import render_features %}
{% from 'cms/platform/sections/_testimonials.html' import render_testimonials %}
{% from 'cms/platform/sections/_gallery.html' import render_gallery %}
{% from 'cms/platform/sections/_contact_info.html' import render_contact_info %}
{% from 'cms/platform/sections/_cta.html' import render_cta %}
{% set lang = request.state.language|default("fr") %}
{% set default_lang = 'fr' %}
<div class="min-h-screen">
{% if sections.hero %}{{ render_hero(sections.hero, lang, default_lang) }}{% endif %}
{% if sections.features %}{{ render_features(sections.features, lang, default_lang) }}{% endif %}
{% if sections.testimonials %}{{ render_testimonials(sections.testimonials, lang, default_lang) }}{% endif %}
{% if sections.gallery %}{{ render_gallery(sections.gallery, lang, default_lang) }}{% endif %}
{% if sections.contact_info %}{{ render_contact_info(sections.contact_info, lang, default_lang) }}{% endif %}
{% if sections.cta %}{{ render_cta(sections.cta, lang, default_lang) }}{% endif %}
</div>
{% else %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# HARDCODED LAYOUT (original full landing page — no sections JSON) #}
{# ═══════════════════════════════════════════════════════════════════ #}
<div class="min-h-screen">
{# Hero Section - Split Design #}
@@ -255,4 +284,5 @@
</section>
</div>
{% endif %}
{% endblock %}

View File

@@ -80,6 +80,44 @@ class WidgetContext:
include_details: bool = False
# =============================================================================
# Storefront Dashboard Card
# =============================================================================
@dataclass
class StorefrontDashboardCard:
"""
A card contributed by a module to the storefront customer dashboard.
Modules implement get_storefront_dashboard_cards() to provide these.
The dashboard template renders them without knowing which module provided them.
Attributes:
key: Unique identifier (e.g. "orders.summary", "loyalty.points")
icon: Lucide icon name (e.g. "shopping-bag", "gift")
title: Card title (i18n key or plain text)
subtitle: Card subtitle / description
route: Link destination relative to base_url (e.g. "account/orders")
value: Primary display value (e.g. order count, points balance)
value_label: Label for the value (e.g. "Total Orders", "Points Balance")
order: Sort order (lower = shown first)
template: Optional custom template path for complex rendering
extra_data: Additional data for custom template rendering
"""
key: str
icon: str
title: str
subtitle: str
route: str
value: str | int | None = None
value_label: str | None = None
order: int = 100
template: str | None = None
extra_data: dict[str, Any] = field(default_factory=dict)
# =============================================================================
# Widget Item Types
# =============================================================================
@@ -330,6 +368,30 @@ class DashboardWidgetProviderProtocol(Protocol):
"""
...
def get_storefront_dashboard_cards(
self,
db: "Session",
store_id: int,
customer_id: int,
context: WidgetContext | None = None,
) -> list["StorefrontDashboardCard"]:
"""
Get cards for the storefront customer dashboard.
Called by the customer account dashboard. Each module contributes
its own cards (e.g. orders summary, loyalty points).
Args:
db: Database session for queries
store_id: ID of the store
customer_id: ID of the logged-in customer
context: Optional filtering/scoping context
Returns:
List of StorefrontDashboardCard objects
"""
...
__all__ = [
# Context
@@ -343,6 +405,8 @@ __all__ = [
"WidgetData",
# Main envelope
"DashboardWidget",
# Storefront
"StorefrontDashboardCard",
# Protocol
"DashboardWidgetProviderProtocol",
]

View File

@@ -9,11 +9,13 @@ Store pages for core functionality:
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import (
UserContext,
get_current_store_from_cookie_or_header,
get_current_store_optional,
get_db,
get_resolved_store_code,
)
@@ -24,6 +26,21 @@ from app.templates_config import templates
router = APIRouter()
# ============================================================================
# STORE ROOT REDIRECT
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def store_root(
current_user: UserContext | None = Depends(get_current_store_optional),
):
"""Redirect /store/ based on authentication status."""
if current_user:
return RedirectResponse(url="/store/dashboard", status_code=302)
return RedirectResponse(url="/store/login", status_code=302)
# ============================================================================
# STORE DASHBOARD
# ============================================================================

View File

@@ -67,6 +67,7 @@ class DiscoveredMenuItem:
section_order: int
is_visible: bool = True
is_module_enabled: bool = True
header_template: str | None = None
@dataclass
@@ -191,6 +192,7 @@ class MenuDiscoveryService:
section_label_key=section.label_key,
section_order=section.order,
is_module_enabled=is_module_enabled,
header_template=item.header_template,
)
sections_map[section.id].items.append(discovered_item)

View File

@@ -34,6 +34,7 @@ from sqlalchemy.orm import Session
from app.modules.contracts.widgets import (
DashboardWidget,
DashboardWidgetProviderProtocol,
StorefrontDashboardCard,
WidgetContext,
)
@@ -233,6 +234,49 @@ class WidgetAggregatorService:
return widget
return None
def get_storefront_dashboard_cards(
self,
db: Session,
store_id: int,
customer_id: int,
platform_id: int,
context: WidgetContext | None = None,
) -> list[StorefrontDashboardCard]:
"""
Get dashboard cards for the storefront customer account page.
Collects cards from all enabled modules that implement
get_storefront_dashboard_cards(), sorted by order.
Args:
db: Database session
store_id: ID of the store
customer_id: ID of the logged-in customer
platform_id: Platform ID (for module enablement check)
context: Optional filtering/scoping context
Returns:
Flat list of StorefrontDashboardCard sorted by order
"""
providers = self._get_enabled_providers(db, platform_id)
cards: list[StorefrontDashboardCard] = []
for module, provider in providers:
if not hasattr(provider, "get_storefront_dashboard_cards"):
continue
try:
module_cards = provider.get_storefront_dashboard_cards(
db, store_id, customer_id, context
)
if module_cards:
cards.extend(module_cards)
except Exception as e:
logger.warning(
f"Failed to get storefront cards from module {module.code}: {e}"
)
return sorted(cards, key=lambda c: c.order)
def get_available_categories(
self, db: Session, platform_id: int
) -> list[str]:

View File

@@ -278,7 +278,7 @@ class TestMerchantMenuModuleGating:
s for s in data["sections"] if s["id"] == platform_section_id
)
item_ids = {i["id"] for i in platform_section["items"]}
assert "loyalty-program" in item_ids
assert "program" in item_ids
def test_loyalty_hidden_when_module_not_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
@@ -304,7 +304,7 @@ class TestMerchantMenuModuleGating:
s for s in data["sections"] if s["id"] == platform_section_id
)
overview = next(
i for i in platform_section["items"] if i["id"] == "loyalty-program"
i for i in platform_section["items"] if i["id"] == "program"
)
assert overview["url"] == "/merchants/loyalty/program"
@@ -498,7 +498,7 @@ class TestMerchantMenuMultiPlatform:
s for s in data["sections"] if s["id"] == platform_a_section_id
)
item_ids = {i["id"] for i in pa_section["items"]}
assert "loyalty-program" in item_ids
assert "program" in item_ids
# Core sections always present
assert "main" in section_ids
assert "billing" in section_ids

View File

@@ -61,12 +61,12 @@ class TestMenuDiscoveryService:
assert "profile" in item_ids
def test_merchant_loyalty_section_items(self):
"""Loyalty section contains loyalty-program."""
"""Loyalty section contains program."""
menus = self.service.discover_all_menus()
loyalty_sections = [s for s in menus[FrontendType.MERCHANT] if s.id == "loyalty"]
assert len(loyalty_sections) == 1
item_ids = [i.id for i in loyalty_sections[0].items]
assert "loyalty-program" in item_ids
assert "program" in item_ids
def test_get_all_menu_items_merchant(self):
"""get_all_menu_items returns items for MERCHANT frontend type."""
@@ -75,7 +75,7 @@ class TestMenuDiscoveryService:
item_ids = {i.id for i in items}
assert "dashboard" in item_ids
assert "subscriptions" in item_ids
assert "loyalty-program" in item_ids
assert "program" in item_ids
def test_get_all_menu_item_ids_merchant(self):
"""get_all_menu_item_ids returns IDs for MERCHANT frontend type."""
@@ -85,7 +85,7 @@ class TestMenuDiscoveryService:
assert "invoices" in item_ids
assert "stores" in item_ids
assert "profile" in item_ids
assert "loyalty-program" in item_ids
assert "program" in item_ids
def test_get_mandatory_item_ids_merchant(self):
"""Mandatory items for MERCHANT include dashboard and subscriptions."""

View File

@@ -384,15 +384,17 @@ def get_storefront_context(
if access_method == "path" and store:
platform = getattr(request.state, "platform", None)
platform_original_path = getattr(request.state, "platform_original_path", None)
# Use subdomain (lowercase, hyphens) for URL routing — store_code is for internal use
store_slug = store.subdomain or store.store_code
if platform and platform_original_path and platform_original_path.startswith("/platforms/"):
base_url = f"/platforms/{platform.code}/storefront/{store.store_code}/"
base_url = f"/platforms/{platform.code}/storefront/{store_slug}/"
else:
full_prefix = (
store_context.get("full_prefix", "/storefront/")
if store_context
else "/storefront/"
)
base_url = f"{full_prefix}{store.store_code}/"
base_url = f"{full_prefix}{store_slug}/"
# Read subscription info set by StorefrontAccessMiddleware
subscription = getattr(request.state, "subscription", None)

View File

@@ -141,28 +141,28 @@ customers_module = ModuleDefinition(
items=[
MenuItemDefinition(
id="dashboard",
label_key="storefront.account.dashboard",
label_key="customers.storefront.account.dashboard",
icon="home",
route="account/dashboard",
order=10,
),
MenuItemDefinition(
id="profile",
label_key="storefront.account.profile",
label_key="customers.storefront.account.profile",
icon="user",
route="account/profile",
order=20,
),
MenuItemDefinition(
id="addresses",
label_key="storefront.account.addresses",
label_key="customers.storefront.account.addresses",
icon="map-pin",
route="account/addresses",
order=30,
),
MenuItemDefinition(
id="settings",
label_key="storefront.account.settings",
label_key="customers.storefront.account.settings",
icon="cog",
route="account/settings",
order=90,

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Kundendatensätze entfernen",
"customers_export": "Kunden exportieren",
"customers_export_desc": "Kundendaten exportieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Einstellungen"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_section": "Customers",
"customers": "Customers",
"all_customers": "All Customers"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profile",
"addresses": "Addresses",
"settings": "Settings"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Supprimer les fiches clients",
"customers_export": "Exporter les clients",
"customers_export_desc": "Exporter les données clients"
},
"storefront": {
"account": {
"dashboard": "Tableau de bord",
"profile": "Profil",
"addresses": "Adresses",
"settings": "Paramètres"
}
}
}

View File

@@ -52,5 +52,13 @@
"customers_delete_desc": "Clientedossieren ewechhuelen",
"customers_export": "Clienten exportéieren",
"customers_export_desc": "Clientedaten exportéieren"
},
"storefront": {
"account": {
"dashboard": "Dashboard",
"profile": "Profil",
"addresses": "Adressen",
"settings": "Astellungen"
}
}
}

View File

@@ -0,0 +1,31 @@
"""customers 003 - add birth_date column
Adds an optional birth_date column to the customers table so that
self-enrollment flows (e.g. loyalty) can persist the customer's birthday
collected on the enrollment form. Previously the field was collected by
the UI and accepted by the loyalty service signature, but never written
anywhere — see Phase 1.4 of the loyalty production launch plan.
Revision ID: customers_003
Revises: customers_002
Create Date: 2026-04-09
"""
import sqlalchemy as sa
from alembic import op
revision = "customers_003"
down_revision = "customers_002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"customers",
sa.Column("birth_date", sa.Date(), nullable=True),
)
def downgrade() -> None:
op.drop_column("customers", "birth_date")

View File

@@ -10,6 +10,7 @@ from sqlalchemy import (
JSON,
Boolean,
Column,
Date,
ForeignKey,
Integer,
String,
@@ -34,6 +35,7 @@ class Customer(Base, TimestampMixin, SoftDeleteMixin):
first_name = Column(String(100))
last_name = Column(String(100))
phone = Column(String(50))
birth_date = Column(Date, nullable=True)
customer_number = Column(
String(100), nullable=False, index=True
) # Store-specific ID

View File

@@ -195,9 +195,25 @@ async def shop_account_dashboard_page(
},
)
# Collect dashboard cards from enabled modules via widget protocol
from app.modules.core.services.widget_aggregator import widget_aggregator
store = getattr(request.state, "store", None)
platform = getattr(request.state, "platform", None)
dashboard_cards = []
if store and platform:
dashboard_cards = widget_aggregator.get_storefront_dashboard_cards(
db,
store_id=store.id,
customer_id=current_customer.id,
platform_id=platform.id,
)
return templates.TemplateResponse(
"customers/storefront/dashboard.html",
get_storefront_context(request, db=db, user=current_customer),
get_storefront_context(
request, db=db, user=current_customer, dashboard_cards=dashboard_cards
),
)

View File

@@ -9,7 +9,7 @@ Provides schemas for:
- Admin customer management
"""
from datetime import datetime
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, EmailStr, Field, field_validator
@@ -60,6 +60,9 @@ class CustomerUpdate(BaseModel):
first_name: str | None = Field(None, min_length=1, max_length=100)
last_name: str | None = Field(None, min_length=1, max_length=100)
phone: str | None = Field(None, max_length=50)
birth_date: date | None = Field(
None, description="Date of birth (YYYY-MM-DD)"
)
marketing_consent: bool | None = None
preferred_language: str | None = Field(
None, description="Preferred language (en, fr, de, lb)"
@@ -71,6 +74,21 @@ class CustomerUpdate(BaseModel):
"""Convert email to lowercase."""
return v.lower() if v else None
@field_validator("birth_date")
@classmethod
def birth_date_sane(cls, v: date | None) -> date | None:
"""Birthday must be in the past and within a plausible age range."""
if v is None:
return v
today = date.today()
if v >= today:
raise ValueError("birth_date must be in the past")
# Plausible human age range — guards against typos like 0001-01-01
years = (today - v).days / 365.25
if years < 13 or years > 120:
raise ValueError("birth_date implies an implausible age")
return v
class CustomerPasswordChange(BaseModel):
"""Schema for customer password change."""
@@ -108,6 +126,7 @@ class CustomerResponse(BaseModel):
first_name: str | None
last_name: str | None
phone: str | None
birth_date: date | None = None
customer_number: str
marketing_consent: bool
preferred_language: str | None
@@ -253,6 +272,7 @@ class CustomerDetailResponse(BaseModel):
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
birth_date: date | None = None
customer_number: str | None = None
marketing_consent: bool | None = None
preferred_language: str | None = None
@@ -304,6 +324,7 @@ class AdminCustomerItem(BaseModel):
first_name: str | None = None
last_name: str | None = None
phone: str | None = None
birth_date: date | None = None
customer_number: str
marketing_consent: bool = False
preferred_language: str | None = None

View File

@@ -7,7 +7,7 @@ with complete store isolation.
"""
import logging
from datetime import UTC, datetime, timedelta
from datetime import UTC, date, datetime, timedelta
from typing import Any
from sqlalchemy import and_
@@ -567,6 +567,7 @@ class CustomerService:
first_name: str = "",
last_name: str = "",
phone: str | None = None,
birth_date: date | None = None,
) -> Customer:
"""
Create a customer for loyalty/external enrollment.
@@ -580,6 +581,7 @@ class CustomerService:
first_name: First name
last_name: Last name
phone: Phone number
birth_date: Date of birth (optional)
Returns:
Created Customer object
@@ -603,6 +605,7 @@ class CustomerService:
first_name=first_name,
last_name=last_name,
phone=phone,
birth_date=birth_date,
hashed_password=unusable_hash,
customer_number=cust_number,
store_id=store_id,

View File

@@ -37,7 +37,7 @@
<!-- Empty State -->
<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('location-marker', 'h-12 w-12 mx-auto')"></span>
<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>
<button @click="openAddModal()"

View File

@@ -17,25 +17,31 @@
<!-- Dashboard Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Orders Card -->
<a href="{{ base_url }}account/orders"
{# Module-contributed cards (orders, loyalty, etc.) — rendered via widget protocol #}
{% for card in dashboard_cards|default([]) %}
<a href="{{ base_url }}{{ card.route }}"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('shopping-bag', 'h-8 w-8')"></span>
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('{{ card.icon }}', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Orders</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View order history</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ card.title }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ card.subtitle }}</p>
</div>
</div>
{% if card.value is not none %}
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ user.total_orders }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)">{{ card.value }}</p>
{% if card.value_label %}
<p class="text-sm text-gray-500 dark:text-gray-400">{{ card.value_label }}</p>
{% endif %}
</div>
{% endif %}
</a>
{% endfor %}
<!-- Profile Card -->
<!-- Profile Card (always shown — core) -->
<a href="{{ base_url }}account/profile"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
@@ -52,12 +58,12 @@
</div>
</a>
<!-- Addresses Card -->
<!-- Addresses Card (always shown — core) -->
<a href="{{ base_url }}account/addresses"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('location-marker', 'h-8 w-8')"></span>
<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>
@@ -66,36 +72,7 @@
</div>
</a>
{% if 'loyalty' in enabled_modules %}
<!-- Loyalty Rewards Card -->
<a href="{{ base_url }}account/loyalty"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ points: null, loaded: false }"
x-init="fetch('/api/v1/storefront/loyalty/card').then(r => r.json()).then(d => { if (d.card) { points = d.card.points_balance; } loaded = true; }).catch(() => { loaded = true; })">
<div class="flex items-center mb-4">
<div class="flex-shrink-0">
<span class="h-8 w-8 text-primary" style="color: var(--color-primary)" x-html="$icon('gift', 'h-8 w-8')"></span>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Loyalty Rewards</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View your points & rewards</p>
</div>
</div>
<div>
<template x-if="loaded && points !== null">
<div>
<p class="text-2xl font-bold text-primary" style="color: var(--color-primary)" x-text="points.toLocaleString()"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Points Balance</p>
</div>
</template>
<template x-if="loaded && points === null">
<p class="text-sm text-gray-500 dark:text-gray-400">Join our rewards program</p>
</template>
</div>
</a>
{% endif %}
<!-- Messages Card -->
<!-- Messages Card (always shown — messaging is core) -->
<a href="{{ base_url }}account/messages"
class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow p-6 border border-gray-200 dark:border-gray-700"
x-data="{ unreadCount: 0 }"
@@ -126,10 +103,6 @@
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">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">Total Orders</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.total_orders }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Customer Number</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.customer_number }}</p>

View File

@@ -99,10 +99,9 @@ def execute_query(db: Session, sql: str) -> dict:
start = time.perf_counter()
result = connection.execute(text(sql))
columns = list(result.keys()) if result.returns_rows else []
rows_raw = result.fetchmany(max_rows + 1)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
columns = list(result.keys()) if result.returns_rows else []
truncated = len(rows_raw) > max_rows
rows_raw = rows_raw[:max_rows]

View File

@@ -40,129 +40,272 @@ function sqlQueryTool() {
// Schema explorer
showPresets: true,
expandedCategories: {},
presetQueries: [
presetSearch: '',
// Preset sections — grouped by platform
presetSections: [
// ── Infrastructure ──
{
category: 'Schema',
items: [
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
label: 'Infrastructure',
groups: [
{
category: 'Schema',
items: [
{ name: 'All tables', sql: "SELECT table_name, pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS size\nFROM information_schema.tables\nWHERE table_schema = 'public'\nORDER BY table_name;" },
{ name: 'Columns for table', sql: "SELECT column_name, data_type, is_nullable, column_default,\n character_maximum_length\nFROM information_schema.columns\nWHERE table_schema = 'public'\n AND table_name = 'REPLACE_TABLE_NAME'\nORDER BY ordinal_position;" },
{ name: 'Foreign keys', sql: "SELECT\n tc.table_name, kcu.column_name,\n ccu.table_name AS foreign_table,\n ccu.column_name AS foreign_column\nFROM information_schema.table_constraints tc\nJOIN information_schema.key_column_usage kcu\n ON tc.constraint_name = kcu.constraint_name\nJOIN information_schema.constraint_column_usage ccu\n ON ccu.constraint_name = tc.constraint_name\nWHERE tc.constraint_type = 'FOREIGN KEY'\nORDER BY tc.table_name, kcu.column_name;" },
{ name: 'Indexes', sql: "SELECT tablename, indexname, indexdef\nFROM pg_indexes\nWHERE schemaname = 'public'\nORDER BY tablename, indexname;" },
]
},
{
category: 'Statistics',
items: [
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
]
},
]
},
// ── Core ──
{
category: 'Statistics',
items: [
{ name: 'Table row counts', sql: "SELECT relname AS table_name,\n n_live_tup AS row_count\nFROM pg_stat_user_tables\nORDER BY n_live_tup DESC;" },
{ name: 'Table sizes', sql: "SELECT relname AS table_name,\n pg_size_pretty(pg_total_relation_size(relid)) AS total_size,\n pg_size_pretty(pg_relation_size(relid)) AS data_size,\n pg_size_pretty(pg_indexes_size(relid)) AS index_size\nFROM pg_catalog.pg_statio_user_tables\nORDER BY pg_total_relation_size(relid) DESC;" },
{ name: 'Database size', sql: "SELECT pg_size_pretty(pg_database_size(current_database())) AS db_size,\n current_database() AS db_name,\n version() AS pg_version;" },
label: 'Core',
groups: [
{
category: 'Tenancy',
items: [
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
{ name: 'Merchant domains', sql: "SELECT md.id, m.name AS merchant_name,\n md.domain, md.is_primary, md.is_active,\n md.ssl_status, md.is_verified\nFROM merchant_domains md\nJOIN merchants m ON m.id = md.merchant_id\nORDER BY m.name, md.domain;" },
{ name: 'Store domains', sql: "SELECT sd.id, s.name AS store_name,\n sd.domain, sd.is_primary, sd.is_active,\n sd.ssl_status, sd.is_verified\nFROM store_domains sd\nJOIN stores s ON s.id = sd.store_id\nORDER BY s.name, sd.domain;" },
]
},
{
category: 'Permissions',
items: [
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
]
},
{
category: 'Admin & Audit',
items: [
{ name: 'Audit log', sql: "SELECT al.id, u.email AS admin_email,\n al.action, al.target_type, al.target_id,\n al.ip_address, al.created_at\nFROM admin_audit_logs al\nJOIN users u ON u.id = al.admin_user_id\nORDER BY al.created_at DESC\nLIMIT 100;" },
{ name: 'Active sessions', sql: "SELECT s.id, u.email AS admin_email,\n s.ip_address, s.login_at, s.last_activity_at,\n s.is_active, s.logout_reason\nFROM admin_sessions s\nJOIN users u ON u.id = s.admin_user_id\nORDER BY s.last_activity_at DESC\nLIMIT 50;" },
{ name: 'Admin settings', sql: "SELECT id, key, value, value_type,\n category, is_encrypted, is_public\nFROM admin_settings\nORDER BY category, key;" },
{ name: 'Platform alerts', sql: "SELECT id, alert_type, severity, title,\n is_resolved, occurrence_count,\n first_occurred_at, last_occurred_at\nFROM platform_alerts\nORDER BY last_occurred_at DESC\nLIMIT 50;" },
{ name: 'Application logs', sql: "SELECT id, timestamp, level, logger_name,\n module, message, exception_type,\n request_id\nFROM application_logs\nORDER BY timestamp DESC\nLIMIT 100;" },
]
},
{
category: 'Customers',
items: [
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
{ name: 'Password reset tokens', sql: "SELECT prt.id, c.email AS customer_email,\n prt.expires_at, prt.used_at, prt.created_at\nFROM password_reset_tokens prt\nJOIN customers c ON c.id = prt.customer_id\nORDER BY prt.created_at DESC\nLIMIT 50;" },
]
},
{
category: 'Messaging',
items: [
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Conversations', sql: "SELECT cv.id, cv.conversation_type, cv.subject,\n s.name AS store_name, cv.is_closed,\n cv.message_count, cv.last_message_at\nFROM conversations cv\nLEFT JOIN stores s ON s.id = cv.store_id\nORDER BY cv.last_message_at DESC\nLIMIT 50;" },
{ name: 'Messages', sql: "SELECT m.id, m.conversation_id,\n m.sender_type, m.sender_id,\n LEFT(m.content, 100) AS content_preview,\n m.is_system_message, m.created_at\nFROM messages m\nORDER BY m.created_at DESC\nLIMIT 100;" },
{ name: 'Message attachments', sql: "SELECT ma.id, ma.message_id,\n ma.original_filename, ma.mime_type,\n ma.file_size, ma.is_image, ma.created_at\nFROM message_attachments ma\nORDER BY ma.created_at DESC\nLIMIT 50;" },
{ name: 'Store email templates', sql: "SELECT set_.id, s.name AS store_name,\n set_.template_code, set_.language,\n set_.name, set_.is_active\nFROM store_email_templates set_\nJOIN stores s ON s.id = set_.store_id\nORDER BY s.name, set_.template_code;" },
{ name: 'Store email settings', sql: "SELECT ses.id, s.name AS store_name,\n ses.from_email, ses.from_name, ses.provider,\n ses.is_configured, ses.is_verified\nFROM store_email_settings ses\nJOIN stores s ON s.id = ses.store_id\nORDER BY s.name;" },
]
},
{
category: 'CMS',
items: [
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
]
},
{
category: 'Billing',
items: [
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
{ name: 'Tier feature limits', sql: "SELECT tfl.id, st.code AS tier_code,\n st.name AS tier_name, tfl.feature_code,\n tfl.limit_value\nFROM tier_feature_limits tfl\nJOIN subscription_tiers st ON st.id = tfl.tier_id\nORDER BY st.code, tfl.feature_code;" },
{ name: 'Merchant feature overrides', sql: "SELECT mfo.id, m.name AS merchant_name,\n p.code AS platform_code, mfo.feature_code,\n mfo.limit_value, mfo.is_enabled, mfo.reason\nFROM merchant_feature_overrides mfo\nJOIN merchants m ON m.id = mfo.merchant_id\nJOIN platforms p ON p.id = mfo.platform_id\nORDER BY m.name, mfo.feature_code;" },
{ name: 'Store add-ons', sql: "SELECT sa.id, s.name AS store_name,\n ap.name AS addon_name, sa.status,\n sa.quantity, sa.domain_name,\n sa.period_start, sa.period_end\nFROM store_addons sa\nJOIN stores s ON s.id = sa.store_id\nJOIN addon_products ap ON ap.id = sa.addon_product_id\nORDER BY sa.id DESC;" },
{ name: 'Stripe webhook events', sql: "SELECT swe.id, swe.event_id, swe.event_type,\n swe.status, swe.processed_at,\n s.name AS store_name, swe.error_message\nFROM stripe_webhook_events swe\nLEFT JOIN stores s ON s.id = swe.store_id\nORDER BY swe.created_at DESC\nLIMIT 50;" },
]
},
]
},
// ── OMS ──
{
category: 'Tenancy',
items: [
{ name: 'Users', sql: "SELECT id, email, username, role, is_active,\n last_login, created_at\nFROM users\nORDER BY id\nLIMIT 50;" },
{ name: 'Merchants', sql: "SELECT m.id, m.name,\n u.email AS owner_email, m.contact_email,\n m.is_active, m.is_verified, m.created_at\nFROM merchants m\nJOIN users u ON u.id = m.owner_user_id\nORDER BY m.id\nLIMIT 50;" },
{ name: 'Stores', sql: "SELECT id, store_code, name, merchant_id,\n subdomain, is_active, is_verified\nFROM stores\nORDER BY id\nLIMIT 50;" },
{ name: 'Platforms', sql: "SELECT id, code, name, domain, path_prefix,\n default_language, is_active, is_public\nFROM platforms\nORDER BY id;" },
{ name: 'Customers', sql: "SELECT id, store_id, email, first_name, last_name,\n is_active, created_at\nFROM customers\nORDER BY id\nLIMIT 50;" },
label: 'OMS',
groups: [
{
category: 'Orders',
items: [
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
{ name: 'Order item exceptions', sql: "SELECT oie.id, o.order_number,\n oie.original_product_name, oie.original_gtin,\n oie.exception_type, oie.status,\n oie.resolved_at, oie.created_at\nFROM order_item_exceptions oie\nJOIN order_items oi ON oi.id = oie.order_item_id\nJOIN orders o ON o.id = oi.order_id\nORDER BY oie.created_at DESC\nLIMIT 50;" },
{ name: 'Invoice settings', sql: "SELECT sis.id, s.name AS store_name,\n sis.merchant_name, sis.vat_number,\n sis.is_vat_registered, sis.invoice_prefix,\n sis.invoice_next_number, sis.default_vat_rate\nFROM store_invoice_settings sis\nJOIN stores s ON s.id = sis.store_id\nORDER BY s.name;" },
{ name: 'Customer order stats', sql: "SELECT cos.id, s.name AS store_name,\n c.email AS customer_email, cos.total_orders,\n cos.total_spent_cents, cos.first_order_date,\n cos.last_order_date\nFROM customer_order_stats cos\nJOIN stores s ON s.id = cos.store_id\nJOIN customers c ON c.id = cos.customer_id\nORDER BY cos.total_spent_cents DESC\nLIMIT 50;" },
]
},
{
category: 'Cart',
items: [
{ name: 'Cart items', sql: "SELECT ci.id, s.name AS store_name,\n p.store_sku, ci.session_id,\n ci.quantity, ci.price_at_add_cents,\n ci.created_at\nFROM cart_items ci\nJOIN stores s ON s.id = ci.store_id\nJOIN products p ON p.id = ci.product_id\nORDER BY ci.created_at DESC\nLIMIT 50;" },
]
},
{
category: 'Catalog',
items: [
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
]
},
{
category: 'Inventory',
items: [
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
]
},
{
category: 'Marketplace',
items: [
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
{ name: 'Import errors', sql: "SELECT mie.id, mij.marketplace,\n mie.row_number, mie.identifier,\n mie.error_type, mie.error_message\nFROM marketplace_import_errors mie\nJOIN marketplace_import_jobs mij ON mij.id = mie.import_job_id\nORDER BY mie.created_at DESC\nLIMIT 100;" },
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
{ name: 'Product translations', sql: "SELECT mpt.id, mp.gtin,\n mpt.language, mpt.title, mpt.url_slug\nFROM marketplace_product_translations mpt\nJOIN marketplace_products mp ON mp.id = mpt.marketplace_product_id\nORDER BY mpt.id DESC\nLIMIT 50;" },
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
{ name: 'Letzshop credentials', sql: "SELECT slc.id, s.name AS store_name,\n slc.api_endpoint, slc.auto_sync_enabled,\n slc.sync_interval_minutes, slc.last_sync_at,\n slc.last_sync_status\nFROM store_letzshop_credentials slc\nJOIN stores s ON s.id = slc.store_id\nORDER BY s.name;" },
{ name: 'Sync logs', sql: "SELECT sl.id, s.name AS store_name,\n sl.operation_type, sl.direction, sl.status,\n sl.records_processed, sl.records_failed,\n sl.duration_seconds, sl.triggered_by\nFROM letzshop_sync_logs sl\nJOIN stores s ON s.id = sl.store_id\nORDER BY sl.created_at DESC\nLIMIT 50;" },
{ name: 'Historical import jobs', sql: "SELECT hij.id, s.name AS store_name,\n hij.status, hij.current_phase,\n hij.orders_imported, hij.orders_skipped,\n hij.products_matched, hij.products_not_found\nFROM letzshop_historical_import_jobs hij\nJOIN stores s ON s.id = hij.store_id\nORDER BY hij.created_at DESC\nLIMIT 50;" },
]
},
]
},
// ── Loyalty ──
{
category: 'Permissions',
items: [
{ name: 'Roles per store', sql: "SELECT r.id, r.store_id, s.name AS store_name,\n r.name AS role_name, r.permissions\nFROM roles r\nJOIN stores s ON s.id = r.store_id\nORDER BY s.name, r.name;" },
{ name: 'Store team members', sql: "SELECT su.id, su.store_id, s.name AS store_name,\n u.email, u.username, r.name AS role_name,\n su.is_active, su.invitation_accepted_at\nFROM store_users su\nJOIN stores s ON s.id = su.store_id\nJOIN users u ON u.id = su.user_id\nLEFT JOIN roles r ON r.id = su.role_id\nORDER BY s.name, u.email\nLIMIT 100;" },
{ name: 'Admin platform assignments', sql: "SELECT ap.id, u.email, u.username, u.role,\n p.code AS platform_code, p.name AS platform_name,\n ap.is_active, ap.assigned_at\nFROM admin_platforms ap\nJOIN users u ON u.id = ap.user_id\nJOIN platforms p ON p.id = ap.platform_id\nORDER BY u.email, p.code;" },
{ name: 'Platform modules', sql: "SELECT pm.id, p.code AS platform_code,\n pm.module_code, pm.is_enabled,\n pm.enabled_at, pm.disabled_at\nFROM platform_modules pm\nJOIN platforms p ON p.id = pm.platform_id\nORDER BY p.code, pm.module_code;" },
{ name: 'Store platforms', sql: "SELECT sp.id, s.name AS store_name,\n p.code AS platform_code,\n sp.is_active, sp.custom_subdomain, sp.joined_at\nFROM store_platforms sp\nJOIN stores s ON s.id = sp.store_id\nJOIN platforms p ON p.id = sp.platform_id\nORDER BY s.name, p.code;" },
label: 'Loyalty',
groups: [
{
category: 'Loyalty',
items: [
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
{ name: 'Apple device registrations', sql: "SELECT adr.id, lc.card_number,\n adr.device_library_identifier,\n adr.push_token, adr.created_at\nFROM apple_device_registrations adr\nJOIN loyalty_cards lc ON lc.id = adr.card_id\nORDER BY adr.created_at DESC\nLIMIT 50;" },
{ name: 'Merchant loyalty settings', sql: "SELECT mls.id, m.name AS merchant_name,\n mls.staff_pin_policy,\n mls.allow_self_enrollment,\n mls.allow_void_transactions,\n mls.allow_cross_location_redemption,\n mls.require_order_reference\nFROM merchant_loyalty_settings mls\nJOIN merchants m ON m.id = mls.merchant_id\nORDER BY m.name;" },
]
},
]
},
// ── Hosting ──
{
category: 'System',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
label: 'Hosting',
groups: [
{
category: 'Hosting',
items: [
{ name: 'Hosted sites', sql: "SELECT hs.id, s.name AS store_name,\n hs.business_name, hs.status,\n hs.contact_email, hs.live_domain,\n hs.went_live_at, hs.created_at\nFROM hosted_sites hs\nLEFT JOIN stores s ON s.id = hs.store_id\nORDER BY hs.created_at DESC;" },
{ name: 'Client services', sql: "SELECT cs.id, hs.business_name,\n cs.service_type, cs.name, cs.status,\n cs.billing_period, cs.price_cents,\n cs.domain_name, cs.expires_at\nFROM client_services cs\nJOIN hosted_sites hs ON hs.id = cs.hosted_site_id\nORDER BY hs.business_name, cs.service_type;" },
]
},
{
category: 'Prospecting',
items: [
{ name: 'Prospects', sql: "SELECT id, channel, business_name,\n domain_name, status, source,\n city, country, created_at\nFROM prospects\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Prospect contacts', sql: "SELECT pc.id, p.business_name,\n pc.contact_type, pc.value, pc.label,\n pc.is_primary, pc.is_validated\nFROM prospect_contacts pc\nJOIN prospects p ON p.id = pc.prospect_id\nORDER BY pc.created_at DESC\nLIMIT 50;" },
{ name: 'Tech profiles', sql: "SELECT tp.id, p.business_name,\n tp.cms, tp.cms_version, tp.server,\n tp.hosting_provider, tp.ecommerce_platform,\n tp.has_valid_cert\nFROM prospect_tech_profiles tp\nJOIN prospects p ON p.id = tp.prospect_id\nORDER BY tp.created_at DESC\nLIMIT 50;" },
{ name: 'Performance profiles', sql: "SELECT pp.id, p.business_name,\n pp.performance_score, pp.accessibility_score,\n pp.seo_score, pp.is_mobile_friendly,\n pp.total_bytes, pp.total_requests\nFROM prospect_performance_profiles pp\nJOIN prospects p ON p.id = pp.prospect_id\nORDER BY pp.created_at DESC\nLIMIT 50;" },
{ name: 'Interactions', sql: "SELECT pi.id, p.business_name,\n pi.interaction_type, pi.subject,\n pi.outcome, pi.next_action,\n pi.next_action_date, pi.created_at\nFROM prospect_interactions pi\nJOIN prospects p ON p.id = pi.prospect_id\nORDER BY pi.created_at DESC\nLIMIT 50;" },
{ name: 'Scan jobs', sql: "SELECT id, job_type, status,\n total_items, processed_items, failed_items,\n started_at, completed_at\nFROM prospect_scan_jobs\nORDER BY created_at DESC\nLIMIT 50;" },
{ name: 'Security audits', sql: "SELECT psa.id, p.business_name,\n psa.score, psa.grade,\n psa.findings_count_critical,\n psa.findings_count_high,\n psa.has_https, psa.has_valid_ssl\nFROM prospect_security_audits psa\nJOIN prospects p ON p.id = psa.prospect_id\nORDER BY psa.created_at DESC\nLIMIT 50;" },
{ name: 'Prospect scores', sql: "SELECT ps.id, p.business_name,\n ps.score, ps.lead_tier,\n ps.technical_health_score,\n ps.modernity_score,\n ps.business_value_score,\n ps.engagement_score\nFROM prospect_scores ps\nJOIN prospects p ON p.id = ps.prospect_id\nORDER BY ps.score DESC\nLIMIT 50;" },
{ name: 'Campaign templates', sql: "SELECT id, name, lead_type,\n channel, language, is_active\nFROM campaign_templates\nORDER BY lead_type, channel;" },
{ name: 'Campaign sends', sql: "SELECT cs.id, ct.name AS template_name,\n p.business_name, cs.channel,\n cs.status, cs.sent_at\nFROM campaign_sends cs\nJOIN campaign_templates ct ON ct.id = cs.template_id\nJOIN prospects p ON p.id = cs.prospect_id\nORDER BY cs.created_at DESC\nLIMIT 50;" },
]
},
]
},
// ── Internal ──
{
category: 'Loyalty',
items: [
{ name: 'Programs', sql: "SELECT lp.id, lp.merchant_id, lp.loyalty_type,\n lp.stamps_target, lp.points_per_euro, lp.is_active,\n lp.activated_at, lp.created_at\nFROM loyalty_programs lp\nORDER BY lp.id DESC;" },
{ name: 'Cards', sql: "SELECT lc.id, lc.card_number, c.email AS customer_email,\n lc.stamp_count, lc.points_balance, lc.is_active,\n lc.last_activity_at, lc.created_at\nFROM loyalty_cards lc\nJOIN customers c ON c.id = lc.customer_id\nORDER BY lc.id DESC\nLIMIT 100;" },
{ name: 'Transactions', sql: "SELECT lt.id, lt.transaction_type, lc.card_number,\n lt.stamps_delta, lt.points_delta,\n lt.purchase_amount_cents, lt.transaction_at\nFROM loyalty_transactions lt\nJOIN loyalty_cards lc ON lc.id = lt.card_id\nORDER BY lt.transaction_at DESC\nLIMIT 100;" },
{ name: 'Staff PINs', sql: "SELECT sp.id, sp.name, sp.staff_id,\n s.name AS store_name, sp.is_active,\n sp.last_used_at, sp.created_at\nFROM staff_pins sp\nJOIN stores s ON s.id = sp.store_id\nORDER BY sp.id DESC;" },
]
},
{
category: 'Billing',
items: [
{ name: 'Subscription tiers', sql: "SELECT st.id, p.code AS platform_code,\n st.code, st.name, st.price_monthly_cents,\n st.price_annual_cents, st.is_active, st.is_public\nFROM subscription_tiers st\nJOIN platforms p ON p.id = st.platform_id\nORDER BY p.code, st.display_order;" },
{ name: 'Merchant subscriptions', sql: "SELECT ms.id, m.name AS merchant_name,\n p.code AS platform_code, st.name AS tier_name,\n ms.status, ms.is_annual,\n ms.stripe_customer_id, ms.stripe_subscription_id\nFROM merchant_subscriptions ms\nJOIN merchants m ON m.id = ms.merchant_id\nJOIN platforms p ON p.id = ms.platform_id\nLEFT JOIN subscription_tiers st ON st.id = ms.tier_id\nORDER BY ms.id DESC;" },
{ name: 'Billing history', sql: "SELECT bh.id, s.name AS store_name,\n bh.invoice_number, bh.total_cents,\n bh.currency, bh.status, bh.invoice_date\nFROM billing_history bh\nJOIN stores s ON s.id = bh.store_id\nORDER BY bh.invoice_date DESC\nLIMIT 50;" },
{ name: 'Add-on products', sql: "SELECT id, code, name, category,\n price_cents, billing_period, is_active\nFROM addon_products\nORDER BY display_order;" },
]
},
{
category: 'Orders',
items: [
{ name: 'Recent orders', sql: "SELECT o.id, s.name AS store_name,\n o.order_number, o.status, o.total_amount_cents,\n o.customer_email, o.order_date\nFROM orders o\nJOIN stores s ON s.id = o.store_id\nORDER BY o.order_date DESC\nLIMIT 50;" },
{ name: 'Order items', sql: "SELECT oi.id, o.order_number, oi.product_name,\n oi.product_sku, oi.quantity, oi.unit_price_cents,\n oi.item_state\nFROM order_items oi\nJOIN orders o ON o.id = oi.order_id\nORDER BY oi.id DESC\nLIMIT 100;" },
{ name: 'Invoices', sql: "SELECT i.id, s.name AS store_name,\n i.invoice_number, i.status,\n i.total_cents, i.invoice_date\nFROM invoices i\nJOIN stores s ON s.id = i.store_id\nORDER BY i.invoice_date DESC\nLIMIT 50;" },
]
},
{
category: 'Catalog',
items: [
{ name: 'Products', sql: "SELECT p.id, s.name AS store_name,\n p.store_sku, p.gtin, p.price_cents,\n p.availability, p.is_active\nFROM products p\nJOIN stores s ON s.id = p.store_id\nORDER BY p.id DESC\nLIMIT 50;" },
{ name: 'Product translations', sql: "SELECT pt.id, p.store_sku,\n pt.language, pt.title\nFROM product_translations pt\nJOIN products p ON p.id = pt.product_id\nORDER BY pt.id DESC\nLIMIT 50;" },
{ name: 'Product media', sql: "SELECT pm.id, p.store_sku,\n pm.usage_type, pm.display_order\nFROM product_media pm\nJOIN products p ON p.id = pm.product_id\nORDER BY pm.id DESC\nLIMIT 50;" },
]
},
{
category: 'Customers',
items: [
{ name: 'Customers', sql: "SELECT c.id, s.name AS store_name,\n c.email, c.first_name, c.last_name,\n c.customer_number, c.is_active\nFROM customers c\nJOIN stores s ON s.id = c.store_id\nORDER BY c.id DESC\nLIMIT 50;" },
{ name: 'Customer addresses', sql: "SELECT ca.id, c.email AS customer_email,\n ca.address_type, ca.city, ca.country_iso,\n ca.is_default\nFROM customer_addresses ca\nJOIN customers c ON c.id = ca.customer_id\nORDER BY ca.id DESC\nLIMIT 50;" },
]
},
{
category: 'Inventory',
items: [
{ name: 'Stock levels', sql: "SELECT inv.id, s.name AS store_name,\n p.store_sku, inv.quantity,\n inv.reserved_quantity, inv.warehouse\nFROM inventory inv\nJOIN products p ON p.id = inv.product_id\nJOIN stores s ON s.id = inv.store_id\nORDER BY inv.id DESC;" },
{ name: 'Recent transactions', sql: "SELECT it.id, p.store_sku,\n it.transaction_type, it.quantity_change,\n it.order_number, it.created_at\nFROM inventory_transactions it\nJOIN products p ON p.id = it.product_id\nORDER BY it.created_at DESC\nLIMIT 100;" },
]
},
{
category: 'CMS',
items: [
{ name: 'Content pages', sql: "SELECT cp.id, cp.slug, cp.title,\n cp.is_published, cp.is_platform_page,\n s.name AS store_name, p.code AS platform_code\nFROM content_pages cp\nLEFT JOIN stores s ON s.id = cp.store_id\nLEFT JOIN platforms p ON p.id = cp.platform_id\nORDER BY cp.id DESC\nLIMIT 50;" },
{ name: 'Media files', sql: "SELECT mf.id, s.name AS store_name,\n mf.filename, mf.media_type,\n mf.file_size, mf.usage_count\nFROM media_files mf\nJOIN stores s ON s.id = mf.store_id\nORDER BY mf.id DESC\nLIMIT 50;" },
{ name: 'Store themes', sql: "SELECT st.id, s.name AS store_name,\n st.theme_name, st.is_active, st.layout_style\nFROM store_themes st\nJOIN stores s ON s.id = st.store_id\nORDER BY st.id DESC;" },
]
},
{
category: 'Messaging',
items: [
{ name: 'Email templates', sql: "SELECT id, code, language, name,\n category, is_active\nFROM email_templates\nORDER BY category, code, language;" },
{ name: 'Email logs', sql: "SELECT id, recipient_email, subject,\n status, sent_at, provider\nFROM email_logs\nORDER BY sent_at DESC\nLIMIT 50;" },
{ name: 'Admin notifications', sql: "SELECT id, type, priority, title,\n is_read, action_required, created_at\nFROM admin_notifications\nORDER BY created_at DESC\nLIMIT 50;" },
]
},
{
category: 'Marketplace',
items: [
{ name: 'Import jobs', sql: "SELECT mij.id, s.name AS store_name,\n mij.marketplace, mij.status,\n mij.imported_count, mij.error_count,\n mij.created_at\nFROM marketplace_import_jobs mij\nJOIN stores s ON s.id = mij.store_id\nORDER BY mij.created_at DESC\nLIMIT 50;" },
{ name: 'Marketplace products', sql: "SELECT id, marketplace, brand, gtin,\n price_cents, availability, is_active\nFROM marketplace_products\nORDER BY id DESC\nLIMIT 50;" },
{ name: 'Fulfillment queue', sql: "SELECT fq.id, s.name AS store_name,\n fq.operation, fq.status, fq.attempts,\n fq.error_message, fq.created_at\nFROM letzshop_fulfillment_queue fq\nJOIN stores s ON s.id = fq.store_id\nORDER BY fq.created_at DESC\nLIMIT 50;" },
label: 'Internal',
groups: [
{
category: 'System',
items: [
{ name: 'Alembic versions', sql: "SELECT * FROM alembic_version\nORDER BY version_num;" },
{ name: 'Menu configs', sql: "SELECT amc.id, amc.frontend_type,\n p.code AS platform_code, u.email,\n amc.menu_item_id, amc.is_visible\nFROM admin_menu_configs amc\nLEFT JOIN platforms p ON p.id = amc.platform_id\nLEFT JOIN users u ON u.id = amc.user_id\nORDER BY amc.frontend_type, amc.menu_item_id;" },
]
},
{
category: 'Monitoring',
items: [
{ name: 'Capacity snapshots', sql: "SELECT id, snapshot_date,\n active_stores, total_products,\n total_orders_month, total_team_members,\n db_size_mb, avg_response_ms,\n peak_cpu_percent, peak_memory_percent\nFROM capacity_snapshots\nORDER BY snapshot_date DESC\nLIMIT 30;" },
]
},
{
category: 'Dev Tools',
items: [
{ name: 'Test runs', sql: "SELECT id, timestamp, status,\n total_tests, passed, failed, errors,\n coverage_percent, duration_seconds,\n git_branch\nFROM test_runs\nORDER BY timestamp DESC\nLIMIT 30;" },
{ name: 'Architecture scans', sql: "SELECT id, timestamp, validator_type,\n status, total_files, total_violations,\n errors, warnings, duration_seconds\nFROM architecture_scans\nORDER BY timestamp DESC\nLIMIT 30;" },
{ name: 'Architecture violations', sql: "SELECT av.id, av.rule_id, av.rule_name,\n av.severity, av.file_path, av.line_number,\n av.status, av.message\nFROM architecture_violations av\nORDER BY av.created_at DESC\nLIMIT 100;" },
]
},
]
},
],
get filteredPresetSections() {
const q = this.presetSearch.toLowerCase().trim();
if (!q) return this.presetSections;
const filtered = [];
for (const section of this.presetSections) {
const groups = [];
for (const group of section.groups) {
const items = group.items.filter(
item => item.name.toLowerCase().includes(q)
|| group.category.toLowerCase().includes(q)
|| section.label.toLowerCase().includes(q)
);
if (items.length > 0) {
groups.push({ ...group, items });
}
}
if (groups.length > 0) {
filtered.push({ ...section, groups });
}
}
return filtered;
},
toggleCategory(category) {
this.expandedCategories[category] = !this.expandedCategories[category];
},
isCategoryExpanded(category) {
if (this.presetSearch.trim()) return true;
return this.expandedCategories[category] || false;
},

View File

@@ -24,24 +24,37 @@
<span x-html="$icon(showPresets ? 'chevron-up' : 'chevron-down', 'w-4 h-4')"></span>
</button>
<div x-show="showPresets" x-collapse class="mt-3">
<template x-for="group in presetQueries" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<span x-text="group.category"></span>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</button>
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
<template x-for="preset in group.items" :key="preset.name">
<li @click="loadPreset(preset)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
<span class="truncate" x-text="preset.name"></span>
</li>
</template>
</ul>
<!-- Search filter -->
<div class="mb-2">
<input type="text" x-model="presetSearch" placeholder="Filter presets..."
class="w-full text-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1.5 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<template x-for="section in filteredPresetSections" :key="section.label">
<div class="mb-2">
<div class="text-[10px] font-bold text-indigo-500 dark:text-indigo-400 uppercase tracking-widest px-2 py-1"
x-text="section.label"></div>
<template x-for="group in section.groups" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
<span x-text="group.category"></span>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</button>
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
<template x-for="preset in group.items" :key="preset.name">
<li @click="loadPreset(preset)"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm cursor-pointer text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<span x-html="$icon('document-text', 'w-3.5 h-3.5 flex-shrink-0')"></span>
<span class="truncate" x-text="preset.name"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</template>
<div x-show="presetSearch && filteredPresetSections.length === 0"
class="text-xs text-gray-400 px-2 py-2">No matching presets.</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import logging
from math import ceil
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
@@ -22,13 +23,90 @@ from app.modules.hosting.schemas.hosted_site import (
HostedSiteUpdate,
SendProposalRequest,
)
from app.modules.hosting.schemas.template import TemplateListResponse, TemplateResponse
from app.modules.hosting.services.hosted_site_service import hosted_site_service
from app.modules.hosting.services.poc_builder_service import poc_builder_service
from app.modules.hosting.services.template_service import template_service
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/sites")
logger = logging.getLogger(__name__)
@router.get("/templates", response_model=TemplateListResponse)
def list_templates(
current_admin: UserContext = Depends(get_current_admin_api),
):
"""List available industry templates for POC site generation."""
templates = template_service.list_templates()
return TemplateListResponse(
templates=[TemplateResponse(**t) for t in templates],
)
class PreviewUrlResponse(BaseModel):
"""Response with signed preview URL."""
preview_url: str
expires_in_hours: int = 24
@router.get("/sites/{site_id}/preview-url", response_model=PreviewUrlResponse)
def get_preview_url(
site_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Generate a signed preview URL for a hosted site."""
from app.core.preview_token import create_preview_token
site = hosted_site_service.get_by_id(db, site_id)
store = site.store
subdomain = store.subdomain or store.store_code
token = create_preview_token(store.id, subdomain, site.id)
return PreviewUrlResponse(
preview_url=f"/storefront/{subdomain}/?_preview={token}",
)
class BuildPocRequest(BaseModel):
"""Request to build a POC site from prospect + template."""
prospect_id: int
template_id: str
merchant_id: int | None = None
site_id: int | None = None # If set, populate existing site instead of creating new one
class BuildPocResponse(BaseModel):
"""Response from POC builder."""
hosted_site_id: int
store_id: int
pages_created: int
theme_applied: bool
template_id: str
subdomain: str | None = None
@router.post("/poc/build", response_model=BuildPocResponse)
def build_poc(
data: BuildPocRequest,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Build a POC site from prospect data + industry template."""
result = poc_builder_service.build_poc(
db,
prospect_id=data.prospect_id,
template_id=data.template_id,
merchant_id=data.merchant_id,
site_id=data.site_id,
)
db.commit()
return BuildPocResponse(**result)
def _to_response(site) -> HostedSiteResponse:
"""Convert a hosted site model to response schema."""
return HostedSiteResponse(
@@ -96,17 +174,6 @@ def create_site(
return _to_response(site)
@router.post("/from-prospect/{prospect_id}", response_model=HostedSiteResponse)
def create_from_prospect(
prospect_id: int = Path(...),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""Create a hosted site pre-filled from prospect data."""
site = hosted_site_service.create_from_prospect(db, prospect_id)
db.commit()
return _to_response(site)
@router.put("/{site_id}", response_model=HostedSiteResponse)
def update_site(

View File

@@ -2,45 +2,73 @@
"""
Hosting Public Page Routes.
Public-facing routes for POC site viewing:
- POC Viewer - Shows the Store's storefront with a HostWizard preview banner
POC site preview via signed URL redirect to the storefront.
The StorefrontAccessMiddleware validates the preview token and
allows rendering without an active subscription.
"""
from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.templates_config import templates
from app.core.preview_token import create_preview_token
router = APIRouter()
@router.get(
"/hosting/sites/{site_id}/preview",
response_class=HTMLResponse,
include_in_schema=False,
)
async def poc_site_viewer(
request: Request,
site_id: int = Path(..., description="Hosted Site ID"),
page: str = Query("homepage", description="Page slug to preview"),
db: Session = Depends(get_db),
):
"""Render POC site viewer with HostWizard preview banner."""
"""Redirect to storefront with signed preview token.
Generates a time-limited JWT and redirects to the store's
storefront URL. The StorefrontAccessMiddleware validates the
token and bypasses the subscription check.
"""
from app.modules.hosting.models import HostedSite, HostedSiteStatus
site = db.query(HostedSite).filter(HostedSite.id == site_id).first()
# Only allow viewing for poc_ready or proposal_sent sites
if not site or site.status not in (HostedSiteStatus.POC_READY, HostedSiteStatus.PROPOSAL_SENT):
if not site or site.status not in (
HostedSiteStatus.POC_READY,
HostedSiteStatus.PROPOSAL_SENT,
HostedSiteStatus.ACCEPTED,
):
return HTMLResponse(content="<h1>Site not available for preview</h1>", status_code=404)
context = {
"request": request,
"site": site,
"store_url": f"/stores/{site.store.subdomain}" if site.store else "#",
}
return templates.TemplateResponse(
"hosting/public/poc-viewer.html",
context,
store = site.store
if not store:
return HTMLResponse(content="<h1>Store not found</h1>", status_code=404)
# Generate signed preview token — use subdomain for URL routing
subdomain = store.subdomain or store.store_code
token = create_preview_token(store.id, subdomain, site.id)
# Get platform code for dev-mode URL prefix
from app.core.config import settings
from app.modules.tenancy.models import StorePlatform
store_platform = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == store.id)
.first()
)
# In dev mode, storefront needs /platforms/{code}/ prefix
if settings.debug and store_platform and store_platform.platform:
platform_code = store_platform.platform.code
base_url = f"/platforms/{platform_code}/storefront/{subdomain}"
else:
base_url = f"/storefront/{subdomain}"
# Append page slug — storefront needs /{slug} (root has no catch-all)
base_url += f"/{page}"
return RedirectResponse(f"{base_url}?_preview={token}", status_code=302)

View File

@@ -3,18 +3,31 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class HostedSiteCreate(BaseModel):
"""Schema for creating a hosted site."""
"""Schema for creating a hosted site.
Either merchant_id or prospect_id must be provided:
- merchant_id: store is created under this merchant
- prospect_id: a merchant is auto-created from prospect data
"""
business_name: str = Field(..., max_length=255)
merchant_id: int | None = None
prospect_id: int | None = None
contact_name: str | None = Field(None, max_length=255)
contact_email: str | None = Field(None, max_length=255)
contact_phone: str | None = Field(None, max_length=50)
internal_notes: str | None = None
@model_validator(mode="after")
def require_merchant_or_prospect(self) -> "HostedSiteCreate":
if not self.merchant_id and not self.prospect_id:
raise ValueError("Either merchant_id or prospect_id is required")
return self
class HostedSiteUpdate(BaseModel):
"""Schema for updating a hosted site."""

View File

@@ -0,0 +1,21 @@
# app/modules/hosting/schemas/template.py
"""Pydantic schemas for template responses."""
from pydantic import BaseModel
class TemplateResponse(BaseModel):
"""Schema for a single template."""
id: str
name: str
description: str
tags: list[str] = []
languages: list[str] = []
pages: list[str] = []
class TemplateListResponse(BaseModel):
"""Schema for template list response."""
templates: list[TemplateResponse]

View File

@@ -34,12 +34,30 @@ ALLOWED_TRANSITIONS: dict[HostedSiteStatus, list[HostedSiteStatus]] = {
}
def _slugify(name: str) -> str:
"""Generate a URL-safe slug from a business name."""
def _slugify(name: str, max_length: int = 30) -> str:
"""Generate a short URL-safe slug from a domain or business name.
Priority: domain name (clean) > first 3 words of business name > full slug truncated.
"""
slug = name.lower().strip()
# If it looks like a domain, extract the hostname part
for prefix in ["https://", "http://", "www."]:
if slug.startswith(prefix):
slug = slug[len(prefix):]
slug = slug.rstrip("/")
if "." in slug and " " not in slug:
# Domain: remove TLD → batirenovation-strasbourg.fr → batirenovation-strasbourg
slug = slug.rsplit(".", 1)[0]
else:
# Business name: take first 3 meaningful words for brevity
words = re.sub(r"[^a-z0-9\s]", "", slug).split()
# Skip filler words
filler = {"the", "le", "la", "les", "de", "du", "des", "et", "and", "und", "die", "der", "das"}
words = [w for w in words if w not in filler][:3]
slug = " ".join(words)
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
slug = re.sub(r"[\s-]+", "-", slug)
return slug.strip("-")[:50]
return slug.strip("-")[:max_length]
class HostedSiteService:
@@ -88,50 +106,47 @@ class HostedSiteService:
return sites, total
def create(self, db: Session, data: dict) -> HostedSite:
"""Create a hosted site with an auto-created Store on the hosting platform."""
from app.modules.tenancy.models import Platform
"""Create a hosted site with an auto-created Store on the hosting platform.
Requires either merchant_id or prospect_id in data:
- merchant_id: store created under this merchant
- prospect_id: merchant auto-created from prospect data
"""
from app.modules.tenancy.models import Merchant, Platform, Store
from app.modules.tenancy.schemas.store import StoreCreate
from app.modules.tenancy.services.admin_service import admin_service
business_name = data["business_name"]
slug = _slugify(business_name)
merchant_id = data.get("merchant_id")
prospect_id = data.get("prospect_id")
# Prefer domain_name for slug (shorter, cleaner), fall back to business_name
slug_source = data.get("domain_name") or business_name
slug = _slugify(slug_source)
# Find hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if not platform:
raise ValueError("Hosting platform not found. Run init_production first.")
# Create a temporary merchant-less store requires a merchant_id.
# For POC sites we create a placeholder: the store is re-assigned on accept_proposal.
# Use the platform's own admin store or create under a system merchant.
# For now, create store via AdminService which handles defaults.
store_code = slug.upper().replace("-", "_")[:50]
subdomain = slug
# Resolve merchant
if merchant_id:
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
elif prospect_id:
merchant = self._create_merchant_from_prospect(db, prospect_id, data)
else:
raise ValueError("Either merchant_id or prospect_id is required")
# Check for duplicate subdomain
from app.modules.tenancy.models import Store
subdomain = slug
existing = db.query(Store).filter(Store.subdomain == subdomain).first()
if existing:
raise DuplicateSlugException(subdomain)
# We need a system merchant for POC sites.
# Look for one or create if needed.
from app.modules.tenancy.models import Merchant
system_merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
if not system_merchant:
system_merchant = Merchant(
name="HostWizard System",
contact_email="system@hostwizard.lu",
is_active=True,
is_verified=True,
)
db.add(system_merchant)
db.flush()
store_code = slug.upper().replace("-", "_")[:50]
store_data = StoreCreate(
merchant_id=system_merchant.id,
merchant_id=merchant.id,
store_code=store_code,
subdomain=subdomain,
name=business_name,
@@ -142,7 +157,7 @@ class HostedSiteService:
site = HostedSite(
store_id=store.id,
prospect_id=data.get("prospect_id"),
prospect_id=prospect_id,
status=HostedSiteStatus.DRAFT,
business_name=business_name,
contact_name=data.get("contact_name"),
@@ -153,12 +168,14 @@ class HostedSiteService:
db.add(site)
db.flush()
logger.info("Created hosted site: %s (store_id=%d)", site.display_name, store.id)
logger.info("Created hosted site: %s (store_id=%d, merchant_id=%d)", site.display_name, store.id, merchant.id)
return site
def create_from_prospect(self, db: Session, prospect_id: int) -> HostedSite:
"""Create a hosted site pre-filled from prospect data."""
def _create_merchant_from_prospect(self, db: Session, prospect_id: int, data: dict):
"""Create a merchant from prospect data."""
from app.modules.prospecting.models import Prospect
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
if not prospect:
@@ -166,20 +183,29 @@ class HostedSiteService:
raise ProspectNotFoundException(str(prospect_id))
# Get primary contact info from prospect contacts
# Get contact info: prefer form data, fall back to prospect contacts
contacts = prospect.contacts or []
primary_email = next((c.value for c in contacts if c.contact_type == "email"), None)
primary_phone = next((c.value for c in contacts if c.contact_type == "phone"), None)
contact_name = next((c.label for c in contacts if c.label), None)
email = (
data.get("contact_email")
or next((c.value for c in contacts if c.contact_type == "email"), None)
or f"contact-{prospect_id}@hostwizard.lu"
)
phone = data.get("contact_phone") or next(
(c.value for c in contacts if c.contact_type == "phone"), None
)
business_name = data.get("business_name") or prospect.business_name or prospect.domain_name
data = {
"business_name": prospect.business_name or prospect.domain_name or f"Prospect #{prospect.id}",
"contact_name": contact_name,
"contact_email": primary_email,
"contact_phone": primary_phone,
"prospect_id": prospect.id,
}
return self.create(db, data)
merchant_data = MerchantCreate(
name=business_name,
contact_email=email,
contact_phone=phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s from prospect %d", merchant.name, prospect_id)
return merchant
def update(self, db: Session, site_id: int, data: dict) -> HostedSite:
site = self.get_by_id(db, site_id)
@@ -192,8 +218,19 @@ class HostedSiteService:
return site
def delete(self, db: Session, site_id: int) -> bool:
"""Delete a hosted site and soft-delete the associated store."""
from app.core.soft_delete import soft_delete
site = self.get_by_id(db, site_id)
store = site.store
db.delete(site)
# Soft-delete the store created for this site (frees the subdomain)
if store:
soft_delete(db, store)
logger.info("Soft-deleted store %d (subdomain=%s) for site %d", store.id, store.subdomain, site_id)
db.flush()
logger.info("Deleted hosted site: %d", site_id)
return True
@@ -227,37 +264,25 @@ class HostedSiteService:
def accept_proposal(
self, db: Session, site_id: int, merchant_id: int | None = None
) -> HostedSite:
"""Accept proposal: create or link merchant, create subscription, mark converted."""
"""Accept proposal: create subscription, mark prospect converted.
The merchant already exists (assigned at site creation time).
Optionally pass merchant_id to reassign to a different merchant.
"""
site = self._transition(db, site_id, HostedSiteStatus.ACCEPTED)
site.proposal_accepted_at = datetime.now(UTC)
from app.modules.tenancy.models import Merchant, Platform
# Use provided merchant_id to reassign, or keep existing store merchant
if merchant_id:
# Link to existing merchant
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
if not merchant:
raise ValueError(f"Merchant {merchant_id} not found")
site.store.merchant_id = merchant.id
db.flush()
else:
# Create new merchant from contact info
from app.modules.tenancy.schemas.merchant import MerchantCreate
from app.modules.tenancy.services.merchant_service import merchant_service
email = site.contact_email or f"contact-{site.id}@hostwizard.lu"
merchant_data = MerchantCreate(
name=site.business_name,
contact_email=email,
contact_phone=site.contact_phone,
owner_email=email,
)
merchant, _owner_user, _temp_password = merchant_service.create_merchant_with_owner(
db, merchant_data
)
logger.info("Created merchant %s for site %d", merchant.name, site_id)
# Re-assign store to the real merchant
site.store.merchant_id = merchant.id
db.flush()
merchant = site.store.merchant
# Create MerchantSubscription on hosting platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
@@ -286,7 +311,6 @@ class HostedSiteService:
prospect = db.query(Prospect).filter(Prospect.id == site.prospect_id).first()
if prospect and prospect.status != ProspectStatus.CONVERTED:
prospect.status = ProspectStatus.CONVERTED
db.flush()
db.flush()
logger.info("Proposal accepted for site %d (merchant=%d)", site_id, merchant.id)

View File

@@ -0,0 +1,277 @@
# app/modules/hosting/services/poc_builder_service.py
"""
POC Builder Service — creates a near-final multi-page website from
a prospect + industry template.
Flow:
1. Load prospect data (scraped content, contacts)
2. Load industry template (pages, theme)
3. Create HostedSite + Store via hosted_site_service
4. Populate CMS ContentPages from template, replacing {{placeholders}}
with prospect data
5. Apply StoreTheme from template
6. Result: a previewable site at {subdomain}.hostwizard.lu
"""
import json
import logging
import re
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.modules.hosting.services.hosted_site_service import hosted_site_service
from app.modules.hosting.services.template_service import template_service
logger = logging.getLogger(__name__)
class PocBuilderService:
"""Builds POC sites from prospect data + industry templates."""
def build_poc(
self,
db: Session,
prospect_id: int,
template_id: str,
merchant_id: int | None = None,
site_id: int | None = None,
) -> dict:
"""Build a complete POC site from prospect data and a template.
If site_id is given, populates the existing site's store with CMS
content. Otherwise creates a new HostedSite + Store.
Returns dict with hosted_site, store, pages_created, theme_applied.
"""
from app.modules.prospecting.models import Prospect
# 1. Load prospect
prospect = db.query(Prospect).filter(Prospect.id == prospect_id).first()
if not prospect:
from app.modules.prospecting.exceptions import ProspectNotFoundException
raise ProspectNotFoundException(str(prospect_id))
# 2. Load template
template = template_service.get_template(template_id)
if not template:
raise ValueError(f"Template '{template_id}' not found")
# 3. Build placeholder context from prospect data
context = self._build_context(prospect)
# 4. Use existing site or create new one
if site_id:
site = hosted_site_service.get_by_id(db, site_id)
else:
site_data = {
"business_name": context["business_name"],
"domain_name": prospect.domain_name,
"prospect_id": prospect_id,
"contact_email": context.get("email"),
"contact_phone": context.get("phone"),
}
if merchant_id:
site_data["merchant_id"] = merchant_id
site = hosted_site_service.create(db, site_data)
# 5. Get the hosting platform_id from the store
from app.modules.tenancy.models import StorePlatform
store_platform = (
db.query(StorePlatform)
.filter(StorePlatform.store_id == site.store_id)
.first()
)
platform_id = store_platform.platform_id if store_platform else None
if not platform_id:
logger.warning("No platform found for store %d", site.store_id)
# 6. Populate CMS ContentPages from template
pages_created = 0
if platform_id:
pages_created = self._create_pages(db, site.store_id, platform_id, template, context)
# 7. Apply StoreTheme
theme_applied = self._apply_theme(db, site.store_id, template)
# 8. Mark POC ready
hosted_site_service.mark_poc_ready(db, site.id)
db.flush()
logger.info(
"POC built for prospect %d: site=%d, store=%d, %d pages, template=%s",
prospect_id, site.id, site.store_id, pages_created, template_id,
)
return {
"hosted_site_id": site.id,
"store_id": site.store_id,
"pages_created": pages_created,
"theme_applied": theme_applied,
"template_id": template_id,
"subdomain": site.store.subdomain if site.store else None,
}
def _build_context(self, prospect) -> dict:
"""Build placeholder replacement context from prospect data."""
# Base context
context = {
"business_name": prospect.business_name or prospect.domain_name or "My Business",
"domain": prospect.domain_name or "",
"city": prospect.city or "",
"address": "",
"email": "",
"phone": "",
"meta_description": "",
"about_paragraph": "",
}
# From contacts
contacts = prospect.contacts or []
for c in contacts:
if c.contact_type == "email" and not context["email"]:
context["email"] = c.value
elif c.contact_type == "phone" and not context["phone"]:
context["phone"] = c.value
elif c.contact_type == "address" and not context["address"]:
context["address"] = c.value
# From scraped content
if prospect.scraped_content_json:
try:
scraped = json.loads(prospect.scraped_content_json)
if scraped.get("meta_description"):
context["meta_description"] = scraped["meta_description"]
if scraped.get("paragraphs"):
context["about_paragraph"] = scraped["paragraphs"][0]
# Build rich content from scraped paragraphs for page bodies
context["scraped_paragraphs_html"] = "\n".join(
f"<p>{p}</p>" for p in scraped["paragraphs"][:5]
)
if scraped.get("headings"):
if not prospect.business_name:
context["business_name"] = scraped["headings"][0]
# Use second heading as tagline if available
if len(scraped["headings"]) > 1:
context["tagline"] = scraped["headings"][1]
except (json.JSONDecodeError, KeyError):
pass
# From prospect fields
if prospect.city:
context["city"] = prospect.city
elif context["address"]:
# Try to extract city from address (last word after postal code)
parts = context["address"].split()
if len(parts) >= 2:
context["city"] = parts[-1]
return context
def _replace_placeholders(self, text: str, context: dict) -> str:
"""Replace {{placeholder}} variables in text with context values."""
if not text:
return text
def replacer(match):
key = match.group(1).strip()
return context.get(key, match.group(0))
return re.sub(r"\{\{(\w+)\}\}", replacer, text)
def _replace_in_structure(self, data, context: dict):
"""Recursively replace placeholders in a nested dict/list structure."""
if isinstance(data, str):
return self._replace_placeholders(data, context)
if isinstance(data, dict):
return {k: self._replace_in_structure(v, context) for k, v in data.items()}
if isinstance(data, list):
return [self._replace_in_structure(item, context) for item in data]
return data
def _create_pages(self, db: Session, store_id: int, platform_id: int, template: dict, context: dict) -> int:
"""Create CMS ContentPages from template page definitions."""
from app.modules.cms.models.content_page import ContentPage
count = 0
for page_def in template.get("pages", []):
slug = page_def.get("slug", "")
if not slug:
continue
# Replace placeholders in all text fields
page_data = self._replace_in_structure(page_def, context)
# Build content from content_translations if present
content = page_data.get("content", "")
content_translations = page_data.get("content_translations")
if content_translations and not content:
content = next(iter(content_translations.values()), "")
# Enrich with scraped paragraphs (append to template content)
scraped_html = context.get("scraped_paragraphs_html", "")
if scraped_html and slug in ("about", "services", "projects"):
content = content + "\n" + scraped_html if content else scraped_html
if content_translations:
for lang_code in content_translations:
content_translations[lang_code] = (
content_translations[lang_code] + "\n" + scraped_html
)
page = ContentPage(
platform_id=platform_id,
store_id=store_id,
is_platform_page=False,
slug=slug,
title=page_data.get("title", slug.title()),
content=content or f"<p>{slug.title()} page content</p>",
content_format="html",
template=page_data.get("template", "default"),
sections=page_data.get("sections"),
title_translations=page_data.get("title_translations"),
content_translations=content_translations,
meta_description=context.get("meta_description"),
is_published=page_data.get("is_published", True),
published_at=datetime.now(UTC) if page_data.get("is_published", True) else None,
show_in_header=page_data.get("show_in_header", False),
show_in_footer=page_data.get("show_in_footer", False),
)
db.add(page)
count += 1
db.flush()
return count
def _apply_theme(self, db: Session, store_id: int, template: dict) -> bool:
"""Apply the template's theme to the store."""
from app.modules.cms.models.store_theme import StoreTheme
theme_data = template.get("theme")
if not theme_data:
return False
# Check if store already has a theme
existing = db.query(StoreTheme).filter(StoreTheme.store_id == store_id).first()
if existing:
# Update existing
theme = existing
else:
theme = StoreTheme(store_id=store_id)
db.add(theme)
colors = theme_data.get("colors", {})
theme.theme_name = theme_data.get("theme_name", "default")
theme.colors = colors
theme.font_family_heading = theme_data.get("font_family_heading", "Inter")
theme.font_family_body = theme_data.get("font_family_body", "Inter")
theme.layout_style = theme_data.get("layout_style", "grid")
theme.header_style = theme_data.get("header_style", "fixed")
db.flush()
return True
poc_builder_service = PocBuilderService()

View File

@@ -0,0 +1,114 @@
# app/modules/hosting/services/template_service.py
"""
Template service for the hosting module.
Loads and manages industry templates from the templates_library directory.
Templates are JSON files that define page content, themes, and sections
for different business types (restaurant, construction, etc.).
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "templates_library"
class TemplateService:
"""Manages industry templates for POC site generation."""
def __init__(self):
self._manifest = None
self._cache: dict[str, dict] = {}
def _load_manifest(self) -> dict:
"""Load the manifest.json file."""
if self._manifest is None:
manifest_path = TEMPLATES_DIR / "manifest.json"
self._manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
return self._manifest
def list_templates(self) -> list[dict]:
"""List all available templates with metadata."""
manifest = self._load_manifest()
templates = []
for entry in manifest.get("templates", []):
template_id = entry["id"]
meta = self._load_meta(template_id)
templates.append({
"id": template_id,
"name": meta.get("name", entry.get("name", template_id)),
"description": meta.get("description", entry.get("description", "")),
"tags": meta.get("tags", entry.get("tags", [])),
"languages": meta.get("languages", []),
"pages": entry.get("pages", []),
})
return templates
def get_template(self, template_id: str) -> dict | None:
"""Load a complete template with meta, theme, and all pages."""
if template_id in self._cache:
return self._cache[template_id]
template_dir = TEMPLATES_DIR / template_id
if not template_dir.is_dir():
return None
meta = self._load_meta(template_id)
theme = self._load_json(template_dir / "theme.json")
pages = self._load_pages(template_dir)
template = {
"id": template_id,
"meta": meta,
"theme": theme,
"pages": pages,
}
self._cache[template_id] = template
return template
def get_theme(self, template_id: str) -> dict | None:
"""Load just the theme configuration for a template."""
template_dir = TEMPLATES_DIR / template_id
return self._load_json(template_dir / "theme.json")
def get_page(self, template_id: str, page_slug: str) -> dict | None:
"""Load a single page definition from a template."""
page_path = TEMPLATES_DIR / template_id / "pages" / f"{page_slug}.json"
return self._load_json(page_path)
def template_exists(self, template_id: str) -> bool:
"""Check if a template exists."""
return (TEMPLATES_DIR / template_id / "meta.json").is_file()
def _load_meta(self, template_id: str) -> dict:
"""Load meta.json for a template."""
return self._load_json(TEMPLATES_DIR / template_id / "meta.json") or {}
def _load_pages(self, template_dir: Path) -> list[dict]:
"""Load all page JSONs from a template's pages/ directory."""
pages_dir = template_dir / "pages"
if not pages_dir.is_dir():
return []
pages = []
for page_file in sorted(pages_dir.glob("*.json")):
page_data = self._load_json(page_file)
if page_data:
pages.append(page_data)
return pages
@staticmethod
def _load_json(path: Path) -> dict | None:
"""Safely load a JSON file."""
if not path.is_file():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to load template file %s: %s", path, e)
return None
template_service = TemplateService()

View File

@@ -12,7 +12,8 @@
{{ loading_state('Loading site...') }}
{{ error_state('Error loading site') }}
<div x-show="!loading && !error && site" class="space-y-6">
<template x-if="!loading && !error && site">
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
<div class="flex items-center space-x-4">
@@ -38,6 +39,27 @@
</div>
</div>
<!-- Build POC (draft sites only) -->
<div x-show="site.status === 'draft' && site.prospect_id" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Build POC from Template</h3>
<div class="flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[200px]">
<select x-model="selectedTemplate"
class="w-full px-3 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="">Select a template...</option>
<template x-for="t in templates" :key="t.id">
<option :value="t.id" x-text="t.name + ' — ' + t.description"></option>
</template>
</select>
</div>
<button type="button" @click="buildPoc()" :disabled="!selectedTemplate || buildingPoc"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50">
<span x-text="buildingPoc ? 'Building...' : 'Build POC'"></span>
</button>
</div>
<p x-show="pocResult" class="mt-2 text-sm text-green-600" x-text="pocResult"></p>
</div>
<!-- Lifecycle Actions -->
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<button type="button" x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
@@ -187,6 +209,7 @@
</div>
</div>
</div>
</template>
<!-- Send Proposal Modal -->
{% call modal('proposalModal', 'Send Proposal', show_var='showProposalModal', size='md', show_footer=false) %}
@@ -305,7 +328,40 @@ function hostingSiteDetail(siteId) {
acceptMerchantId: '',
goLiveDomain: '',
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
async init() { await this.loadSite(); },
// POC builder
templates: [],
selectedTemplate: '',
buildingPoc: false,
pocResult: '',
async init() {
await this.loadSite();
await this.loadTemplates();
},
async loadTemplates() {
try {
var resp = await apiClient.get('/admin/hosting/sites/templates');
this.templates = resp.templates || [];
} catch (e) { /* ignore */ }
},
async buildPoc() {
if (!this.selectedTemplate || !this.site.prospect_id) return;
this.buildingPoc = true;
this.pocResult = '';
try {
var result = await apiClient.post('/admin/hosting/sites/poc/build', {
prospect_id: this.site.prospect_id,
template_id: this.selectedTemplate,
site_id: this.site.id,
});
this.pocResult = 'POC built! ' + result.pages_created + ' pages created.';
Utils.showToast('POC built successfully', 'success');
await this.loadSite();
} catch (e) {
Utils.showToast('Build failed: ' + e.message, 'error');
} finally {
this.buildingPoc = false;
}
},
async loadSite() {
this.loading = true;
this.error = null;

View File

@@ -44,18 +44,48 @@
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div>
<!-- Prospect Selector -->
<!-- Prospect Search -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Create from Prospect (optional)</label>
<div class="flex mt-1 space-x-2">
<input type="number" x-model="prospectId" placeholder="Prospect ID" {# noqa: FE008 - prospect ID input #}
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<button type="button" @click="createFromProspect()"
:disabled="!prospectId || creating"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
Create from Prospect
</button>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Link to Prospect <span class="text-red-500">*</span>
</label>
<div class="relative">
<input type="text" x-model="prospectSearch" @input.debounce.300ms="searchProspects()"
@focus="showProspectDropdown = true"
placeholder="Search by domain or business name..."
class="w-full px-3 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">
<!-- Selected prospect badge -->
<div x-show="selectedProspect" class="absolute right-2 top-1/2 -translate-y-1/2">
<span class="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300">
<span x-text="'#' + form.prospect_id"></span>
<button type="button" @click="clearProspect()" class="ml-1 text-teal-500 hover:text-teal-700">&times;</button>
</span>
</div>
<!-- Dropdown -->
<div x-show="showProspectDropdown && prospectResults.length > 0" @click.away="showProspectDropdown = false"
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-auto">
<template x-for="p in prospectResults" :key="p.id">
<button type="button" @click="selectProspect(p)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-600 flex justify-between items-center">
<div>
<span class="font-medium text-gray-700 dark:text-gray-200" x-text="p.business_name || p.domain_name"></span>
<span x-show="p.domain_name && p.business_name" class="text-xs text-gray-400 ml-2" x-text="p.domain_name"></span>
</div>
<span class="text-xs text-gray-400" x-text="'#' + p.id"></span>
</button>
</template>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">A merchant will be auto-created from the prospect's contact data.</p>
</div>
<!-- Optional: Existing Merchant -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Or link to existing Merchant ID <span class="text-xs text-gray-400">(optional)</span>
</label>
<input type="number" x-model.number="form.merchant_id" placeholder="Leave empty to auto-create" {# noqa: FE008 #}
class="w-full px-3 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>
@@ -66,10 +96,8 @@
Cancel
</a>
<button type="button" @click="createSite()"
:disabled="!form.business_name || creating"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
<span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
:disabled="!canCreate || creating"
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
<span x-text="creating ? 'Creating...' : 'Create Site'"></span>
</button>
</div>
@@ -85,15 +113,76 @@ function hostingSiteNew() {
return {
...data(),
currentPage: 'hosting-sites',
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
prospectId: '',
form: {
business_name: '',
prospect_id: null,
merchant_id: null,
contact_name: '',
contact_email: '',
contact_phone: '',
internal_notes: '',
},
// Prospect search
prospectSearch: '',
prospectResults: [],
selectedProspect: null,
showProspectDropdown: false,
creating: false,
errorMsg: '',
get canCreate() {
return this.form.business_name && (this.form.prospect_id || this.form.merchant_id);
},
async searchProspects() {
if (this.prospectSearch.length < 2) { this.prospectResults = []; return; }
try {
var resp = await apiClient.get('/admin/prospecting/prospects?search=' + encodeURIComponent(this.prospectSearch) + '&per_page=10');
this.prospectResults = resp.items || [];
this.showProspectDropdown = true;
} catch (e) {
this.prospectResults = [];
}
},
selectProspect(prospect) {
this.selectedProspect = prospect;
this.form.prospect_id = prospect.id;
this.prospectSearch = prospect.business_name || prospect.domain_name;
this.showProspectDropdown = false;
// Auto-fill form from prospect
if (!this.form.business_name) {
this.form.business_name = prospect.business_name || prospect.domain_name || '';
}
if (!this.form.contact_email && prospect.primary_email) {
this.form.contact_email = prospect.primary_email;
}
if (!this.form.contact_phone && prospect.primary_phone) {
this.form.contact_phone = prospect.primary_phone;
}
},
clearProspect() {
this.selectedProspect = null;
this.form.prospect_id = null;
this.prospectSearch = '';
this.prospectResults = [];
},
async createSite() {
if (!this.canCreate) {
this.errorMsg = 'Business name and a linked prospect or merchant are required';
return;
}
this.creating = true;
this.errorMsg = '';
try {
const site = await apiClient.post('/admin/hosting/sites', this.form);
var payload = {};
for (var k in this.form) {
if (this.form[k] !== null && this.form[k] !== '') payload[k] = this.form[k];
}
const site = await apiClient.post('/admin/hosting/sites', payload);
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) {
this.errorMsg = e.message || 'Failed to create site';
@@ -101,18 +190,6 @@ function hostingSiteNew() {
this.creating = false;
}
},
async createFromProspect() {
this.creating = true;
this.errorMsg = '';
try {
const site = await apiClient.post('/admin/hosting/sites/from-prospect/' + this.prospectId);
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) {
this.errorMsg = e.message || 'Failed to create from prospect';
} finally {
this.creating = false;
}
},
};
}
</script>

View File

@@ -40,11 +40,6 @@
<option value="cancelled">Cancelled</option>
</select>
<a href="/admin/prospecting/prospects"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Create from Prospect
</a>
</div>
</div>
</div>
@@ -95,6 +90,11 @@
title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<button type="button" @click="deleteSite(s)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete">
<span x-html="$icon('trash', 'w-5 h-5')"></span>
</button>
</div>
</td>
</tr>
@@ -144,6 +144,16 @@ function hostingSitesList() {
this.loading = false;
}
},
async deleteSite(site) {
if (!confirm('Delete "' + site.business_name + '"? This will also delete the associated store.')) return;
try {
await apiClient.delete('/admin/hosting/sites/' + site.id);
Utils.showToast('Site deleted', 'success');
await this.loadSites();
} catch (e) {
Utils.showToast('Failed: ' + e.message, 'error');
}
},
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;

View File

@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site.business_name }} - Preview by HostWizard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.hw-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background: linear-gradient(135deg, #0D9488, #14B8A6);
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
.hw-banner-logo { font-weight: 700; font-size: 16px; }
.hw-banner-text { opacity: 0.9; }
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
.hw-banner-link {
color: white;
text-decoration: none;
padding: 6px 16px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 6px;
font-size: 13px;
transition: background 0.2s;
}
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
.hw-iframe-container {
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
}
.hw-iframe-container iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<div class="hw-banner">
<div class="hw-banner-left">
<span class="hw-banner-logo">HostWizard</span>
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
</div>
<div class="hw-banner-right">
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
</div>
</div>
<div class="hw-iframe-container">
<iframe src="{{ store_url }}" title="Site preview"></iframe>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@
{"id": "auto-parts", "name": "Auto Parts & Garage", "description": "Template for auto parts shops, garages, and car dealers", "tags": ["automotive", "garage", "car", "parts"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,12 @@
{
"slug": "catalog",
"title": "Catalog",
"title_translations": {"en": "Parts Catalog", "fr": "Catalogue de pièces", "de": "Teilekatalog"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Parts Catalog</h2>\n<p>Browse our extensive catalog of auto parts for all major brands.</p>",
"fr": "<h2>Catalogue de pièces</h2>\n<p>Parcourez notre catalogue complet de pièces auto pour toutes les grandes marques.</p>"
}
}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Contact Us</h2>\n<p>Visit our store or get in touch for parts inquiries.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Contactez-nous</h2>\n<p>Visitez notre magasin ou contactez-nous pour vos demandes de pièces.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,34 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "Your trusted auto parts specialist in {{city}}", "fr": "Votre spécialiste pièces auto de confiance à {{city}}"}},
"background_type": "image",
"buttons": [
{"text": {"translations": {"en": "Browse Parts", "fr": "Voir les pièces"}}, "url": "/catalog", "style": "primary"},
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Why Choose Us", "fr": "Pourquoi nous choisir"}},
"items": [
{"icon": "truck", "title": {"translations": {"en": "Fast Delivery", "fr": "Livraison rapide"}}, "description": {"translations": {"en": "Same-day delivery on in-stock parts", "fr": "Livraison le jour même pour les pièces en stock"}}},
{"icon": "shield-check", "title": {"translations": {"en": "Quality Guaranteed", "fr": "Qualité garantie"}}, "description": {"translations": {"en": "OEM and certified aftermarket parts", "fr": "Pièces OEM et aftermarket certifiées"}}},
{"icon": "currency-euro", "title": {"translations": {"en": "Best Prices", "fr": "Meilleurs prix"}}, "description": {"translations": {"en": "Competitive pricing on all brands", "fr": "Prix compétitifs sur toutes les marques"}}}
]
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Need a specific part?", "fr": "Besoin d'une pièce spécifique ?"}},
"buttons": [
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,8 @@
{
"theme_name": "modern",
"colors": {"primary": "#dc2626", "secondary": "#991b1b", "accent": "#f59e0b", "background": "#fafafa", "text": "#18181b", "border": "#e4e4e7"},
"font_family_heading": "Montserrat",
"font_family_body": "Inter",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1 @@
{"id": "construction", "name": "Construction & Renovation", "description": "Professional template for builders, renovators, and tradespeople", "tags": ["construction", "renovation", "building", "trades"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Get a Free Quote</h2>\n<p>Tell us about your project and we'll get back to you within 24 hours.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Demandez un devis gratuit</h2>\n<p>Décrivez-nous votre projet et nous vous recontacterons sous 24h.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,40 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "Quality construction and renovation in {{city}}", "fr": "Construction et rénovation de qualité à {{city}}"}},
"background_type": "image",
"buttons": [
{"text": {"translations": {"en": "Get a Free Quote", "fr": "Devis gratuit"}}, "url": "/contact", "style": "primary"},
{"text": {"translations": {"en": "Our Projects", "fr": "Nos réalisations"}}, "url": "/projects", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Our Services", "fr": "Nos Services"}},
"items": [
{"icon": "home", "title": {"translations": {"en": "New Construction", "fr": "Construction neuve"}}, "description": {"translations": {"en": "Custom-built homes and commercial buildings", "fr": "Maisons et bâtiments commerciaux sur mesure"}}},
{"icon": "wrench", "title": {"translations": {"en": "Renovation", "fr": "Rénovation"}}, "description": {"translations": {"en": "Complete interior and exterior renovation", "fr": "Rénovation complète intérieure et extérieure"}}},
{"icon": "color-swatch", "title": {"translations": {"en": "Painting & Finishing", "fr": "Peinture & Finitions"}}, "description": {"translations": {"en": "Professional painting and finishing work", "fr": "Travaux de peinture et finitions professionnels"}}},
{"icon": "shield-check", "title": {"translations": {"en": "Insulation", "fr": "Isolation"}}, "description": {"translations": {"en": "Energy-efficient insulation solutions", "fr": "Solutions d'isolation éco-énergétiques"}}}
]
},
"testimonials": {
"enabled": true,
"title": {"translations": {"en": "What Our Clients Say", "fr": "Témoignages de nos clients"}},
"items": []
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Ready to start your project?", "fr": "Prêt à démarrer votre projet ?"}},
"buttons": [
{"text": {"translations": {"en": "Request a Quote", "fr": "Demander un devis"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "projects",
"title": "Projects",
"title_translations": {"en": "Our Projects", "fr": "Nos Réalisations", "de": "Unsere Projekte"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Projects</h2>\n<p>Browse our portfolio of completed construction and renovation projects.</p>",
"fr": "<h2>Nos Réalisations</h2>\n<p>Découvrez notre portfolio de projets de construction et rénovation réalisés.</p>"
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "services",
"title": "Services",
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Services</h2>\n<p>We offer a comprehensive range of construction and renovation services.</p>\n<h3>Construction</h3>\n<p>From foundations to finishing touches, we handle every aspect of new builds.</p>\n<h3>Renovation</h3>\n<p>Transform your existing space with our expert renovation team.</p>\n<h3>Painting & Decoration</h3>\n<p>Professional interior and exterior painting services.</p>",
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons une gamme complète de services de construction et rénovation.</p>\n<h3>Construction</h3>\n<p>Des fondations aux finitions, nous gérons chaque aspect des constructions neuves.</p>\n<h3>Rénovation</h3>\n<p>Transformez votre espace avec notre équipe de rénovation experte.</p>\n<h3>Peinture & Décoration</h3>\n<p>Services professionnels de peinture intérieure et extérieure.</p>"
}
}

View File

@@ -0,0 +1,15 @@
{
"theme_name": "modern",
"colors": {
"primary": "#d97706",
"secondary": "#92400e",
"accent": "#fbbf24",
"background": "#fafaf9",
"text": "#1c1917",
"border": "#d6d3d1"
},
"font_family_heading": "Montserrat",
"font_family_body": "Open Sans",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1,7 @@
{
"id": "generic",
"name": "Generic Business",
"description": "Clean, minimal template that works for any business type",
"tags": ["general", "minimal", "any"],
"languages": ["en", "fr", "de"]
}

View File

@@ -0,0 +1,13 @@
{
"slug": "about",
"title": "About Us",
"title_translations": {"en": "About Us", "fr": "À propos", "de": "Über uns"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content": "{{about_content}}",
"content_translations": {
"en": "<h2>About {{business_name}}</h2>\n<p>{{about_paragraph}}</p>",
"fr": "<h2>À propos de {{business_name}}</h2>\n<p>{{about_paragraph}}</p>"
}
}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contact", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Get in Touch</h2>\n<p>We'd love to hear from you. Reach out using the information below.</p>\n<ul>\n<li>Email: {{email}}</li>\n<li>Phone: {{phone}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Contactez-nous</h2>\n<p>N'hésitez pas à nous contacter.</p>\n<ul>\n<li>Email : {{email}}</li>\n<li>Téléphone : {{phone}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,35 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"show_in_header": false,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "{{meta_description}}", "fr": "{{meta_description}}"}},
"background_type": "gradient",
"buttons": [
{"text": {"translations": {"en": "Contact Us", "fr": "Contactez-nous"}}, "url": "/contact", "style": "primary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "What We Offer", "fr": "Nos Services"}},
"items": [
{"icon": "shield-check", "title": {"translations": {"en": "Quality", "fr": "Qualité"}}, "description": {"translations": {"en": "Committed to excellence in everything we do", "fr": "Engagés pour l'excellence dans tout ce que nous faisons"}}},
{"icon": "clock", "title": {"translations": {"en": "Reliability", "fr": "Fiabilité"}}, "description": {"translations": {"en": "Dependable service you can count on", "fr": "Un service fiable sur lequel vous pouvez compter"}}},
{"icon": "users", "title": {"translations": {"en": "Experience", "fr": "Expérience"}}, "description": {"translations": {"en": "Years of expertise at your service", "fr": "Des années d'expertise à votre service"}}}
]
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Ready to get started?", "fr": "Prêt à commencer ?"}},
"subtitle": {"translations": {"en": "Contact us today for a free consultation", "fr": "Contactez-nous pour une consultation gratuite"}},
"buttons": [
{"text": {"translations": {"en": "Get in Touch", "fr": "Nous Contacter"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,15 @@
{
"theme_name": "modern",
"colors": {
"primary": "#3b82f6",
"secondary": "#1e40af",
"accent": "#f59e0b",
"background": "#ffffff",
"text": "#1e293b",
"border": "#e2e8f0"
},
"font_family_heading": "Inter",
"font_family_body": "Inter",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1,40 @@
{
"version": "1.0",
"templates": [
{
"id": "generic",
"name": "Generic Business",
"description": "Clean, minimal template that works for any business type",
"tags": ["general", "minimal", "any"],
"pages": ["homepage", "about", "contact"]
},
{
"id": "restaurant",
"name": "Restaurant & Dining",
"description": "Elegant template for restaurants, cafés, bars, and catering",
"tags": ["food", "dining", "hospitality", "café"],
"pages": ["homepage", "about", "menu", "contact"]
},
{
"id": "construction",
"name": "Construction & Renovation",
"description": "Professional template for builders, renovators, and tradespeople",
"tags": ["construction", "renovation", "building", "trades"],
"pages": ["homepage", "services", "projects", "contact"]
},
{
"id": "auto-parts",
"name": "Auto Parts & Garage",
"description": "Template for auto parts shops, garages, and car dealers",
"tags": ["automotive", "garage", "car", "parts"],
"pages": ["homepage", "catalog", "contact"]
},
{
"id": "professional-services",
"name": "Professional Services",
"description": "Template for lawyers, accountants, consultants, and agencies",
"tags": ["professional", "consulting", "legal", "finance"],
"pages": ["homepage", "services", "team", "contact"]
}
]
}

View File

@@ -0,0 +1 @@
{"id": "professional-services", "name": "Professional Services", "description": "Template for lawyers, accountants, consultants, and agencies", "tags": ["professional", "consulting", "legal", "finance"], "languages": ["en", "fr", "de"]}

View File

@@ -0,0 +1,13 @@
{
"slug": "contact",
"title": "Contact",
"title_translations": {"en": "Contact Us", "fr": "Contactez-nous", "de": "Kontakt"},
"template": "default",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"content_translations": {
"en": "<h2>Contact Us</h2>\n<p>Schedule a consultation or reach out with any questions.</p>\n<ul>\n<li>Phone: {{phone}}</li>\n<li>Email: {{email}}</li>\n<li>Address: {{address}}</li>\n</ul>",
"fr": "<h2>Contactez-nous</h2>\n<p>Planifiez une consultation ou posez-nous vos questions.</p>\n<ul>\n<li>Téléphone : {{phone}}</li>\n<li>Email : {{email}}</li>\n<li>Adresse : {{address}}</li>\n</ul>"
}
}

View File

@@ -0,0 +1,34 @@
{
"slug": "homepage",
"title": "{{business_name}}",
"template": "full",
"is_published": true,
"sections": {
"hero": {
"enabled": true,
"title": {"translations": {"en": "{{business_name}}", "fr": "{{business_name}}"}},
"subtitle": {"translations": {"en": "Professional expertise you can trust", "fr": "Une expertise professionnelle de confiance"}},
"background_type": "gradient",
"buttons": [
{"text": {"translations": {"en": "Book a Consultation", "fr": "Prendre rendez-vous"}}, "url": "/contact", "style": "primary"},
{"text": {"translations": {"en": "Our Expertise", "fr": "Notre expertise"}}, "url": "/services", "style": "secondary"}
]
},
"features": {
"enabled": true,
"title": {"translations": {"en": "Areas of Expertise", "fr": "Domaines d'expertise"}},
"items": [
{"icon": "briefcase", "title": {"translations": {"en": "Advisory", "fr": "Conseil"}}, "description": {"translations": {"en": "Strategic guidance tailored to your needs", "fr": "Conseils stratégiques adaptés à vos besoins"}}},
{"icon": "document-text", "title": {"translations": {"en": "Compliance", "fr": "Conformité"}}, "description": {"translations": {"en": "Ensure regulatory compliance across your operations", "fr": "Assurez la conformité réglementaire de vos opérations"}}},
{"icon": "chart-bar", "title": {"translations": {"en": "Analysis", "fr": "Analyse"}}, "description": {"translations": {"en": "Data-driven insights for informed decisions", "fr": "Analyses basées sur les données pour des décisions éclairées"}}}
]
},
"cta": {
"enabled": true,
"title": {"translations": {"en": "Need professional guidance?", "fr": "Besoin d'un accompagnement professionnel ?"}},
"buttons": [
{"text": {"translations": {"en": "Schedule a Meeting", "fr": "Planifier un rendez-vous"}}, "url": "/contact", "style": "primary"}
]
}
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "services",
"title": "Services",
"title_translations": {"en": "Our Services", "fr": "Nos Services", "de": "Unsere Leistungen"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Services</h2>\n<p>We provide comprehensive professional services to help your business thrive.</p>",
"fr": "<h2>Nos Services</h2>\n<p>Nous proposons des services professionnels complets pour aider votre entreprise à prospérer.</p>"
}
}

View File

@@ -0,0 +1,12 @@
{
"slug": "team",
"title": "Team",
"title_translations": {"en": "Our Team", "fr": "Notre Équipe", "de": "Unser Team"},
"template": "default",
"is_published": true,
"show_in_header": true,
"content_translations": {
"en": "<h2>Our Team</h2>\n<p>Meet the professionals behind {{business_name}}.</p>",
"fr": "<h2>Notre Équipe</h2>\n<p>Découvrez les professionnels derrière {{business_name}}.</p>"
}
}

View File

@@ -0,0 +1,8 @@
{
"theme_name": "modern",
"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "accent": "#3b82f6", "background": "#f8fafc", "text": "#0f172a", "border": "#cbd5e1"},
"font_family_heading": "Merriweather",
"font_family_body": "Source Sans Pro",
"layout_style": "grid",
"header_style": "fixed"
}

View File

@@ -0,0 +1 @@
{"id": "restaurant", "name": "Restaurant & Dining", "description": "Elegant template for restaurants, cafés, bars, and catering", "tags": ["food", "dining", "hospitality"], "languages": ["en", "fr", "de"]}

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