feat(merchant): extract merchant portal as first-class frontend with auth, Tailwind fixes, and Gitea CI
Some checks failed
Some checks failed
- 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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> ·
|
||||
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 %}
|
||||
@@ -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">
|
||||
© 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
93
app/modules/core/routes/pages/merchant.py
Normal file
93
app/modules/core/routes/pages/merchant.py
Normal 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,
|
||||
)
|
||||
134
app/modules/core/static/merchant/js/init-alpine.js
Normal file
134
app/modules/core/static/merchant/js/init-alpine.js
Normal 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';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
143
app/modules/core/static/merchant/js/login.js
Normal file
143
app/modules/core/static/merchant/js/login.js
Normal 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');
|
||||
150
app/modules/core/templates/core/merchant/dashboard.html
Normal file
150
app/modules/core/templates/core/merchant/dashboard.html
Normal 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> ·
|
||||
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 %}
|
||||
@@ -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"])
|
||||
|
||||
117
app/modules/tenancy/routes/api/merchant_auth.py
Normal file
117
app/modules/tenancy/routes/api/merchant_auth.py
Normal 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")
|
||||
141
app/modules/tenancy/templates/tenancy/merchant/login.html
Normal file
141
app/modules/tenancy/templates/tenancy/merchant/login.html
Normal 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="/">
|
||||
← 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>
|
||||
Reference in New Issue
Block a user