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:
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 %}
|
||||
Reference in New Issue
Block a user