feat(merchant): extract merchant portal as first-class frontend with auth, Tailwind fixes, and Gitea CI
Some checks failed
CI / ruff (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / architecture (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / audit (push) Has been cancelled
CI / docs (push) Has been cancelled

- Extract login/dashboard from billing module into core (matching admin pattern)
- Add merchant auth API with path-isolated cookies (path=/merchants)
- Add merchant base layout with sidebar/header partials and Alpine.js init
- Add frontend detection and login redirect for MERCHANT type
- Wire merchant token in shared api-client.js (get/clear)
- Migrate billing templates to merchant base with dark mode support
- Fix Tailwind: rename shop→storefront in sources and config
- DRY Makefile tailwind targets with TAILWIND_FRONTENDS loop
- Rebuild all Tailwind outputs (production minified)
- Add Gitea Actions CI workflow (ruff, pytest, architecture, docs)
- Add Gitea deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 20:25:29 +01:00
parent ecb5309879
commit 0437af67ec
31 changed files with 1925 additions and 780 deletions

175
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,175 @@
# Gitea Actions CI/CD Configuration
# ==================================
# Equivalent of the GitLab CI pipeline, using GitHub Actions-compatible syntax.
# Requires Gitea 1.19+ with Actions enabled.
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
PYTHON_VERSION: "3.11"
jobs:
# ---------------------------------------------------------------------------
# Lint
# ---------------------------------------------------------------------------
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Run ruff
run: .venv/bin/ruff check .
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
pytest:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: wizamart_test
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U test_user -d wizamart_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
TEST_DATABASE_URL: "postgresql://test_user:test_password@localhost:5432/wizamart_test"
DATABASE_URL: "postgresql://test_user:test_password@localhost:5432/wizamart_test"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Cache pip & venv
uses: actions/cache@v4
with:
path: |
~/.cache/pip
.venv
key: ${{ runner.os }}-pip-${{ hashFiles('uv.lock', 'pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Run tests
run: .venv/bin/python -m pytest tests/ -v --tb=short
architecture:
runs-on: ubuntu-latest
env:
DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Validate architecture
run: .venv/bin/python scripts/validate/validate_architecture.py
# ---------------------------------------------------------------------------
# Security (non-blocking)
# ---------------------------------------------------------------------------
dependency-scanning:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install pip-audit
run: pip install pip-audit
- name: Run pip-audit
run: pip-audit --requirement requirements.txt || true
audit:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Run audit
run: .venv/bin/python scripts/validate/validate_audit.py
# ---------------------------------------------------------------------------
# Build (docs - only on push to master)
# ---------------------------------------------------------------------------
docs:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [ruff, pytest, architecture]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Build docs
run: .venv/bin/mkdocs build
- name: Upload docs artifact
uses: actions/upload-artifact@v4
with:
name: docs-site
path: site/

View File

@@ -399,26 +399,22 @@ tailwind-install:
@mv tailwindcss-linux-x64 $(TAILWIND_CLI) @mv tailwindcss-linux-x64 $(TAILWIND_CLI)
@echo "Tailwind CLI installed: $$($(TAILWIND_CLI) --help | head -1)" @echo "Tailwind CLI installed: $$($(TAILWIND_CLI) --help | head -1)"
# All frontends that have a Tailwind build (static/<name>/css/tailwind.css)
TAILWIND_FRONTENDS := admin store storefront platform merchant
tailwind-dev: tailwind-dev:
@echo "Building Tailwind CSS (development)..." @echo "Building Tailwind CSS (development)..."
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css @$(foreach fe,$(TAILWIND_FRONTENDS),$(TAILWIND_CLI) -i static/$(fe)/css/tailwind.css -o static/$(fe)/css/tailwind.output.css &&) true
$(TAILWIND_CLI) -i static/store/css/tailwind.css -o static/store/css/tailwind.output.css @echo "Tailwind CSS built ($(TAILWIND_FRONTENDS))"
$(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css
$(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css
@echo "Tailwind CSS built (admin + store + shop + platform)"
tailwind-build: tailwind-build:
@echo "Building Tailwind CSS (production - minified)..." @echo "Building Tailwind CSS (production - minified)..."
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify @$(foreach fe,$(TAILWIND_FRONTENDS),$(TAILWIND_CLI) -i static/$(fe)/css/tailwind.css -o static/$(fe)/css/tailwind.output.css --minify &&) true
$(TAILWIND_CLI) -i static/store/css/tailwind.css -o static/store/css/tailwind.output.css --minify
$(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css --minify
$(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css --minify
@echo "Tailwind CSS built and minified for production" @echo "Tailwind CSS built and minified for production"
tailwind-watch: tailwind-watch:
@echo "Watching Tailwind CSS for changes..." @echo "Watching Tailwind CSS for changes ($(fe))..."
@echo "Note: This watches admin CSS only. Run in separate terminal." $(TAILWIND_CLI) -i static/$(fe)/css/tailwind.css -o static/$(fe)/css/tailwind.output.css --watch
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch
# ============================================================================= # =============================================================================
# CELERY / TASK QUEUE # CELERY / TASK QUEUE
@@ -593,7 +589,7 @@ help:
@echo " tailwind-install - Install Tailwind standalone CLI" @echo " tailwind-install - Install Tailwind standalone CLI"
@echo " tailwind-dev - Build Tailwind CSS (development)" @echo " tailwind-dev - Build Tailwind CSS (development)"
@echo " tailwind-build - Build Tailwind CSS (production, minified)" @echo " tailwind-build - Build Tailwind CSS (production, minified)"
@echo " tailwind-watch - Watch and rebuild on changes" @echo " tailwind-watch fe=X - Watch and rebuild on changes (specify frontend)"
@echo "" @echo ""
@echo "=== CELERY / TASK QUEUE ===" @echo "=== CELERY / TASK QUEUE ==="
@echo " celery-worker - Start Celery worker" @echo " celery-worker - Start Celery worker"

View File

@@ -50,6 +50,7 @@ class FrontendDetector:
"/api/v1/shop", # Legacy support "/api/v1/shop", # Legacy support
"/stores/", # Path-based store access "/stores/", # Path-based store access
) )
MERCHANT_PATH_PREFIXES = ("/merchants", "/api/v1/merchants")
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",) PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
@classmethod @classmethod
@@ -94,6 +95,10 @@ class FrontendDetector:
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path") logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path")
return FrontendType.ADMIN return FrontendType.ADMIN
if cls._matches_any(path, cls.MERCHANT_PATH_PREFIXES):
logger.debug("[FRONTEND_DETECTOR] Detected MERCHANT from path")
return FrontendType.MERCHANT
# Check storefront BEFORE store since /api/v1/storefront starts with /api/v1/store # Check storefront BEFORE store since /api/v1/storefront starts with /api/v1/store
if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES): if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES):
logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path") logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path")

View File

@@ -393,6 +393,9 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
if frontend_type == FrontendType.ADMIN: if frontend_type == FrontendType.ADMIN:
logger.debug("Redirecting to /admin/login") logger.debug("Redirecting to /admin/login")
return RedirectResponse(url="/admin/login", status_code=302) return RedirectResponse(url="/admin/login", status_code=302)
if frontend_type == FrontendType.MERCHANT:
logger.debug("Redirecting to /merchants/login")
return RedirectResponse(url="/merchants/login", status_code=302)
if frontend_type == FrontendType.STORE: if frontend_type == FrontendType.STORE:
# Extract store code from the request path # Extract store code from the request path
# Path format: /store/{store_code}/... # Path format: /store/{store_code}/...

View File

@@ -3,14 +3,14 @@
Merchant Billing Page Routes (HTML rendering). Merchant Billing Page Routes (HTML rendering).
Page routes for the merchant billing portal: Page routes for the merchant billing portal:
- Dashboard (overview of stores, subscriptions)
- Subscriptions list - Subscriptions list
- Subscription detail per platform - Subscription detail per platform
- Billing history / invoices - Billing history / invoices
- Login page
Authentication: merchant_token cookie or Authorization header. Authentication: merchant_token cookie or Authorization header.
Login page uses optional auth to check if already logged in.
Login and dashboard routes have moved to core module
(app/modules/core/routes/pages/merchant.py) to match the admin pattern.
Auto-discovered by the route system (merchant.py in routes/pages/ triggers Auto-discovered by the route system (merchant.py in routes/pages/ triggers
registration under /merchants/billing/*). registration under /merchants/billing/*).
@@ -20,10 +20,7 @@ from fastapi import APIRouter, Depends, Path, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import get_current_merchant_from_cookie_or_header
get_current_merchant_from_cookie_or_header,
get_current_merchant_optional,
)
from app.core.database import get_db from app.core.database import get_db
from app.modules.core.utils.page_context import get_context_for_frontend from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType from app.modules.enums import FrontendType
@@ -53,15 +50,6 @@ def _get_merchant_context(
Uses the module-driven context builder with FrontendType.MERCHANT, Uses the module-driven context builder with FrontendType.MERCHANT,
and adds the authenticated user to the context. and adds the authenticated user to the context.
Args:
request: FastAPI request
db: Database session
current_user: Authenticated merchant user context
**extra_context: Additional template variables
Returns:
Dict of context variables for template rendering
""" """
return get_context_for_frontend( return get_context_for_frontend(
FrontendType.MERCHANT, FrontendType.MERCHANT,
@@ -73,26 +61,14 @@ def _get_merchant_context(
# ============================================================================ # ============================================================================
# DASHBOARD # BILLING ROOT
# ============================================================================ # ============================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False) @router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def merchant_dashboard_page( async def merchant_billing_root():
request: Request, """Redirect /merchants/billing/ to subscriptions page."""
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), return RedirectResponse(url="/merchants/billing/subscriptions", status_code=302)
db: Session = Depends(get_db),
):
"""
Render merchant dashboard page.
Shows an overview of the merchant's stores and subscriptions.
"""
context = _get_merchant_context(request, db, current_user)
return templates.TemplateResponse(
"billing/merchant/dashboard.html",
context,
)
# ============================================================================ # ============================================================================
@@ -164,35 +140,3 @@ async def merchant_billing_history_page(
"billing/merchant/billing-history.html", "billing/merchant/billing-history.html",
context, context,
) )
# ============================================================================
# LOGIN
# ============================================================================
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def merchant_login_page(
request: Request,
current_user: UserContext | None = Depends(get_current_merchant_optional),
db: Session = Depends(get_db),
):
"""
Render merchant login page.
If the user is already authenticated as a merchant owner,
redirects to the merchant dashboard.
"""
# Redirect to dashboard if already logged in
if current_user is not None:
return RedirectResponse(url="/merchants/billing/", status_code=302)
context = get_context_for_frontend(
FrontendType.MERCHANT,
request,
db,
)
return templates.TemplateResponse(
"billing/merchant/login.html",
context,
)

View File

@@ -7,22 +7,22 @@
<div x-data="merchantBillingHistory()"> <div x-data="merchantBillingHistory()">
<!-- Page Header --> <!-- Page Header -->
<div class="mb-8"> <div class="mb-8 mt-6">
<h2 class="text-2xl font-bold text-gray-900">Billing History</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Billing History</h2>
<p class="mt-1 text-gray-500">View your invoices and payment history.</p> <p class="mt-1 text-gray-500 dark:text-gray-400">View your invoices and payment history.</p>
</div> </div>
<!-- Error --> <!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg"> <div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p> <p class="text-sm text-red-800 dark:text-red-400" x-text="error"></p>
</div> </div>
<!-- Invoices Table --> <!-- Invoices Table -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full whitespace-nowrap"> <table class="w-full whitespace-nowrap">
<thead> <thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase bg-gray-50 border-b border-gray-200"> <tr class="text-xs font-semibold tracking-wide text-left text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
<th class="px-6 py-3">Date</th> <th class="px-6 py-3">Date</th>
<th class="px-6 py-3">Invoice #</th> <th class="px-6 py-3">Invoice #</th>
<th class="px-6 py-3 text-right">Amount</th> <th class="px-6 py-3 text-right">Amount</th>
@@ -30,11 +30,11 @@
<th class="px-6 py-3 text-right">Actions</th> <th class="px-6 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Loading --> <!-- Loading -->
<template x-if="loading"> <template x-if="loading">
<tr> <tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500"> <td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
<svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24"> <svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@@ -47,7 +47,7 @@
<!-- Empty --> <!-- Empty -->
<template x-if="!loading && invoices.length === 0"> <template x-if="!loading && invoices.length === 0">
<tr> <tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500"> <td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
No invoices found. No invoices found.
</td> </td>
</tr> </tr>
@@ -55,7 +55,7 @@
<!-- Rows --> <!-- Rows -->
<template x-for="invoice in invoices" :key="invoice.id"> <template x-for="invoice in invoices" :key="invoice.id">
<tr class="text-gray-700 hover:bg-gray-50 transition-colors"> <tr class="text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4 text-sm" x-text="formatDate(invoice.invoice_date)"></td> <td class="px-6 py-4 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
<td class="px-6 py-4 text-sm font-mono" x-text="invoice.invoice_number || '-'"></td> <td class="px-6 py-4 text-sm font-mono" x-text="invoice.invoice_number || '-'"></td>
<td class="px-6 py-4 text-right"> <td class="px-6 py-4 text-right">
@@ -64,11 +64,11 @@
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{ :class="{
'bg-green-100 text-green-800': invoice.status === 'paid', 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'open', 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': invoice.status === 'open',
'bg-gray-100 text-gray-600': invoice.status === 'draft', 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': invoice.status === 'draft',
'bg-red-100 text-red-800': invoice.status === 'uncollectible', 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': invoice.status === 'uncollectible',
'bg-gray-100 text-gray-500': invoice.status === 'void' 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500': invoice.status === 'void'
}" }"
x-text="invoice.status.toUpperCase()"></span> x-text="invoice.status.toUpperCase()"></span>
</td> </td>
@@ -77,21 +77,17 @@
<a x-show="invoice.hosted_invoice_url" <a x-show="invoice.hosted_invoice_url"
:href="invoice.hosted_invoice_url" :href="invoice.hosted_invoice_url"
target="_blank" target="_blank"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors" class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
title="View Invoice"> title="View Invoice">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
View View
</a> </a>
<a x-show="invoice.invoice_pdf_url" <a x-show="invoice.invoice_pdf_url"
:href="invoice.invoice_pdf_url" :href="invoice.invoice_pdf_url"
target="_blank" target="_blank"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors" class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors"
title="Download PDF"> title="Download PDF">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span x-html="$icon('download', 'w-3.5 h-3.5 mr-1')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
PDF PDF
</a> </a>
</div> </div>
@@ -118,28 +114,9 @@ function merchantBillingHistory() {
this.loadInvoices(); this.loadInvoices();
}, },
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadInvoices() { async loadInvoices() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
try { try {
const resp = await fetch('/api/v1/merchants/billing/invoices', { const data = await apiClient.get('/merchants/billing/invoices');
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load invoices');
const data = await resp.json();
this.invoices = data.invoices || data.items || []; this.invoices = data.invoices || data.items || [];
} catch (err) { } catch (err) {
console.error('Error loading invoices:', err); console.error('Error loading invoices:', err);

View File

@@ -1,180 +0,0 @@
{# app/modules/billing/templates/billing/merchant/dashboard.html #}
{% extends "merchant/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div x-data="merchantDashboard()">
<!-- Welcome -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900">Welcome back<span x-show="merchantName">, <span x-text="merchantName"></span></span></h2>
<p class="mt-1 text-gray-500">Here is an overview of your account.</p>
</div>
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Active Subscriptions -->
<div class="flex items-center p-6 bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-3 mr-4 text-indigo-600 bg-indigo-100 rounded-full">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Active Subscriptions</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats.active_subscriptions">--</p>
</div>
</div>
<!-- Total Stores -->
<div class="flex items-center p-6 bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-3 mr-4 text-green-600 bg-green-100 rounded-full">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Total Stores</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats.total_stores">--</p>
</div>
</div>
<!-- Current Plan -->
<div class="flex items-center p-6 bg-white rounded-lg shadow-sm border border-gray-200">
<div class="p-3 mr-4 text-purple-600 bg-purple-100 rounded-full">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-500">Current Plan</p>
<p class="text-2xl font-bold text-gray-900" x-text="stats.current_plan || '--'">--</p>
</div>
</div>
</div>
<!-- Subscription Overview -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Subscription Overview</h3>
</div>
<div class="p-6">
<!-- Loading -->
<div x-show="loading" class="text-center py-8 text-gray-500">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading...
</div>
<!-- Subscriptions list -->
<div x-show="!loading && subscriptions.length > 0" class="space-y-4">
<template x-for="sub in subscriptions" :key="sub.id">
<div class="flex items-center justify-between p-4 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
<div>
<p class="font-semibold text-gray-900" x-text="sub.platform_name || 'Subscription'"></p>
<p class="text-sm text-gray-500">
<span x-text="sub.tier" class="capitalize"></span> &middot;
Renews <span x-text="formatDate(sub.period_end)"></span>
</p>
</div>
<span class="px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': sub.status === 'active',
'bg-blue-100 text-blue-800': sub.status === 'trial',
'bg-yellow-100 text-yellow-800': sub.status === 'past_due',
'bg-red-100 text-red-800': sub.status === 'cancelled'
}"
x-text="sub.status.replace('_', ' ')"></span>
</div>
</template>
</div>
<!-- Empty state -->
<div x-show="!loading && subscriptions.length === 0" class="text-center py-8">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p class="text-gray-500">No active subscriptions.</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function merchantDashboard() {
return {
loading: true,
merchantName: '',
stats: {
active_subscriptions: '--',
total_stores: '--',
current_plan: '--'
},
subscriptions: [],
init() {
// Get merchant name from parent component
const token = this.getToken();
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
this.merchantName = payload.merchant_name || '';
} catch (e) {}
}
this.loadDashboard();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadDashboard() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
try {
const resp = await fetch('/api/v1/merchants/billing/subscriptions', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
const data = await resp.json();
this.subscriptions = data.subscriptions || data.items || [];
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
this.stats.active_subscriptions = active.length;
this.stats.total_stores = this.subscriptions.length;
this.stats.current_plan = active.length > 0
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
: 'None';
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
this.loading = false;
}
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
};
}
</script>
{% endblock %}

View File

@@ -1,163 +0,0 @@
{# app/modules/billing/templates/billing/merchant/login.html #}
{# Standalone login page - does NOT extend merchant/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Merchant Login - Wizamart</title>
<!-- Fonts -->
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body class="bg-gray-50 font-sans" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4" x-data="merchantLogin()">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-indigo-600 rounded-xl mb-4">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-gray-900">Merchant Portal</h1>
<p class="mt-1 text-gray-500">Sign in to manage your account</p>
</div>
<!-- Login Card -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<!-- Error message -->
<div x-show="error" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
</div>
<form @submit.prevent="handleLogin()">
<!-- Email/Username -->
<div class="mb-5">
<label for="login_email" class="block text-sm font-medium text-gray-700 mb-1">
Email or Username
</label>
<input
id="login_email"
type="text"
x-model="email"
required
autocomplete="username"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="you@example.com"
/>
</div>
<!-- Password -->
<div class="mb-6">
<label for="login_password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="login_password"
type="password"
x-model="password"
required
autocomplete="current-password"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="Enter your password"
/>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="loading || !email || !password"
class="w-full px-4 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!loading">Sign In</span>
<span x-show="loading" class="inline-flex items-center">
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Signing in...
</span>
</button>
</form>
</div>
<!-- Footer -->
<p class="mt-6 text-center text-sm text-gray-400">
&copy; 2026 Wizamart. All rights reserved.
</p>
</div>
</div>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
<script>
function merchantLogin() {
return {
email: '',
password: '',
loading: false,
error: null,
init() {
// If already logged in, redirect to dashboard
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
if (match && match[1]) {
window.location.href = '/merchants/billing/';
}
},
async handleLogin() {
this.loading = true;
this.error = null;
try {
const resp = await fetch('/api/v1/merchants/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: this.email,
password: this.password
})
});
const data = await resp.json();
if (!resp.ok) {
this.error = data.detail || 'Invalid credentials. Please try again.';
return;
}
// Set merchant_token cookie (expires in 24 hours)
const token = data.access_token || data.token;
if (token) {
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `merchant_token=${encodeURIComponent(token)}; path=/; expires=${expires}; SameSite=Lax`;
window.location.href = '/merchants/billing/';
} else {
this.error = 'Login succeeded but no token was returned.';
}
} catch (err) {
console.error('Login error:', err);
this.error = 'Unable to connect to the server. Please try again.';
} finally {
this.loading = false;
}
}
};
}
</script>
</body>
</html>

View File

@@ -7,18 +7,16 @@
<div x-data="merchantSubscriptionDetail()"> <div x-data="merchantSubscriptionDetail()">
<!-- Back link and header --> <!-- Back link and header -->
<div class="mb-8"> <div class="mb-8 mt-6">
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4"> <a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span x-html="$icon('chevron-left', 'w-4 h-4 mr-1')"></span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Subscriptions Back to Subscriptions
</a> </a>
<h2 class="text-2xl font-bold text-gray-900">Subscription Details</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Subscription Details</h2>
</div> </div>
<!-- Loading --> <!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-500"> <div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24"> <svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@@ -27,78 +25,78 @@
</div> </div>
<!-- Error --> <!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg"> <div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p> <p class="text-sm text-red-800 dark:text-red-400" x-text="error"></p>
</div> </div>
<!-- Success Message --> <!-- Success Message -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg"> <div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm text-green-800" x-text="successMessage"></p> <p class="text-sm text-green-800 dark:text-green-400" x-text="successMessage"></p>
</div> </div>
<!-- Subscription Info --> <!-- Subscription Info -->
<div x-show="!loading && subscription" class="space-y-6"> <div x-show="!loading && subscription" class="space-y-6">
<!-- Main Details Card --> <!-- Main Details Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between"> <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || 'Subscription'"></h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="subscription?.platform_name || 'Subscription'"></h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full" <span class="px-3 py-1 text-sm font-semibold rounded-full"
:class="{ :class="{
'bg-green-100 text-green-800': subscription?.status === 'active', 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': subscription?.status === 'active',
'bg-blue-100 text-blue-800': subscription?.status === 'trial', 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800': subscription?.status === 'past_due', 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': subscription?.status === 'past_due',
'bg-red-100 text-red-800': subscription?.status === 'cancelled', 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': subscription?.status === 'cancelled',
'bg-gray-100 text-gray-600': subscription?.status === 'expired' 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': subscription?.status === 'expired'
}" }"
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span> x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
</div> </div>
<div class="p-6"> <div class="p-6">
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div> <div>
<dt class="text-sm font-medium text-gray-500">Tier</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Tier</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="capitalize(subscription?.tier)"></dd> <dd class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="capitalize(subscription?.tier)"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Billing Period</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Billing Period</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd> <dd class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Period End</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Period End</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd> <dd class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="formatDate(subscription?.period_end)"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Platform</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.platform_name || '-'"></dd> <dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="subscription?.platform_name || '-'"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Created</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="formatDate(subscription?.created_at)"></dd> <dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.created_at)"></dd>
</div> </div>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Auto Renew</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Auto Renew</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd> <dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
</div> </div>
</dl> </dl>
</div> </div>
</div> </div>
<!-- Feature Limits Card --> <!-- Feature Limits Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900">Plan Features</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Plan Features</h3>
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code"> <template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code">
<div class="p-4 bg-gray-50 rounded-lg"> <div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p> <p class="text-sm text-gray-500 dark:text-gray-400" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
<p class="text-xl font-bold text-gray-900" x-text="fl.limit_value || 'Unlimited'"></p> <p class="text-xl font-bold text-gray-900 dark:text-gray-100" x-text="fl.limit_value || 'Unlimited'"></p>
</div> </div>
</template> </template>
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length"> <template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
<div class="p-4 bg-gray-50 rounded-lg sm:col-span-3"> <div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg sm:col-span-3">
<p class="text-sm text-gray-500 text-center">No feature limits configured for this tier</p> <p class="text-sm text-gray-500 dark:text-gray-400 text-center">No feature limits configured for this tier</p>
</div> </div>
</template> </template>
</div> </div>
@@ -107,24 +105,24 @@
<!-- Change Plan --> <!-- Change Plan -->
<div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')" <div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')"
class="bg-white rounded-lg shadow-sm border border-gray-200"> class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900">Change Plan</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Change Plan</h3>
</div> </div>
<div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
<template x-for="t in availableTiers" :key="t.code"> <template x-for="t in availableTiers" :key="t.code">
<div class="p-4 border rounded-lg transition-colors" <div class="p-4 border rounded-lg transition-colors"
:class="t.is_current ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-gray-300'"> :class="t.is_current ? 'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'">
<h4 class="font-semibold text-gray-900" x-text="t.name"></h4> <h4 class="font-semibold text-gray-900 dark:text-gray-100" x-text="t.name"></h4>
<p class="text-sm text-gray-500 mt-1" x-text="formatCurrency(t.price_monthly_cents) + '/mo'"></p> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1" x-text="formatCurrency(t.price_monthly_cents) + '/mo'"></p>
<template x-if="t.is_current"> <template x-if="t.is_current">
<span class="inline-block mt-3 px-3 py-1 text-xs font-semibold text-indigo-700 bg-indigo-100 rounded-full">Current Plan</span> <span class="inline-block mt-3 px-3 py-1 text-xs font-semibold text-purple-700 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">Current Plan</span>
</template> </template>
<template x-if="!t.is_current"> <template x-if="!t.is_current">
<button @click="changeTier(t.code)" <button @click="changeTier(t.code)"
:disabled="changingTier" :disabled="changingTier"
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors disabled:opacity-50" class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors disabled:opacity-50"
:class="t.can_upgrade ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-600 hover:bg-gray-700'" :class="t.can_upgrade ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-600 hover:bg-gray-700'"
x-text="changingTier ? 'Processing...' : (t.can_upgrade ? 'Upgrade' : 'Downgrade')"> x-text="changingTier ? 'Processing...' : (t.can_upgrade ? 'Upgrade' : 'Downgrade')">
</button> </button>
</template> </template>
@@ -152,11 +150,6 @@ function merchantSubscriptionDetail() {
this.loadSubscription(); this.loadSubscription();
}, },
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
getPlatformId() { getPlatformId() {
// Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id} // Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id}
const parts = window.location.pathname.split('/'); const parts = window.location.pathname.split('/');
@@ -164,23 +157,9 @@ function merchantSubscriptionDetail() {
}, },
async loadSubscription() { async loadSubscription() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
const platformId = this.getPlatformId(); const platformId = this.getPlatformId();
try { try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}`, { const data = await apiClient.get(`/merchants/billing/subscriptions/${platformId}`);
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load subscription');
const data = await resp.json();
this.subscription = data.subscription || data; this.subscription = data.subscription || data;
} catch (err) { } catch (err) {
console.error('Error:', err); console.error('Error:', err);
@@ -194,15 +173,8 @@ function merchantSubscriptionDetail() {
}, },
async loadAvailableTiers(platformId) { async loadAvailableTiers(platformId) {
const token = this.getToken();
if (!token) return;
try { try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/tiers`, { const data = await apiClient.get(`/merchants/billing/subscriptions/${platformId}/tiers`);
headers: { 'Authorization': `Bearer ${token}` }
});
if (!resp.ok) return;
const data = await resp.json();
this.availableTiers = data.tiers || []; this.availableTiers = data.tiers || [];
} catch (err) { } catch (err) {
console.error('Failed to load tiers:', err); console.error('Failed to load tiers:', err);
@@ -216,23 +188,13 @@ function merchantSubscriptionDetail() {
this.error = null; this.error = null;
this.successMessage = null; this.successMessage = null;
const token = this.getToken();
const platformId = this.getPlatformId(); const platformId = this.getPlatformId();
try { try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/change-tier`, { const result = await apiClient.post(`/merchants/billing/subscriptions/${platformId}/change-tier`, {
method: 'POST', tier_code: tierCode,
headers: { is_annual: this.subscription?.is_annual || false
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ tier_code: tierCode, is_annual: this.subscription?.is_annual || false })
}); });
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.detail || 'Failed to change tier');
}
const result = await resp.json();
this.successMessage = result.message || 'Plan changed successfully.'; this.successMessage = result.message || 'Plan changed successfully.';
// Reload data // Reload data

View File

@@ -7,22 +7,22 @@
<div x-data="merchantSubscriptions()"> <div x-data="merchantSubscriptions()">
<!-- Page Header --> <!-- Page Header -->
<div class="mb-8"> <div class="mb-8 mt-6">
<h2 class="text-2xl font-bold text-gray-900">My Subscriptions</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Subscriptions</h2>
<p class="mt-1 text-gray-500">Manage your platform subscriptions and plans.</p> <p class="mt-1 text-gray-500 dark:text-gray-400">Manage your platform subscriptions and plans.</p>
</div> </div>
<!-- Error --> <!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg"> <div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p> <p class="text-sm text-red-800 dark:text-red-400" x-text="error"></p>
</div> </div>
<!-- Subscriptions Table --> <!-- Subscriptions Table -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full whitespace-nowrap"> <table class="w-full whitespace-nowrap">
<thead> <thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase bg-gray-50 border-b border-gray-200"> <tr class="text-xs font-semibold tracking-wide text-left text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
<th class="px-6 py-3">Platform</th> <th class="px-6 py-3">Platform</th>
<th class="px-6 py-3">Tier</th> <th class="px-6 py-3">Tier</th>
<th class="px-6 py-3">Status</th> <th class="px-6 py-3">Status</th>
@@ -30,11 +30,11 @@
<th class="px-6 py-3 text-right">Actions</th> <th class="px-6 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Loading --> <!-- Loading -->
<template x-if="loading"> <template x-if="loading">
<tr> <tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500"> <td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
<svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24"> <svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
@@ -47,7 +47,7 @@
<!-- Empty --> <!-- Empty -->
<template x-if="!loading && subscriptions.length === 0"> <template x-if="!loading && subscriptions.length === 0">
<tr> <tr>
<td colspan="5" class="px-6 py-8 text-center text-gray-500"> <td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
No subscriptions found. No subscriptions found.
</td> </td>
</tr> </tr>
@@ -55,35 +55,35 @@
<!-- Rows --> <!-- Rows -->
<template x-for="sub in subscriptions" :key="sub.id"> <template x-for="sub in subscriptions" :key="sub.id">
<tr class="text-gray-700 hover:bg-gray-50 transition-colors"> <tr class="text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 py-4"> <td class="px-6 py-4">
<p class="font-semibold text-gray-900" x-text="sub.platform_name"></p> <p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.platform_name"></p>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{ :class="{
'bg-indigo-100 text-indigo-800': sub.tier === 'essential', 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400': sub.tier === 'essential',
'bg-blue-100 text-blue-800': sub.tier === 'professional', 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.tier === 'professional',
'bg-green-100 text-green-800': sub.tier === 'business', 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.tier === 'business',
'bg-yellow-100 text-yellow-800': sub.tier === 'enterprise' 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': sub.tier === 'enterprise'
}" }"
x-text="sub.tier.charAt(0).toUpperCase() + sub.tier.slice(1)"></span> x-text="sub.tier.charAt(0).toUpperCase() + sub.tier.slice(1)"></span>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{ :class="{
'bg-green-100 text-green-800': sub.status === 'active', 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.status === 'active',
'bg-blue-100 text-blue-800': sub.status === 'trial', 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.status === 'trial',
'bg-yellow-100 text-yellow-800': sub.status === 'past_due', 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': sub.status === 'past_due',
'bg-red-100 text-red-800': sub.status === 'cancelled', 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': sub.status === 'cancelled',
'bg-gray-100 text-gray-600': sub.status === 'expired' 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': sub.status === 'expired'
}" }"
x-text="sub.status.replace('_', ' ').toUpperCase()"></span> x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
</td> </td>
<td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td> <td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td>
<td class="px-6 py-4 text-right"> <td class="px-6 py-4 text-right">
<a :href="'/merchants/billing/subscriptions/' + sub.platform_id" <a :href="'/merchants/billing/subscriptions/' + sub.platform_id"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors"> class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors">
View Details View Details
</a> </a>
</td> </td>
@@ -109,28 +109,9 @@ function merchantSubscriptions() {
this.loadSubscriptions(); this.loadSubscriptions();
}, },
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadSubscriptions() { async loadSubscriptions() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
try { try {
const resp = await fetch('/api/v1/merchants/billing/subscriptions', { const data = await apiClient.get('/merchants/billing/subscriptions');
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.status === 401) {
window.location.href = '/merchants/login';
return;
}
if (!resp.ok) throw new Error('Failed to load subscriptions');
const data = await resp.json();
this.subscriptions = data.subscriptions || data.items || []; this.subscriptions = data.subscriptions || data.items || [];
} catch (err) { } catch (err) {
console.error('Error loading subscriptions:', err); console.error('Error loading subscriptions:', err);

View File

@@ -0,0 +1,93 @@
# app/modules/core/routes/pages/merchant.py
"""
Core Merchant Page Routes (HTML rendering).
Merchant pages for core functionality:
- Login page
- Dashboard
- Root redirect
These are core concerns, not billing-specific, matching the admin pattern
where login/dashboard live in core (app/modules/core/routes/pages/admin.py).
"""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header, get_current_merchant_optional, get_db
from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.templates_config import templates
from models.schema.auth import UserContext
ROUTE_CONFIG = {
"prefix": "",
}
router = APIRouter()
# ============================================================================
# PUBLIC ROUTES (No Authentication Required)
# ============================================================================
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def merchant_root(
current_user: UserContext | None = Depends(get_current_merchant_optional),
):
"""
Redirect /merchants/ based on authentication status.
- Authenticated merchant users -> /merchants/dashboard
- Unauthenticated users -> /merchants/login
"""
if current_user:
return RedirectResponse(url="/merchants/dashboard", status_code=302)
return RedirectResponse(url="/merchants/login", status_code=302)
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def merchant_login_page(
request: Request,
current_user: UserContext | None = Depends(get_current_merchant_optional),
):
"""
Render merchant login page.
If user is already authenticated as merchant, redirect to dashboard.
Otherwise, show login form.
"""
if current_user:
return RedirectResponse(url="/merchants/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/merchant/login.html", {"request": request})
# ============================================================================
# AUTHENTICATED ROUTES (Merchant Only)
# ============================================================================
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
async def merchant_dashboard_page(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render merchant dashboard page.
Shows merchant overview with stores and subscriptions.
"""
context = get_context_for_frontend(
FrontendType.MERCHANT,
request,
db,
user=current_user,
)
return templates.TemplateResponse(
"core/merchant/dashboard.html",
context,
)

View File

@@ -0,0 +1,134 @@
// app/modules/core/static/merchant/js/init-alpine.js
/**
* Alpine.js initialization for merchant pages
* Provides common data and methods for all merchant pages
*/
// Use centralized logger
const merchantLog = window.LogConfig.log;
console.log('[MERCHANT INIT-ALPINE] Loading...');
// Sidebar section state persistence
const MERCHANT_SIDEBAR_STORAGE_KEY = 'merchant_sidebar_sections';
function getMerchantSidebarSectionsFromStorage() {
try {
const stored = localStorage.getItem(MERCHANT_SIDEBAR_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
billing: true,
account: true
};
}
function saveMerchantSidebarSectionsToStorage(sections) {
try {
localStorage.setItem(MERCHANT_SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
} catch (e) {
console.warn('[MERCHANT INIT-ALPINE] Failed to save sidebar state to localStorage:', e);
}
}
function data() {
console.log('[MERCHANT INIT-ALPINE] data() function called');
return {
dark: false,
isSideMenuOpen: false,
isProfileMenuOpen: false,
currentPage: '',
merchantName: '',
// Sidebar collapsible sections state
openSections: getMerchantSidebarSectionsFromStorage(),
init() {
// Set current page from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
// For /merchants/dashboard -> 'dashboard'
// For /merchants/billing/subscriptions -> 'subscriptions'
this.currentPage = segments[segments.length - 1] || 'dashboard';
// Load merchant name from JWT token
const token = localStorage.getItem('merchant_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
this.merchantName = payload.merchant_name || payload.sub || 'Merchant';
} catch (e) {
this.merchantName = 'Merchant';
}
}
// Load theme preference
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
this.dark = true;
}
// Save last visited page (for redirect after login)
if (!path.includes('/login') &&
!path.includes('/logout') &&
!path.includes('/errors/')) {
try {
localStorage.setItem('merchant_last_visited_page', path);
} catch (e) {
// Ignore storage errors
}
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},
closeSideMenu() {
this.isSideMenuOpen = false;
},
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen;
},
closeProfileMenu() {
this.isProfileMenuOpen = false;
},
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
},
// Sidebar section toggle with persistence
toggleSection(section) {
this.openSections[section] = !this.openSections[section];
saveMerchantSidebarSectionsToStorage(this.openSections);
},
async handleLogout() {
console.log('Logging out merchant user...');
try {
// Call logout API
await apiClient.post('/merchants/auth/logout');
console.log('Logout API called successfully');
} catch (error) {
console.error('Logout API error (continuing anyway):', error);
} finally {
// Clear merchant tokens only
console.log('Clearing merchant tokens...');
localStorage.removeItem('merchant_token');
console.log('Redirecting to login...');
window.location.href = '/merchants/login';
}
}
};
}

View File

@@ -0,0 +1,143 @@
// app/modules/core/static/merchant/js/login.js
// noqa: js-003 - Standalone login page, doesn't use base layout
// noqa: js-004 - No sidebar on login page, doesn't need currentPage
// Use centralized logger
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
function merchantLogin() {
return {
dark: false,
credentials: {
email: '',
password: ''
},
loading: false,
error: null,
success: null,
errors: {},
init() {
// Guard against multiple initialization
if (window._merchantLoginInitialized) return;
window._merchantLoginInitialized = true;
loginLog.info('=== MERCHANT LOGIN PAGE INITIALIZING ===');
// Just set theme - NO auth checking, NO redirecting!
// If user lands here with a valid token, the server-side route
// already handles the redirect. Don't redirect from JS or it
// creates an infinite loop with expired tokens.
this.dark = localStorage.getItem('theme') === 'dark';
const token = localStorage.getItem('merchant_token');
if (token) {
loginLog.warn('Found existing token on login page');
loginLog.info('Not redirecting - server handles auth redirect, clearing stale token');
localStorage.removeItem('merchant_token');
}
loginLog.info('=== MERCHANT LOGIN PAGE INITIALIZATION COMPLETE ===');
},
clearTokens() {
loginLog.debug('Clearing merchant auth tokens...');
localStorage.removeItem('merchant_token');
},
clearErrors() {
this.error = null;
this.success = null;
this.errors = {};
},
validateForm() {
this.clearErrors();
let isValid = true;
if (!this.credentials.email.trim()) {
this.errors.email = 'Email is required';
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
isValid = false;
} else if (this.credentials.password.length < 6) {
this.errors.password = 'Password must be at least 6 characters';
isValid = false;
}
return isValid;
},
async handleLogin() {
loginLog.info('=== MERCHANT LOGIN ATTEMPT STARTED ===');
if (!this.validateForm()) {
loginLog.warn('Form validation failed, aborting login');
return;
}
this.loading = true;
this.clearErrors();
try {
loginLog.info('Calling merchant login API endpoint...');
const url = '/merchants/auth/login';
const payload = {
email_or_username: this.credentials.email.trim(),
password: this.credentials.password
};
const response = await apiClient.post(url, payload);
loginLog.info('Login API response received');
// Validate response
if (!response.access_token && !response.token) {
loginLog.error('Invalid response: No access token');
throw new Error('Invalid response from server - no token');
}
loginLog.info('Login successful, storing authentication data...');
// Store authentication data
const token = response.access_token || response.token;
localStorage.setItem('merchant_token', token);
// Show success message
this.success = 'Login successful! Redirecting...';
// Check for last visited page
const lastPage = localStorage.getItem('merchant_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/merchants/') && !lastPage.includes('/login'))
? lastPage
: '/merchants/dashboard';
loginLog.info('Redirecting to:', redirectTo);
window.location.href = redirectTo;
} catch (error) {
window.LogConfig.logError(error, 'MerchantLogin');
this.error = error.message || 'Invalid email or password. Please try again.';
// Only clear tokens on login FAILURE
this.clearTokens();
} finally {
this.loading = false;
loginLog.info('=== MERCHANT LOGIN ATTEMPT FINISHED ===');
}
},
toggleDarkMode() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
}
}
}
loginLog.info('Merchant login module loaded');

View File

@@ -0,0 +1,150 @@
{# app/modules/core/templates/core/merchant/dashboard.html #}
{% extends "merchant/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div x-data="merchantDashboard()">
<!-- Welcome -->
<div class="mb-8 mt-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Welcome back<span x-show="merchantName">, <span x-text="merchantName"></span></span></h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">Here is an overview of your account.</p>
</div>
<!-- Quick Stats -->
<div class="grid gap-6 mb-8 md:grid-cols-3">
<!-- Active Subscriptions -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<span x-html="$icon('clipboard-list', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Subscriptions</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.active_subscriptions">--</p>
</div>
</div>
<!-- Total Stores -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 rounded-full">
<span x-html="$icon('shopping-bag', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Stores</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.total_stores">--</p>
</div>
</div>
<!-- Current Plan -->
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<span x-html="$icon('sparkles', 'w-6 h-6')"></span>
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Current Plan</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.current_plan || '--'">--</p>
</div>
</div>
</div>
<!-- Subscription Overview -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Subscription Overview</h3>
</div>
<div class="p-6">
<!-- Loading -->
<div x-show="loading" class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading...
</div>
<!-- Subscriptions list -->
<div x-show="!loading && subscriptions.length > 0" class="space-y-4">
<template x-for="sub in subscriptions" :key="sub.id">
<div class="flex items-center justify-between p-4 border border-gray-100 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.platform_name || 'Subscription'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="sub.tier" class="capitalize"></span> &middot;
Renews <span x-text="formatDate(sub.period_end)"></span>
</p>
</div>
<span class="px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.status === 'trial',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': sub.status === 'past_due',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': sub.status === 'cancelled'
}"
x-text="sub.status.replace('_', ' ')"></span>
</div>
</template>
</div>
<!-- Empty state -->
<div x-show="!loading && subscriptions.length === 0" class="text-center py-8">
<span x-html="$icon('clipboard-list', 'w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600')"></span>
<p class="text-gray-500 dark:text-gray-400">No active subscriptions.</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function merchantDashboard() {
return {
loading: true,
merchantName: '',
stats: {
active_subscriptions: '--',
total_stores: '--',
current_plan: '--'
},
subscriptions: [],
init() {
// Get merchant name from parent component
const token = localStorage.getItem('merchant_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
this.merchantName = payload.merchant_name || '';
} catch (e) {}
}
this.loadDashboard();
},
async loadDashboard() {
try {
const data = await apiClient.get('/merchants/billing/subscriptions');
this.subscriptions = data.subscriptions || data.items || [];
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
this.stats.active_subscriptions = active.length;
this.stats.total_stores = this.subscriptions.length;
this.stats.current_plan = active.length > 0
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
: 'None';
} catch (error) {
console.error('Failed to load dashboard:', error);
} finally {
this.loading = false;
}
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
};
}
</script>
{% endblock %}

View File

@@ -2,9 +2,9 @@
""" """
Tenancy module merchant API routes. Tenancy module merchant API routes.
Provides merchant-facing API endpoints for the merchant portal: Aggregates all merchant tenancy routes:
- /account/stores - List merchant's stores - /auth/* - Merchant authentication (login, logout, /me)
- /account/profile - Get/update merchant profile - /account/* - Merchant account management (stores, profile)
Auto-discovered by the route system (merchant.py in routes/api/). Auto-discovered by the route system (merchant.py in routes/api/).
""" """
@@ -21,13 +21,17 @@ from app.core.database import get_db
from app.modules.tenancy.models import Merchant from app.modules.tenancy.models import Merchant
from models.schema.auth import UserContext from models.schema.auth import UserContext
from .merchant_auth import merchant_auth_router
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
ROUTE_CONFIG = { # Include auth routes (/auth/login, /auth/logout, /auth/me)
"prefix": "/account", router.include_router(merchant_auth_router, tags=["merchant-auth"])
}
# Account routes are defined below with /account prefix
_account_router = APIRouter(prefix="/account")
# ============================================================================ # ============================================================================
@@ -81,11 +85,11 @@ def _get_user_merchant(db: Session, user_context: UserContext) -> Merchant:
# ============================================================================ # ============================================================================
# ENDPOINTS # ACCOUNT ENDPOINTS
# ============================================================================ # ============================================================================
@router.get("/stores") @_account_router.get("/stores")
async def merchant_stores( async def merchant_stores(
request: Request, request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
@@ -114,7 +118,7 @@ async def merchant_stores(
return {"stores": stores} return {"stores": stores}
@router.get("/profile") @_account_router.get("/profile")
async def merchant_profile( async def merchant_profile(
request: Request, request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
@@ -140,7 +144,7 @@ async def merchant_profile(
} }
@router.put("/profile") @_account_router.put("/profile")
async def update_merchant_profile( async def update_merchant_profile(
request: Request, request: Request,
profile_data: MerchantProfileUpdate, profile_data: MerchantProfileUpdate,
@@ -177,3 +181,7 @@ async def update_merchant_profile(
"tax_number": merchant.tax_number, "tax_number": merchant.tax_number,
"is_verified": merchant.is_verified, "is_verified": merchant.is_verified,
} }
# Include account routes in main router
router.include_router(_account_router, tags=["merchant-account"])

View File

@@ -0,0 +1,117 @@
# app/modules/tenancy/routes/api/merchant_auth.py
"""
Merchant authentication endpoints.
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/merchants (restricted to merchant routes only)
- Returns token in response for localStorage (API calls)
This prevents merchant cookies from being sent to admin or store routes.
"""
import logging
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app.api.deps import get_current_merchant_from_cookie_or_header
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.modules.core.services.auth_service import auth_service
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse, UserContext
merchant_auth_router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@merchant_auth_router.post("/login", response_model=LoginResponse)
def merchant_login(
user_credentials: UserLogin, response: Response, db: Session = Depends(get_db)
):
"""
Merchant login endpoint.
Only allows users who own at least one active merchant to login.
Returns JWT token for authenticated merchant users.
Sets token in two places:
1. HTTP-only cookie with path=/merchants (for browser page navigation)
2. Response body (for localStorage and API calls)
The cookie is restricted to /merchants/* routes only to prevent
it from being sent to admin or store routes.
"""
# Authenticate user and verify merchant ownership
login_result = auth_service.login_merchant(db=db, user_credentials=user_credentials)
logger.info(f"Merchant login successful: {login_result['user'].username}")
# Set HTTP-only cookie for browser navigation
# CRITICAL: path=/merchants restricts cookie to merchant routes only
response.set_cookie(
key="merchant_token",
value=login_result["token_data"]["access_token"],
httponly=True, # JavaScript cannot access (XSS protection)
secure=should_use_secure_cookies(), # HTTPS only in production/staging
samesite="lax", # CSRF protection
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
path="/merchants", # RESTRICTED TO MERCHANT ROUTES ONLY
)
logger.debug(
f"Set merchant_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path=/merchants, httponly=True, secure={should_use_secure_cookies()})"
)
# Also return token in response for localStorage (API calls)
return LoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=login_result["user"],
)
@merchant_auth_router.get("/me", response_model=UserResponse)
def get_current_merchant(
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
):
"""
Get current authenticated merchant user.
This endpoint validates the token and ensures the user owns merchants.
Returns the current user's information.
Token can come from:
- Authorization header (API calls)
- merchant_token cookie (browser navigation, path=/merchants only)
"""
logger.info(f"Merchant user info requested: {current_user.username}")
return current_user
@merchant_auth_router.post("/logout", response_model=LogoutResponse)
def merchant_logout(response: Response):
"""
Merchant logout endpoint.
Clears the merchant_token cookie.
Client should also remove token from localStorage.
"""
logger.info("Merchant logout")
# Clear the cookie (must match path used when setting)
response.delete_cookie(
key="merchant_token",
path="/merchants",
)
# Also clear legacy cookie with path=/ (from before path isolation was added)
response.delete_cookie(
key="merchant_token",
path="/",
)
logger.debug("Deleted merchant_token cookies (both /merchants and / paths)")
return LogoutResponse(message="Logged out successfully")

View File

@@ -0,0 +1,141 @@
{# app/modules/tenancy/templates/tenancy/merchant/login.html #}
{# Standalone login page - does NOT extend merchant/base.html #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="merchantLogin()" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Merchant Login - Wizamart</title>
<!-- Fonts: Local fallback + Google Fonts -->
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
<style>
[x-cloak] { display: none !important; }
</style>
</head>
<body>
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
<div class="flex flex-col overflow-y-auto md:flex-row">
<div class="h-32 md:h-auto md:w-1/2">
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
src="{{ url_for('static', path='admin/img/login-office.jpeg') }}" alt="Office" />
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
src="{{ url_for('static', path='admin/img/login-office-dark.jpeg') }}" alt="Office" />
</div>
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Merchant Login
</h1>
<!-- Alert Messages -->
<div x-show="error" x-text="error"
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
x-transition></div>
<div x-show="success" x-text="success"
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
x-transition></div>
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email</span>
<input x-model="credentials.email"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.email }"
placeholder="you@example.com"
autocomplete="username"
required />
<span x-show="errors.email" x-text="errors.email"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<input x-model="credentials.password"
:disabled="loading"
@input="clearErrors"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
:class="{ 'border-red-600': errors.password }"
placeholder="***************"
type="password"
autocomplete="current-password"
required />
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400"></span>
</label>
{# noqa: FE-002 - Inline spinner SVG for loading state #}
<button type="submit" :disabled="loading"
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="loading" class="flex items-center justify-center">
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
</span>
</button>
</form>
<hr class="my-8" />
<p class="mt-4">
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
href="#">
Forgot your password?
</a>
</p>
<p class="mt-2">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="/">
&larr; Back to Platform
</a>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts - ORDER MATTERS! -->
<!-- 1. Log Configuration -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<!-- 2. Icons -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 3. Utils -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 4. API Client -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 5. Alpine.js v3 with CDN fallback -->
<script>
(function() {
var script = document.createElement('script');
script.defer = true;
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js';
script.onerror = function() {
console.warn('Alpine.js CDN failed, loading local copy...');
var fallbackScript = document.createElement('script');
fallbackScript.defer = true;
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
document.head.appendChild(fallbackScript);
};
document.head.appendChild(script);
})();
</script>
<!-- 6. Merchant Login Logic -->
<script src="{{ url_for('core_static', path='merchant/js/login.js') }}"></script>
</body>
</html>

View File

@@ -1,196 +1,80 @@
{# app/templates/merchant/base.html #} {# app/templates/merchant/base.html #}
{# Base template for the merchant billing portal #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Merchant Portal{% endblock %} - Wizamart</title> <title>{% block title %}Merchant Portal{% endblock %} - Wizamart</title>
<!-- Fonts --> <!-- Fonts: Local fallback + Google Fonts -->
<link href="/static/shared/fonts/inter.css" rel="stylesheet" /> <link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Tailwind CSS --> <!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" /> <link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
<!-- Alpine Cloak --> <!-- Alpine Cloak -->
<style> <style>
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
{% block extra_head %}{% endblock %}
</head> </head>
<body class="bg-gray-50 font-sans" x-data="merchantApp()" x-cloak> <body x-cloak>
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
<!-- Sidebar (server-side included) -->
{% include 'merchant/partials/sidebar.html' %}
<!-- Sidebar --> <div class="flex flex-col flex-1 w-full">
<aside class="hidden md:flex md:flex-shrink-0"> <!-- Header (server-side included) -->
<div class="flex flex-col w-64 bg-indigo-900"> {% include 'merchant/partials/header.html' %}
<!-- Logo / Brand -->
<div class="flex items-center h-16 px-6 bg-indigo-950">
<a href="/merchants/billing/" class="flex items-center space-x-2">
<svg class="w-8 h-8 text-indigo-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="text-lg font-bold text-white">Merchant Portal</span>
</a>
</div>
<!-- Navigation --> <!-- Main Content -->
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto"> <main class="h-full overflow-y-auto">
<a href="/merchants/billing/" <div class="container px-6 mx-auto grid">
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="currentPath === '/merchants/billing/' ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/>
</svg>
Dashboard
</a>
<a href="/merchants/billing/subscriptions"
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="currentPath.startsWith('/merchants/billing/subscription') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Subscriptions
</a>
<a href="/merchants/billing/billing"
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="currentPath.startsWith('/merchants/billing/billing') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2z"/>
</svg>
Billing History
</a>
<div class="pt-4 mt-4 border-t border-indigo-800">
<p class="px-3 mb-2 text-xs font-semibold tracking-wider text-indigo-400 uppercase">Account</p>
</div>
<a href="/merchants/account/stores"
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="currentPath.startsWith('/merchants/account/stores') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
Stores
</a>
<a href="/merchants/account/profile"
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="currentPath.startsWith('/merchants/account/profile') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Profile
</a>
</nav>
</div>
</aside>
<!-- Main Content Area -->
<div class="flex flex-col flex-1 w-full overflow-hidden">
<!-- Top Header -->
<header class="flex items-center justify-between h-16 px-6 bg-white border-b border-gray-200">
<!-- Mobile menu button -->
<button @click="sidebarOpen = !sidebarOpen" class="md:hidden p-2 rounded-md text-gray-500 hover:text-gray-700 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<div class="flex-1"></div>
<!-- Merchant info and logout -->
<div class="flex items-center space-x-4">
<span class="text-sm font-medium text-gray-700" x-text="merchantName || 'Merchant'"></span>
<button @click="logout()"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Logout
</button>
</div>
</header>
<!-- Mobile Sidebar Overlay -->
<div x-show="sidebarOpen" x-cloak
class="fixed inset-0 z-40 md:hidden"
@click="sidebarOpen = false">
<div class="fixed inset-0 bg-gray-600 bg-opacity-50"></div>
<div class="fixed inset-y-0 left-0 w-64 bg-indigo-900 z-50">
<div class="flex items-center justify-between h-16 px-6 bg-indigo-950">
<span class="text-lg font-bold text-white">Merchant Portal</span>
<button @click="sidebarOpen = false" class="text-indigo-300 hover:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<nav class="px-4 py-6 space-y-1">
<a href="/merchants/billing/" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Dashboard</a>
<a href="/merchants/billing/subscriptions" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Subscriptions</a>
<a href="/merchants/billing/billing" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Billing History</a>
<a href="/merchants/account/stores" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Stores</a>
<a href="/merchants/account/profile" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Profile</a>
</nav>
</div>
</div>
<!-- Page Content -->
<main class="flex-1 overflow-y-auto">
<div class="container px-6 py-8 mx-auto">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main> </main>
</div> </div>
</div> </div>
<!-- Alpine.js --> <!-- Core Scripts - ORDER MATTERS! -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
<!-- Base merchant app data --> <!-- 1. FIRST: Log Configuration -->
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
<!-- 2. SECOND: Icons (before Alpine.js) -->
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
<!-- 3. THIRD: Alpine.js Base Data -->
<script src="{{ url_for('core_static', path='merchant/js/init-alpine.js') }}"></script>
<!-- 4. FOURTH: Utils (standalone utilities) -->
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
<!-- 5. FIFTH: API Client (depends on Utils) -->
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
<!-- 6. SIXTH: Alpine.js v3 with CDN fallback (with defer) -->
<script> <script>
function merchantApp() { (function() {
return { var script = document.createElement('script');
sidebarOpen: false, script.defer = true;
currentPath: window.location.pathname, script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js';
merchantName: '',
init() { script.onerror = function() {
// Load merchant name from token/cookie console.warn('Alpine.js CDN failed, loading local copy...');
const token = this.getToken(); var fallbackScript = document.createElement('script');
if (token) { fallbackScript.defer = true;
try { fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
const payload = JSON.parse(atob(token.split('.')[1])); document.head.appendChild(fallbackScript);
this.merchantName = payload.merchant_name || payload.sub || 'Merchant';
} catch (e) {
this.merchantName = 'Merchant';
}
}
},
getToken() {
// Read merchant_token from cookie
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
logout() {
// Clear merchant_token cookie
document.cookie = 'merchant_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
window.location.href = '/merchants/login';
}
}; };
}
document.head.appendChild(script);
})();
</script> </script>
<!-- 7. LAST: Page-specific scripts -->
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,70 @@
{# app/templates/merchant/partials/header.html #}
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
<!-- Mobile hamburger -->
<button class="p-1 mr-5 -ml-1 rounded-md md:hidden focus:outline-none focus:shadow-outline-purple"
@click="toggleSideMenu"
aria-label="Menu">
<span x-html="$icon('menu', 'w-6 h-6')"></span>
</button>
<!-- Spacer -->
<div class="flex-1"></div>
<ul class="flex items-center flex-shrink-0 space-x-6">
<!-- Theme toggler -->
<li class="flex">
<button class="rounded-md focus:outline-none focus:shadow-outline-purple"
@click="toggleTheme"
aria-label="Toggle color mode">
<template x-if="!dark">
<span x-html="$icon('moon', 'w-5 h-5')"></span>
</template>
<template x-if="dark">
<span x-html="$icon('sun', 'w-5 h-5')"></span>
</template>
</button>
</li>
<!-- Profile menu -->
<li class="relative" x-data="{ profileOpen: false }">
<button class="align-middle rounded-full focus:shadow-outline-purple focus:outline-none"
@click="profileOpen = !profileOpen"
@keydown.escape="profileOpen = false"
aria-label="Account"
aria-haspopup="true">
<div class="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white font-semibold">
<span x-text="merchantName?.charAt(0).toUpperCase() || '?'"></span>
</div>
</button>
<ul x-show="profileOpen"
x-cloak
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click.away="profileOpen = false"
@keydown.escape="profileOpen = false"
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:border-gray-700 dark:text-gray-300 dark:bg-gray-700 z-50"
style="display: none;"
aria-label="submenu">
<li class="flex">
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
href="/merchants/account/profile">
<span x-html="$icon('user', 'w-4 h-4 mr-3')"></span>
<span>Profile</span>
</a>
</li>
<li class="flex">
<button
@click="handleLogout()"
class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200 text-left">
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
<span>Log out</span>
</button>
</li>
</ul>
</li>
</ul>
</div>
</header>

View File

@@ -0,0 +1,127 @@
{# app/templates/merchant/partials/sidebar.html #}
{# Collapsible sidebar sections with localStorage persistence - matching store pattern #}
{# ============================================================================
REUSABLE MACROS FOR SIDEBAR ITEMS
============================================================================ #}
{# Macro for collapsible section header #}
{% macro section_header(title, section_key, icon=none) %}
<div class="px-6 my-4">
<hr class="border-gray-200 dark:border-gray-700" />
</div>
<button
@click="toggleSection('{{ section_key }}')"
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class="flex items-center">
{% if icon %}
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
{% endif %}
{{ title }}
</span>
<span
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
:class="{ 'rotate-180': openSections.{{ section_key }} }"
></span>
</button>
{% endmacro %}
{# Macro for collapsible section content wrapper #}
{% macro section_content(section_key) %}
<ul
x-show="openSections.{{ section_key }}"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="mt-1 overflow-hidden"
>
{{ caller() }}
</ul>
{% endmacro %}
{# Macro for menu item - uses static href (no storeCode needed) #}
{% macro menu_item(page_id, path, icon, label) %}
<li class="relative px-6 py-3">
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
href="{{ path }}">
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
<span class="ml-4">{{ label }}</span>
</a>
</li>
{% endmacro %}
{# ============================================================================
SIDEBAR CONTENT (shared between desktop and mobile)
============================================================================ #}
{% macro sidebar_content() %}
<div class="py-4 text-gray-500 dark:text-gray-400">
<!-- Merchant Branding -->
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
href="/merchants/dashboard">
<span x-html="$icon('lightning-bolt', 'w-6 h-6 mr-2 text-purple-600')"></span>
<span>Merchant Portal</span>
</a>
<!-- Dashboard (always visible) -->
<ul class="mt-6">
{{ menu_item('dashboard', '/merchants/dashboard', 'home', 'Dashboard') }}
</ul>
<!-- Billing Section -->
{{ section_header('Billing', 'billing', 'credit-card') }}
{% call section_content('billing') %}
{{ menu_item('subscriptions', '/merchants/billing/subscriptions', 'clipboard-list', 'Subscriptions') }}
{{ menu_item('billing', '/merchants/billing/billing', 'currency-euro', 'Billing History') }}
{% endcall %}
<!-- Account Section -->
{{ section_header('Account', 'account', 'cog') }}
{% call section_content('account') %}
{{ menu_item('stores', '/merchants/account/stores', 'shopping-bag', 'Stores') }}
{{ menu_item('profile', '/merchants/account/profile', 'user', 'Profile') }}
{% endcall %}
</div>
{% endmacro %}
{# ============================================================================
DESKTOP SIDEBAR
============================================================================ #}
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
{{ sidebar_content() }}
</aside>
{# ============================================================================
MOBILE SIDEBAR
============================================================================ #}
<!-- Mobile sidebar backdrop -->
<div x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in-out duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-10 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"></div>
<!-- Mobile sidebar panel -->
<aside class="fixed inset-y-0 z-20 flex-shrink-0 w-64 mt-16 overflow-y-auto bg-white dark:bg-gray-800 md:hidden"
x-show="isSideMenuOpen"
x-transition:enter="transition ease-in-out duration-150"
x-transition:enter-start="opacity-0 transform -translate-x-20"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in-out duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0 transform -translate-x-20"
@click.away="closeSideMenu"
@keydown.escape="closeSideMenu">
{{ sidebar_content() }}
</aside>

View File

@@ -2,7 +2,7 @@
**Session Date:** 2026-01-18 **Session Date:** 2026-01-18
**Status:** Initial Analysis - Requirements Captured **Status:** Initial Analysis - Requirements Captured
**Related:** [Loyalty Program Analysis](./loyalty-program-analysis.md) **Related:** [Loyalty Program Analysis](../proposals/loyalty-program-analysis.md)
--- ---

302
docs/deployment/gitea.md Normal file
View File

@@ -0,0 +1,302 @@
# Gitea CI/CD Deployment Guide
This document describes how to **self-host Gitea** on an external server and migrate CI/CD from GitLab to **Gitea Actions** (GitHub Actions-compatible).
---
## Why Gitea?
- Lightweight, self-hosted Git forge (single binary or Docker image)
- Built-in CI/CD via **Gitea Actions** (GitHub Actions-compatible YAML)
- Built-in migration tool imports repos, issues, and PRs from GitLab
- Low resource usage compared to GitLab
---
## 1. Server Prerequisites
```bash
# Ubuntu/Debian
sudo apt update && sudo apt install -y docker.io docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker $USER # log out/in after this
```
---
## 2. Gitea Server Setup (Docker Compose)
Create a directory on your server:
```bash
mkdir -p ~/gitea && cd ~/gitea
```
Create `docker-compose.yml`:
```yaml
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: always
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=<CHANGE_ME>
- GITEA__server__ROOT_URL=https://git.yourdomain.com/
- GITEA__server__SSH_DOMAIN=git.yourdomain.com
- GITEA__server__DOMAIN=git.yourdomain.com
- GITEA__actions__ENABLED=true
volumes:
- gitea-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000" # Web UI
- "2222:22" # SSH (Git over SSH)
depends_on:
gitea-db:
condition: service_healthy
gitea-db:
image: postgres:15
container_name: gitea-db
restart: always
environment:
POSTGRES_DB: gitea
POSTGRES_USER: gitea
POSTGRES_PASSWORD: <CHANGE_ME>
volumes:
- gitea-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gitea"]
interval: 10s
timeout: 5s
retries: 5
# Gitea Actions runner — executes CI workflows
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: always
environment:
GITEA_INSTANCE_URL: http://gitea:3000
GITEA_RUNNER_REGISTRATION_TOKEN: <RUNNER_TOKEN>
GITEA_RUNNER_NAME: default-runner
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- gitea-runner-data:/data
depends_on:
- gitea
volumes:
gitea-data:
gitea-db-data:
gitea-runner-data:
```
!!! warning "Replace placeholders"
Replace `<CHANGE_ME>` with a strong database password and `<RUNNER_TOKEN>` with the token from step 4.
Start Gitea and the database first:
```bash
docker compose up -d gitea gitea-db
```
Visit `http://your-server-ip:3000` and complete the initial setup wizard.
---
## 3. Reverse Proxy with HTTPS (Nginx + Let's Encrypt)
```bash
sudo apt install -y nginx certbot python3-certbot-nginx
```
Create `/etc/nginx/sites-available/gitea`:
```nginx
server {
server_name git.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
}
```
Enable and get a certificate:
```bash
sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/
sudo certbot --nginx -d git.yourdomain.com
sudo systemctl reload nginx
```
---
## 4. Enable Actions & Register the Runner
1. Go to **Site Administration > Runners** in the Gitea web UI.
2. Click **Create new Runner** and copy the registration token.
3. Paste the token into `docker-compose.yml` as `GITEA_RUNNER_REGISTRATION_TOKEN`.
4. Start the runner:
```bash
docker compose up -d gitea-runner
```
Verify the runner appears as **Online** in the admin panel.
---
## 5. Migrate Your Repository
### Option A: Git push (code only)
```bash
# On your local machine
cd /path/to/letzshop-product-import
git remote add gitea ssh://git@git.yourdomain.com:2222/your-username/letzshop-product-import.git
git push gitea --all
git push gitea --tags
```
### Option B: Gitea built-in migration (code + issues + PRs)
1. In Gitea, click **+** > **New Migration**.
2. Select **GitLab** as the source.
3. Enter your GitLab URL and a Personal Access Token.
4. Gitea will import the repository, issues, labels, milestones, and merge requests.
---
## 6. CI/CD — GitLab vs Gitea Actions
The workflow file lives in `.gitea/workflows/ci.yml` (already created in this repository).
| GitLab CI (`.gitlab-ci.yml`) | Gitea Actions (`.gitea/workflows/ci.yml`) |
|------------------------------|-------------------------------------------|
| `stages:` + `stage:` per job | Jobs run in parallel; use `needs:` for ordering |
| `services:` (top-level on job) | `services:` nested under each job with `options:` |
| `allow_failure: true` | `continue-on-error: true` |
| `rules: - if:` | `on:` triggers + `if:` conditionals per job |
| `artifacts: paths:` | `actions/upload-artifact@v4` |
| `cache: paths:` | `actions/cache@v4` |
| `coverage: '/regex/'` | Use coverage action or parse in step |
| CI/CD Variables (UI) | Repository **Settings > Secrets** |
---
## 7. CI/CD Secrets
Configure these in your Gitea repository under **Settings > Actions > Secrets**:
| Secret | Description | Used by |
|--------|-------------|---------|
| `SSH_PRIVATE_KEY` | Private key for deployment server | Deploy job (if added) |
| `SERVER_HOST` | Production server IP/hostname | Deploy job |
| `SERVER_USER` | SSH user on production server | Deploy job |
| `SERVER_PATH` | App directory on server | Deploy job |
---
## 8. Pipeline Overview
The CI pipeline (`.gitea/workflows/ci.yml`) runs:
```
push/PR to master
├── ruff (lint)
├── pytest (tests + PostgreSQL service)
├── architecture (architecture validation)
├── dependency-scanning (pip-audit, non-blocking)
├── audit (custom audit, non-blocking)
└── docs (mkdocs build, master-only, after lint+test pass)
```
All jobs run in parallel except `docs`, which waits for `ruff`, `pytest`, and `architecture` to pass.
---
## 9. Adding a Deploy Job (Optional)
To add automated deployment via SSH (similar to the GitLab deploy stage), add this job to `.gitea/workflows/ci.yml`:
```yaml
deploy:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
needs: [ruff, pytest, architecture]
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ${{ secrets.SERVER_PATH }}
git pull origin master
source .venv/bin/activate
uv sync --frozen
python -m alembic upgrade head
sudo systemctl restart wizamart
```
---
## 10. Firewall Configuration
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
```
---
## 11. Maintenance
```bash
# View Gitea logs
docker compose -f ~/gitea/docker-compose.yml logs -f gitea
# View runner logs
docker compose -f ~/gitea/docker-compose.yml logs -f gitea-runner
# Update Gitea
cd ~/gitea
docker compose pull
docker compose up -d
# Backup Gitea data
docker run --rm -v gitea-data:/data -v $(pwd):/backup alpine \
tar czf /backup/gitea-backup-$(date +%Y%m%d).tar.gz /data
```
---
## 12. Removing GitLab (After Migration)
Once you have verified everything works on Gitea:
1. Update your local git remote:
```bash
git remote set-url origin ssh://git@git.yourdomain.com:2222/your-username/letzshop-product-import.git
```
2. The `.gitlab-ci.yml` file can be removed from the repository.
3. Archive or delete the GitLab project.

View File

@@ -160,7 +160,7 @@ nav:
- Store Operations Expansion: development/migration/store-operations-expansion.md - Store Operations Expansion: development/migration/store-operations-expansion.md
- Implementation Plans: - Implementation Plans:
- Module Migration Plan: proposals/module-migration-plan.md - Module Migration Plan: archive/module-migration-plan.md
- Admin Inventory Management: implementation/inventory-admin-migration.md - Admin Inventory Management: implementation/inventory-admin-migration.md
- Admin Notification System: implementation/admin-notification-system.md - Admin Notification System: implementation/admin-notification-system.md
- Letzshop Order Import: implementation/letzshop-order-import-improvements.md - Letzshop Order Import: implementation/letzshop-order-import-improvements.md
@@ -196,6 +196,7 @@ nav:
- Docker: deployment/docker.md - Docker: deployment/docker.md
- CloudFlare Setup: deployment/cloudflare.md - CloudFlare Setup: deployment/cloudflare.md
- GitLab CI/CD: deployment/gitlab.md - GitLab CI/CD: deployment/gitlab.md
- Gitea CI/CD: deployment/gitea.md
- Environment Variables: deployment/environment.md - Environment Variables: deployment/environment.md
- Stripe Integration: deployment/stripe-integration.md - Stripe Integration: deployment/stripe-integration.md

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,267 @@
/* Tailwind CSS v4 - Merchant Portal Styles */
/* Configuration is CSS-first in v4 */
@import "tailwindcss";
/* Source paths for content scanning */
@source "../../js/**/*.js";
@source "../../../app/templates/merchant/**/*.html";
@source "../../../app/modules/*/templates/*/merchant/**/*.html";
@source "../../../app/modules/core/static/merchant/js/**/*.js";
/* Custom theme configuration */
@theme {
/* Custom gray palette (Windmill Dashboard) */
--color-gray-50: #f9fafb;
--color-gray-100: #f4f5f7;
--color-gray-200: #e5e7eb;
--color-gray-300: #d5d6d7;
--color-gray-400: #9e9e9e;
--color-gray-500: #707275;
--color-gray-600: #4c4f52;
--color-gray-700: #24262d;
--color-gray-800: #1a1c23;
--color-gray-900: #121317;
/* Custom purple palette (Windmill Dashboard) */
--color-purple-50: #f6f5ff;
--color-purple-100: #edebfe;
--color-purple-200: #dcd7fe;
--color-purple-300: #cabffd;
--color-purple-400: #ac94fa;
--color-purple-500: #9061f9;
--color-purple-600: #7e3af2;
--color-purple-700: #6c2bd9;
--color-purple-800: #5521b5;
--color-purple-900: #4a1d96;
/* Custom orange palette */
--color-orange-50: #fff8f1;
--color-orange-100: #feecdc;
--color-orange-200: #fcd9bd;
--color-orange-300: #fdba8c;
--color-orange-400: #ff8a4c;
--color-orange-500: #ff5a1f;
--color-orange-600: #d03801;
--color-orange-700: #b43403;
--color-orange-800: #8a2c0d;
--color-orange-900: #771d1d;
/* Custom green palette */
--color-green-50: #f3faf7;
--color-green-100: #def7ec;
--color-green-200: #bcf0da;
--color-green-300: #84e1bc;
--color-green-400: #31c48d;
--color-green-500: #0e9f6e;
--color-green-600: #057a55;
--color-green-700: #046c4e;
--color-green-800: #03543f;
--color-green-900: #014737;
/* Custom red palette */
--color-red-50: #fdf2f2;
--color-red-100: #fde8e8;
--color-red-200: #fbd5d5;
--color-red-300: #f8b4b4;
--color-red-400: #f98080;
--color-red-500: #f05252;
--color-red-600: #e02424;
--color-red-700: #c81e1e;
--color-red-800: #9b1c1c;
--color-red-900: #771d1d;
/* Custom yellow palette */
--color-yellow-50: #fdfdea;
--color-yellow-100: #fdf6b2;
--color-yellow-200: #fce96a;
--color-yellow-300: #faca15;
--color-yellow-400: #e3a008;
--color-yellow-500: #c27803;
--color-yellow-600: #9f580a;
--color-yellow-700: #8e4b10;
--color-yellow-800: #723b13;
--color-yellow-900: #633112;
/* Custom teal palette */
--color-teal-50: #edfafa;
--color-teal-100: #d5f5f6;
--color-teal-200: #afecef;
--color-teal-300: #7edce2;
--color-teal-400: #16bdca;
--color-teal-500: #0694a2;
--color-teal-600: #047481;
--color-teal-700: #036672;
--color-teal-800: #05505c;
--color-teal-900: #014451;
/* Custom blue palette */
--color-blue-50: #ebf5ff;
--color-blue-100: #e1effe;
--color-blue-200: #c3ddfd;
--color-blue-300: #a4cafe;
--color-blue-400: #76a9fa;
--color-blue-500: #3f83f8;
--color-blue-600: #1c64f2;
--color-blue-700: #1a56db;
--color-blue-800: #1e429f;
--color-blue-900: #233876;
/* Custom indigo palette */
--color-indigo-50: #f0f5ff;
--color-indigo-100: #e5edff;
--color-indigo-200: #cddbfe;
--color-indigo-300: #b4c6fc;
--color-indigo-400: #8da2fb;
--color-indigo-500: #6875f5;
--color-indigo-600: #5850ec;
--color-indigo-700: #5145cd;
--color-indigo-800: #42389d;
--color-indigo-900: #362f78;
/* Custom pink palette */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce8f3;
--color-pink-200: #fad1e8;
--color-pink-300: #f8b4d9;
--color-pink-400: #f17eb8;
--color-pink-500: #e74694;
--color-pink-600: #d61f69;
--color-pink-700: #bf125d;
--color-pink-800: #99154b;
--color-pink-900: #751a3d;
/* Cool gray palette */
--color-cool-gray-50: #fbfdfe;
--color-cool-gray-100: #f1f5f9;
--color-cool-gray-200: #e2e8f0;
--color-cool-gray-300: #cfd8e3;
--color-cool-gray-400: #97a6ba;
--color-cool-gray-500: #64748b;
--color-cool-gray-600: #475569;
--color-cool-gray-700: #364152;
--color-cool-gray-800: #27303f;
--color-cool-gray-900: #1a202e;
/* Font family */
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/* Custom max-height */
--spacing-xl: 36rem;
}
/* Dark mode variant - uses class on html element */
@variant dark (&:where(.dark, .dark *));
/* Custom utilities layer */
@layer utilities {
/* Hide number input spinners */
.no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.no-spinner {
-moz-appearance: textfield;
appearance: textfield;
}
/* Shadow outline utilities for focus states */
.shadow-outline-gray {
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
}
.shadow-outline-purple {
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
}
.shadow-outline-red {
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
}
.shadow-outline-orange {
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
}
.shadow-outline-green {
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
}
.shadow-outline-blue {
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
}
.shadow-outline-yellow {
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
}
.shadow-outline-teal {
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
}
.shadow-outline-indigo {
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
}
.shadow-outline-pink {
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
}
}
/* Component layer for form styles (replaces @tailwindcss/custom-forms) */
@layer components {
/* Form input styles */
.form-input,
.form-textarea,
.form-select,
.form-multiselect {
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
@apply placeholder:text-gray-400;
}
/* Dark mode form inputs */
.dark .form-input,
.dark .form-textarea,
.dark .form-select,
.dark .form-multiselect {
@apply border-gray-600 bg-gray-700 text-gray-200;
@apply focus:border-purple-400 focus:ring-purple-400;
@apply placeholder:text-gray-500;
}
/* Checkbox styles */
.form-checkbox {
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-checkbox {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
/* Radio styles */
.form-radio {
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
}
.dark .form-radio {
@apply border-gray-600 bg-gray-700;
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
}
}
/* Base layer for default styles */
@layer base {
/* Default body styles */
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
/* Dark mode body */
.dark body {
@apply bg-gray-900 text-gray-200;
}
/* Focus visible for accessibility */
:focus-visible {
@apply outline-2 outline-offset-2 outline-purple-500;
}
.dark :focus-visible {
@apply outline-purple-400;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -59,6 +59,9 @@ class APIClient {
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) { } else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) {
token = customerToken; token = customerToken;
source = 'customer (path-based)'; source = 'customer (path-based)';
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
token = localStorage.getItem('merchant_token');
source = 'merchant (path-based)';
} else { } else {
// Default fallback for other paths // Default fallback for other paths
token = adminToken || storeToken || customerToken; token = adminToken || storeToken || customerToken;
@@ -382,6 +385,9 @@ class APIClient {
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) { } else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) {
apiLog.info('Clearing customer tokens only'); apiLog.info('Clearing customer tokens only');
localStorage.removeItem('customer_token'); localStorage.removeItem('customer_token');
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
apiLog.info('Clearing merchant tokens only');
localStorage.removeItem('merchant_token');
} else { } else {
// Fallback: clear all tokens for unknown paths // Fallback: clear all tokens for unknown paths
apiLog.info('Unknown path context, clearing all tokens'); apiLog.info('Unknown path context, clearing all tokens');

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,11 @@
/* Tailwind CSS v4 - Shop Frontend Styles */ /* Tailwind CSS v4 - Storefront Styles */
/* Configuration is CSS-first in v4 */ /* Configuration is CSS-first in v4 */
@import "tailwindcss"; @import "tailwindcss";
/* Source paths for content scanning */ /* Source paths for content scanning */
@source "../../js/**/*.js"; @source "../../js/**/*.js";
@source "../../../app/templates/shop/**/*.html"; @source "../../../app/templates/storefront/**/*.html";
@source "../../../app/templates/shared/**/*.html"; @source "../../../app/templates/shared/**/*.html";
/* Custom theme configuration */ /* Custom theme configuration */
@@ -198,7 +198,7 @@
} }
} }
/* Shop-specific component styles */ /* Storefront-specific component styles */
@layer components { @layer components {
/* Form input styles */ /* Form input styles */
.form-input, .form-input,
@@ -242,13 +242,13 @@
@apply focus:ring-purple-400 focus:ring-offset-gray-800; @apply focus:ring-purple-400 focus:ring-offset-gray-800;
} }
/* Shop-specific button using CSS variable for store theming */ /* Storefront-specific button using CSS variable for store theming */
.btn-shop-primary { .btn-storefront-primary {
background-color: var(--color-primary, #7e3af2); background-color: var(--color-primary, #7e3af2);
@apply text-white px-4 py-2 rounded-lg font-medium transition-all; @apply text-white px-4 py-2 rounded-lg font-medium transition-all;
} }
.btn-shop-primary:hover { .btn-storefront-primary:hover {
filter: brightness(0.9); filter: brightness(0.9);
} }

File diff suppressed because one or more lines are too long