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

View File

@@ -50,6 +50,7 @@ class FrontendDetector:
"/api/v1/shop", # Legacy support
"/stores/", # Path-based store access
)
MERCHANT_PATH_PREFIXES = ("/merchants", "/api/v1/merchants")
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
@classmethod
@@ -94,6 +95,10 @@ class FrontendDetector:
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path")
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
if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES):
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:
logger.debug("Redirecting to /admin/login")
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:
# Extract store code from the request path
# Path format: /store/{store_code}/...

View File

@@ -3,14 +3,14 @@
Merchant Billing Page Routes (HTML rendering).
Page routes for the merchant billing portal:
- Dashboard (overview of stores, subscriptions)
- Subscriptions list
- Subscription detail per platform
- Billing history / invoices
- Login page
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
registration under /merchants/billing/*).
@@ -20,10 +20,7 @@ from fastapi import APIRouter, Depends, Path, 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,
)
from app.api.deps import get_current_merchant_from_cookie_or_header
from app.core.database import get_db
from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
@@ -53,15 +50,6 @@ def _get_merchant_context(
Uses the module-driven context builder with FrontendType.MERCHANT,
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(
FrontendType.MERCHANT,
@@ -73,26 +61,14 @@ def _get_merchant_context(
# ============================================================================
# DASHBOARD
# BILLING ROOT
# ============================================================================
@router.get("/", 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 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,
)
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
async def merchant_billing_root():
"""Redirect /merchants/billing/ to subscriptions page."""
return RedirectResponse(url="/merchants/billing/subscriptions", status_code=302)
# ============================================================================
@@ -164,35 +140,3 @@ async def merchant_billing_history_page(
"billing/merchant/billing-history.html",
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()">
<!-- Page Header -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900">Billing History</h2>
<p class="mt-1 text-gray-500">View your invoices and payment history.</p>
<div class="mb-8 mt-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Billing History</h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">View your invoices and payment history.</p>
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
<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 dark:text-red-400" x-text="error"></p>
</div>
<!-- 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">
<table class="w-full whitespace-nowrap">
<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">Invoice #</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>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Loading -->
<template x-if="loading">
<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">
<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>
@@ -47,7 +47,7 @@
<!-- Empty -->
<template x-if="!loading && invoices.length === 0">
<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.
</td>
</tr>
@@ -55,7 +55,7 @@
<!-- Rows -->
<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 font-mono" x-text="invoice.invoice_number || '-'"></td>
<td class="px-6 py-4 text-right">
@@ -64,11 +64,11 @@
<td class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-800': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
'bg-gray-100 text-gray-600': invoice.status === 'draft',
'bg-red-100 text-red-800': invoice.status === 'uncollectible',
'bg-gray-100 text-gray-500': invoice.status === 'void'
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': invoice.status === 'paid',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': invoice.status === 'open',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': invoice.status === 'draft',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': invoice.status === 'uncollectible',
'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500': invoice.status === 'void'
}"
x-text="invoice.status.toUpperCase()"></span>
</td>
@@ -77,21 +77,17 @@
<a x-show="invoice.hosted_invoice_url"
:href="invoice.hosted_invoice_url"
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">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
View
</a>
<a x-show="invoice.invoice_pdf_url"
:href="invoice.invoice_pdf_url"
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">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span x-html="$icon('download', 'w-3.5 h-3.5 mr-1')"></span>
PDF
</a>
</div>
@@ -118,28 +114,9 @@ function merchantBillingHistory() {
this.loadInvoices();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadInvoices() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
try {
const resp = await fetch('/api/v1/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();
const data = await apiClient.get('/merchants/billing/invoices');
this.invoices = data.invoices || data.items || [];
} catch (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()">
<!-- Back link and header -->
<div class="mb-8">
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<div class="mb-8 mt-6">
<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">
<span x-html="$icon('chevron-left', 'w-4 h-4 mr-1')"></span>
Back to Subscriptions
</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>
<!-- 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">
<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>
@@ -27,78 +25,78 @@
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
<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 dark:text-red-400" x-text="error"></p>
</div>
<!-- Success Message -->
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<p class="text-sm text-green-800" x-text="successMessage"></p>
<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 dark:text-green-400" x-text="successMessage"></p>
</div>
<!-- Subscription Info -->
<div x-show="!loading && subscription" class="space-y-6">
<!-- Main Details Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || 'Subscription'"></h3>
<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 flex items-center justify-between">
<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"
:class="{
'bg-green-100 text-green-800': subscription?.status === 'active',
'bg-blue-100 text-blue-800': subscription?.status === 'trial',
'bg-yellow-100 text-yellow-800': subscription?.status === 'past_due',
'bg-red-100 text-red-800': subscription?.status === 'cancelled',
'bg-gray-100 text-gray-600': subscription?.status === 'expired'
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': subscription?.status === 'active',
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': subscription?.status === 'trial',
'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 dark:bg-red-900/30 dark:text-red-400': subscription?.status === 'cancelled',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': subscription?.status === 'expired'
}"
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
</div>
<div class="p-6">
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-sm font-medium text-gray-500">Tier</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="capitalize(subscription?.tier)"></dd>
<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 dark:text-gray-100" x-text="capitalize(subscription?.tier)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Billing Period</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
<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 dark:text-gray-100" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Period End</dt>
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd>
<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 dark:text-gray-100" x-text="formatDate(subscription?.period_end)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Platform</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.platform_name || '-'"></dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="subscription?.platform_name || '-'"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="formatDate(subscription?.created_at)"></dd>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.created_at)"></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Auto Renew</dt>
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
<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 dark:text-gray-300" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
</div>
</dl>
</div>
</div>
<!-- Feature Limits Card -->
<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">Plan Features</h3>
<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">Plan Features</h3>
</div>
<div class="p-6">
<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">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
<p class="text-xl font-bold text-gray-900" x-text="fl.limit_value || 'Unlimited'"></p>
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<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 dark:text-gray-100" x-text="fl.limit_value || 'Unlimited'"></p>
</div>
</template>
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
<div class="p-4 bg-gray-50 rounded-lg sm:col-span-3">
<p class="text-sm text-gray-500 text-center">No feature limits configured for this tier</p>
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg sm:col-span-3">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">No feature limits configured for this tier</p>
</div>
</template>
</div>
@@ -107,24 +105,24 @@
<!-- Change Plan -->
<div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')"
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">Change Plan</h3>
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">Change Plan</h3>
</div>
<div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
<template x-for="t in availableTiers" :key="t.code">
<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'">
<h4 class="font-semibold text-gray-900" x-text="t.name"></h4>
<p class="text-sm text-gray-500 mt-1" x-text="formatCurrency(t.price_monthly_cents) + '/mo'"></p>
: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 dark:text-gray-100" x-text="t.name"></h4>
<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">
<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 x-if="!t.is_current">
<button @click="changeTier(t.code)"
: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="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')">
</button>
</template>
@@ -152,11 +150,6 @@ function merchantSubscriptionDetail() {
this.loadSubscription();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
getPlatformId() {
// Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id}
const parts = window.location.pathname.split('/');
@@ -164,23 +157,9 @@ function merchantSubscriptionDetail() {
},
async loadSubscription() {
const token = this.getToken();
if (!token) {
window.location.href = '/merchants/login';
return;
}
const platformId = this.getPlatformId();
try {
const resp = await fetch(`/api/v1/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();
const data = await apiClient.get(`/merchants/billing/subscriptions/${platformId}`);
this.subscription = data.subscription || data;
} catch (err) {
console.error('Error:', err);
@@ -194,15 +173,8 @@ function merchantSubscriptionDetail() {
},
async loadAvailableTiers(platformId) {
const token = this.getToken();
if (!token) return;
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/tiers`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!resp.ok) return;
const data = await resp.json();
const data = await apiClient.get(`/merchants/billing/subscriptions/${platformId}/tiers`);
this.availableTiers = data.tiers || [];
} catch (err) {
console.error('Failed to load tiers:', err);
@@ -216,23 +188,13 @@ function merchantSubscriptionDetail() {
this.error = null;
this.successMessage = null;
const token = this.getToken();
const platformId = this.getPlatformId();
try {
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/change-tier`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ tier_code: tierCode, is_annual: this.subscription?.is_annual || false })
const result = await apiClient.post(`/merchants/billing/subscriptions/${platformId}/change-tier`, {
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.';
// Reload data

View File

@@ -7,22 +7,22 @@
<div x-data="merchantSubscriptions()">
<!-- Page Header -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900">My Subscriptions</h2>
<p class="mt-1 text-gray-500">Manage your platform subscriptions and plans.</p>
<div class="mb-8 mt-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Subscriptions</h2>
<p class="mt-1 text-gray-500 dark:text-gray-400">Manage your platform subscriptions and plans.</p>
</div>
<!-- Error -->
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-800" x-text="error"></p>
<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 dark:text-red-400" x-text="error"></p>
</div>
<!-- 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">
<table class="w-full whitespace-nowrap">
<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">Tier</th>
<th class="px-6 py-3">Status</th>
@@ -30,11 +30,11 @@
<th class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Loading -->
<template x-if="loading">
<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">
<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>
@@ -47,7 +47,7 @@
<!-- Empty -->
<template x-if="!loading && subscriptions.length === 0">
<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.
</td>
</tr>
@@ -55,35 +55,35 @@
<!-- Rows -->
<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">
<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 class="px-6 py-4">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-indigo-100 text-indigo-800': sub.tier === 'essential',
'bg-blue-100 text-blue-800': sub.tier === 'professional',
'bg-green-100 text-green-800': sub.tier === 'business',
'bg-yellow-100 text-yellow-800': sub.tier === 'enterprise'
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400': sub.tier === 'essential',
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.tier === 'professional',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.tier === 'business',
'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>
</td>
<td class="px-6 py-4">
<span class="px-2.5 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',
'bg-gray-100 text-gray-600': sub.status === 'expired'
'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',
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': sub.status === 'expired'
}"
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
</td>
<td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td>
<td class="px-6 py-4 text-right">
<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
</a>
</td>
@@ -109,28 +109,9 @@ function merchantSubscriptions() {
this.loadSubscriptions();
},
getToken() {
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
async loadSubscriptions() {
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;
}
if (!resp.ok) throw new Error('Failed to load subscriptions');
const data = await resp.json();
const data = await apiClient.get('/merchants/billing/subscriptions');
this.subscriptions = data.subscriptions || data.items || [];
} catch (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.
Provides merchant-facing API endpoints for the merchant portal:
- /account/stores - List merchant's stores
- /account/profile - Get/update merchant profile
Aggregates all merchant tenancy routes:
- /auth/* - Merchant authentication (login, logout, /me)
- /account/* - Merchant account management (stores, profile)
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 models.schema.auth import UserContext
from .merchant_auth import merchant_auth_router
logger = logging.getLogger(__name__)
router = APIRouter()
ROUTE_CONFIG = {
"prefix": "/account",
}
# Include auth routes (/auth/login, /auth/logout, /auth/me)
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(
request: Request,
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
@@ -114,7 +118,7 @@ async def merchant_stores(
return {"stores": stores}
@router.get("/profile")
@_account_router.get("/profile")
async def merchant_profile(
request: Request,
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(
request: Request,
profile_data: MerchantProfileUpdate,
@@ -177,3 +181,7 @@ async def update_merchant_profile(
"tax_number": merchant.tax_number,
"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 #}
{# Base template for the merchant billing portal #}
<!DOCTYPE html>
<html lang="en">
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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="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" />
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
<!-- Alpine Cloak -->
<style>
[x-cloak] { display: none !important; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-50 font-sans" x-data="merchantApp()" x-cloak>
<div class="flex h-screen overflow-hidden">
<body x-cloak>
<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 -->
<aside class="hidden md:flex md:flex-shrink-0">
<div class="flex flex-col w-64 bg-indigo-900">
<!-- 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>
<div class="flex flex-col flex-1 w-full">
<!-- Header (server-side included) -->
{% include 'merchant/partials/header.html' %}
<!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
<a href="/merchants/billing/"
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">
<!-- Main Content -->
<main class="h-full overflow-y-auto">
<div class="container px-6 mx-auto grid">
{% block content %}{% endblock %}
</div>
</main>
</div>
</div>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
<!-- Core Scripts - ORDER MATTERS! -->
<!-- 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>
function merchantApp() {
return {
sidebarOpen: false,
currentPath: window.location.pathname,
merchantName: '',
(function() {
var script = document.createElement('script');
script.defer = true;
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js';
init() {
// Load merchant name from token/cookie
const token = this.getToken();
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
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';
}
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>
<!-- 7. LAST: Page-specific scripts -->
{% block extra_scripts %}{% endblock %}
</body>
</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>