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

@@ -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);