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