refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
207
app/modules/billing/templates/billing/admin/billing-history.html
Normal file
207
app/modules/billing/templates/billing/admin/billing-history.html
Normal file
@@ -0,0 +1,207 @@
|
||||
{# app/templates/admin/billing-history.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_refresh %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
{% block title %}Billing History{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminBillingHistory(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header_refresh('Billing History') }}
|
||||
|
||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Total Invoices -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Invoices</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="pagination.total || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paid -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Paid</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="statusCounts.paid || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Open</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="statusCounts.open || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Failed</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="statusCounts.uncollectible || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- Vendor Filter -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="loadInvoices()"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="vendor.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="loadInvoices()"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="uncollectible">Uncollectible</option>
|
||||
<option value="void">Void</option>
|
||||
</select>
|
||||
|
||||
<!-- Reset -->
|
||||
<button
|
||||
@click="resetFilters()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Table -->
|
||||
{% call table_wrapper() %}
|
||||
<table class="w-full whitespace-nowrap">
|
||||
{% call table_header_custom() %}
|
||||
{{ th_sortable('invoice_date', 'Date', 'sortBy', 'sortOrder') }}
|
||||
<th class="px-4 py-3">Invoice #</th>
|
||||
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
|
||||
<th class="px-4 py-3">Description</th>
|
||||
<th class="px-4 py-3 text-right">Amount</th>
|
||||
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
{% endcall %}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
||||
Loading invoices...
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loading && invoices.length === 0">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No invoices found.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="invoice in invoices" :key="invoice.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
|
||||
<td class="px-4 py-3 text-sm font-mono" x-text="invoice.invoice_number || '-'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="invoice.vendor_name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="invoice.vendor_code"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm max-w-xs truncate" x-text="invoice.description || 'Subscription'"></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<span class="font-mono font-semibold" x-text="formatCurrency(invoice.total_cents)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': invoice.status === 'paid',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': invoice.status === 'open',
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': invoice.status === 'draft',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': invoice.status === 'uncollectible',
|
||||
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': invoice.status === 'void'
|
||||
}"
|
||||
x-text="invoice.status.toUpperCase()"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<!-- View Invoice -->
|
||||
<a
|
||||
x-show="invoice.hosted_invoice_url"
|
||||
:href="invoice.hosted_invoice_url"
|
||||
target="_blank"
|
||||
class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
title="View Invoice"
|
||||
>
|
||||
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
<!-- Download PDF -->
|
||||
<a
|
||||
x-show="invoice.invoice_pdf_url"
|
||||
:href="invoice.invoice_pdf_url"
|
||||
target="_blank"
|
||||
class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400"
|
||||
title="Download PDF"
|
||||
>
|
||||
<span x-html="$icon('download', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
<!-- View Vendor -->
|
||||
<a
|
||||
:href="'/admin/vendors/' + invoice.vendor_code"
|
||||
class="p-2 text-gray-500 hover:text-green-600 dark:hover:text-green-400"
|
||||
title="View Vendor"
|
||||
>
|
||||
<span x-html="$icon('user', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('billing_static', path='admin/js/billing-history.js') }}"></script>
|
||||
{% endblock %}
|
||||
355
app/modules/billing/templates/billing/admin/features.html
Normal file
355
app/modules/billing/templates/billing/admin/features.html
Normal file
@@ -0,0 +1,355 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Feature Management{% endblock %}
|
||||
|
||||
{% block alpine_data %}featuresPage(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="py-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Feature Management
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure which features are available to each subscription tier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
|
||||
</div>
|
||||
|
||||
<!-- Tier Tabs -->
|
||||
<div x-show="!loading" x-cloak>
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<template x-for="tier in tiers" :key="tier.code">
|
||||
<button
|
||||
@click="selectedTier = tier.code"
|
||||
:class="{
|
||||
'border-purple-500 text-purple-600 dark:text-purple-400': selectedTier === tier.code,
|
||||
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedTier !== tier.code
|
||||
}"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
|
||||
<span x-text="tier.name"></span>
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full"
|
||||
:class="selectedTier === tier.code ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="tier.feature_count"></span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Available Features (for selected tier) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Included Features
|
||||
</h3>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="`${getSelectedTierFeatures().length} features`"></span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<template x-for="featureCode in getSelectedTierFeatures()" :key="featureCode">
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('check-circle-filled', 'w-5 h-5 text-green-500 mr-3')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
x-text="getFeatureName(featureCode)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400"
|
||||
x-text="getFeatureCategory(featureCode)"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="removeFeatureFromTier(featureCode)"
|
||||
class="text-red-500 hover:text-red-700 p-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="getSelectedTierFeatures().length === 0"
|
||||
class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No features assigned to this tier
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Features (to add) -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Available to Add
|
||||
</h3>
|
||||
<select
|
||||
x-model="categoryFilter"
|
||||
class="text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-md">
|
||||
<option value="">All Categories</option>
|
||||
<template x-for="cat in categories" :key="cat">
|
||||
<option :value="cat" x-text="cat.charAt(0).toUpperCase() + cat.slice(1)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<template x-for="feature in getAvailableFeatures()" :key="feature.code">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('plus', 'w-5 h-5 text-gray-400 mr-3')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
x-text="feature.name"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400"
|
||||
x-text="feature.category"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="addFeatureToTier(feature.code)"
|
||||
class="text-green-500 hover:text-green-700 p-1">
|
||||
<span x-html="$icon('plus', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="getAvailableFeatures().length === 0"
|
||||
class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
All features are assigned to this tier
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
@click="saveTierFeatures"
|
||||
:disabled="saving || !hasChanges"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': saving || !hasChanges,
|
||||
'hover:bg-purple-700': !saving && hasChanges
|
||||
}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
|
||||
<span x-show="saving" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
|
||||
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- All Features Table -->
|
||||
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
All Features
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Complete list of all platform features with their minimum tier requirement.
|
||||
</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Feature
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Minimum Tier
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="feature in allFeatures" :key="feature.code">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.code"></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
x-text="feature.category"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': feature.minimum_tier_code === 'essential',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': feature.minimum_tier_code === 'professional',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': feature.minimum_tier_code === 'business',
|
||||
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': feature.minimum_tier_code === 'enterprise',
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': !feature.minimum_tier_code
|
||||
}"
|
||||
x-text="feature.minimum_tier_name || 'N/A'"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span x-show="feature.is_active"
|
||||
class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||
Active
|
||||
</span>
|
||||
<span x-show="!feature.is_active"
|
||||
class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">
|
||||
Inactive
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function featuresPage() {
|
||||
return {
|
||||
...data(),
|
||||
|
||||
// State
|
||||
loading: true,
|
||||
saving: false,
|
||||
tiers: [],
|
||||
allFeatures: [],
|
||||
categories: [],
|
||||
selectedTier: 'essential',
|
||||
categoryFilter: '',
|
||||
originalTierFeatures: {}, // To track changes
|
||||
currentTierFeatures: {}, // Current state
|
||||
|
||||
// Computed
|
||||
get hasChanges() {
|
||||
const original = this.originalTierFeatures[this.selectedTier] || [];
|
||||
const current = this.currentTierFeatures[this.selectedTier] || [];
|
||||
return JSON.stringify(original.sort()) !== JSON.stringify(current.sort());
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
// Call parent init
|
||||
const path = window.location.pathname;
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
this.currentPage = segments[segments.length - 1] || 'features';
|
||||
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
// Methods
|
||||
async loadData() {
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
// Load tiers and features in parallel
|
||||
const [tiersResponse, featuresResponse, categoriesResponse] = await Promise.all([
|
||||
apiClient.get('/admin/features/tiers'),
|
||||
apiClient.get('/admin/features'),
|
||||
apiClient.get('/admin/features/categories'),
|
||||
]);
|
||||
|
||||
this.tiers = tiersResponse.tiers;
|
||||
this.allFeatures = featuresResponse.features;
|
||||
this.categories = categoriesResponse.categories;
|
||||
|
||||
// Initialize tier features tracking
|
||||
for (const tier of this.tiers) {
|
||||
this.originalTierFeatures[tier.code] = [...tier.features];
|
||||
this.currentTierFeatures[tier.code] = [...tier.features];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load features:', error);
|
||||
this.showNotification('Failed to load features', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getSelectedTierFeatures() {
|
||||
return this.currentTierFeatures[this.selectedTier] || [];
|
||||
},
|
||||
|
||||
getAvailableFeatures() {
|
||||
const tierFeatures = this.getSelectedTierFeatures();
|
||||
return this.allFeatures.filter(f => {
|
||||
const notIncluded = !tierFeatures.includes(f.code);
|
||||
const matchesCategory = !this.categoryFilter || f.category === this.categoryFilter;
|
||||
return notIncluded && matchesCategory && f.is_active;
|
||||
});
|
||||
},
|
||||
|
||||
getFeatureName(code) {
|
||||
const feature = this.allFeatures.find(f => f.code === code);
|
||||
return feature?.name || code;
|
||||
},
|
||||
|
||||
getFeatureCategory(code) {
|
||||
const feature = this.allFeatures.find(f => f.code === code);
|
||||
return feature?.category || 'unknown';
|
||||
},
|
||||
|
||||
addFeatureToTier(featureCode) {
|
||||
if (!this.currentTierFeatures[this.selectedTier].includes(featureCode)) {
|
||||
this.currentTierFeatures[this.selectedTier].push(featureCode);
|
||||
}
|
||||
},
|
||||
|
||||
removeFeatureFromTier(featureCode) {
|
||||
const index = this.currentTierFeatures[this.selectedTier].indexOf(featureCode);
|
||||
if (index > -1) {
|
||||
this.currentTierFeatures[this.selectedTier].splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
async saveTierFeatures() {
|
||||
if (!this.hasChanges) return;
|
||||
|
||||
try {
|
||||
this.saving = true;
|
||||
|
||||
await apiClient.put(`/admin/features/tiers/${this.selectedTier}/features`, {
|
||||
feature_codes: this.currentTierFeatures[this.selectedTier]
|
||||
});
|
||||
|
||||
// Update original to match current
|
||||
this.originalTierFeatures[this.selectedTier] = [...this.currentTierFeatures[this.selectedTier]];
|
||||
|
||||
// Update tier in tiers array
|
||||
const tier = this.tiers.find(t => t.code === this.selectedTier);
|
||||
if (tier) {
|
||||
tier.features = [...this.currentTierFeatures[this.selectedTier]];
|
||||
tier.feature_count = tier.features.length;
|
||||
}
|
||||
|
||||
this.showNotification('Features saved successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to save features:', error);
|
||||
this.showNotification('Failed to save features', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// Simple alert for now - could be improved with toast notifications
|
||||
if (type === 'error') {
|
||||
alert('Error: ' + message);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,479 @@
|
||||
{# app/templates/admin/subscription-tiers.html #}
|
||||
{# noqa: FE-008 - Using raw number inputs for cents/limits in admin tier config modal #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_refresh %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
|
||||
{% from 'shared/macros/modals.html' import modal_confirm %}
|
||||
|
||||
{% block title %}Subscription Tiers{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminSubscriptionTiers(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header_refresh('Subscription Tiers') }}
|
||||
|
||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Total Tiers -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('tag', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Tiers</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tiers.length">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tiers -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Active Tiers</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tiers.filter(t => t.is_active).length">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Tiers -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Public Tiers</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tiers.filter(t => t.is_public).length">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MRR -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Est. MRR</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats ? formatCurrency(stats.mrr_cents) : '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Actions -->
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" x-model="includeInactive" @change="loadTiers()" class="mr-2 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
|
||||
Show inactive tiers
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
Create Tier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tiers Table -->
|
||||
{% call table_wrapper() %}
|
||||
<table class="w-full whitespace-nowrap">
|
||||
{% call table_header_custom() %}
|
||||
<th class="px-4 py-3">#</th>
|
||||
{{ th_sortable('code', 'Code', 'sortBy', 'sortOrder') }}
|
||||
{{ th_sortable('name', 'Name', 'sortBy', 'sortOrder') }}
|
||||
<th class="px-4 py-3 text-right">Monthly</th>
|
||||
<th class="px-4 py-3 text-right">Annual</th>
|
||||
<th class="px-4 py-3 text-center">Orders/Mo</th>
|
||||
<th class="px-4 py-3 text-center">Products</th>
|
||||
<th class="px-4 py-3 text-center">Team</th>
|
||||
<th class="px-4 py-3 text-center">Features</th>
|
||||
<th class="px-4 py-3 text-center">Status</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
{% endcall %}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
||||
Loading tiers...
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loading && tiers.length === 0">
|
||||
<tr>
|
||||
<td colspan="11" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No subscription tiers found.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="(tier, index) in tiers" :key="tier.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400" :class="{ 'opacity-50': !tier.is_active }">
|
||||
<td class="px-4 py-3 text-sm" x-text="tier.display_order"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': tier.code === 'essential',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': tier.code === 'professional',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': tier.code === 'business',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': tier.code === 'enterprise',
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200': !['essential','professional','business','enterprise'].includes(tier.code)
|
||||
}"
|
||||
x-text="tier.code.toUpperCase()"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100" x-text="tier.name"></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-mono" x-text="formatCurrency(tier.price_monthly_cents)"></td>
|
||||
<td class="px-4 py-3 text-sm text-right font-mono" x-text="tier.price_annual_cents ? formatCurrency(tier.price_annual_cents) : '-'"></td>
|
||||
<td class="px-4 py-3 text-sm text-center" x-text="tier.orders_per_month || 'Unlimited'"></td>
|
||||
<td class="px-4 py-3 text-sm text-center" x-text="tier.products_limit || 'Unlimited'"></td>
|
||||
<td class="px-4 py-3 text-sm text-center" x-text="tier.team_members || 'Unlimited'"></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.features || []).length"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span x-show="tier.is_active && tier.is_public" class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:bg-green-900 dark:text-green-200">Active</span>
|
||||
<span x-show="tier.is_active && !tier.is_public" class="px-2 py-1 text-xs font-medium text-blue-700 bg-blue-100 rounded-full dark:bg-blue-900 dark:text-blue-200">Private</span>
|
||||
<span x-show="!tier.is_active" class="px-2 py-1 text-xs font-medium text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-300">Inactive</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="openFeaturePanel(tier)" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="Edit Features">
|
||||
<span x-html="$icon('puzzle-piece', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button @click="openEditModal(tier)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="!tier.is_active"
|
||||
@click="toggleTierStatus(tier, true)"
|
||||
class="p-2 text-gray-500 hover:text-green-600 dark:hover:text-green-400"
|
||||
title="Activate"
|
||||
>
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="tier.is_active"
|
||||
@click="toggleTierStatus(tier, false)"
|
||||
class="p-2 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
title="Deactivate"
|
||||
>
|
||||
<span x-html="$icon('x-circle', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
|
||||
<div class="relative w-full max-w-2xl p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4" x-text="editingTier ? 'Edit Tier' : 'Create Tier'"></h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Code -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.code"
|
||||
:disabled="editingTier"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white disabled:opacity-50"
|
||||
placeholder="e.g., premium"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.name"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., Premium Plan"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Price -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Monthly Price (cents)</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.price_monthly_cents"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., 4900 for 49.00"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Annual Price -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Annual Price (cents)</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.price_annual_cents"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., 49000 for 490.00"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Orders per Month -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Orders/Month (empty = unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.orders_per_month"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., 100"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Products Limit -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Products Limit (empty = unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.products_limit"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., 200"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Team Members -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Team Members (empty = unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.team_members"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., 3"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Display Order -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.display_order"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="e.g., 1"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Stripe Product ID -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stripe Product ID</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.stripe_product_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="prod_..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Stripe Monthly Price ID -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stripe Monthly Price ID</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="formData.stripe_price_monthly_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="price_..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||
<textarea
|
||||
x-model="formData.description"
|
||||
rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Brief description of this tier..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="md:col-span-2 flex gap-6">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="formData.is_active" class="mr-2 rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="formData.is_public" class="mr-2 rounded border-gray-300 dark:border-gray-600">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to vendors)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="closeModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveTier()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!saving" x-text="editingTier ? 'Update' : 'Create'"></span>
|
||||
<span x-show="saving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Assignment Slide-over Panel -->
|
||||
<div
|
||||
x-show="showFeaturePanel"
|
||||
x-cloak
|
||||
@keydown.escape.window="closeFeaturePanel()"
|
||||
class="fixed inset-0 z-50 overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
x-show="showFeaturePanel"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-gray-500/50 dark:bg-gray-900/80 backdrop-blur-sm"
|
||||
@click="closeFeaturePanel()"
|
||||
></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div
|
||||
x-show="showFeaturePanel"
|
||||
x-transition:enter="transform transition ease-in-out duration-300"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition ease-in-out duration-300"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
class="w-screen max-w-lg"
|
||||
>
|
||||
<div class="flex h-full flex-col bg-white dark:bg-gray-800 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Edit Features</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedTierForFeatures?.name"></p>
|
||||
</div>
|
||||
<button
|
||||
@click="closeFeaturePanel()"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<!-- Loading State -->
|
||||
<div x-show="loadingFeatures" class="flex items-center justify-center py-8">
|
||||
<span x-html="$icon('refresh', 'w-6 h-6 animate-spin text-purple-600')"></span>
|
||||
<span class="ml-2 text-gray-500 dark:text-gray-400">Loading features...</span>
|
||||
</div>
|
||||
|
||||
<!-- Feature Categories -->
|
||||
<div x-show="!loadingFeatures" class="space-y-6">
|
||||
<template x-for="category in categories" :key="category">
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<!-- Category Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-700/50">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white" x-text="formatCategoryName(category)"></h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="selectAllInCategory(category)"
|
||||
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
|
||||
x-show="!allSelectedInCategory(category)"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
@click="deselectAllInCategory(category)"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
x-show="allSelectedInCategory(category)"
|
||||
>
|
||||
Deselect all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="feature in featuresGrouped[category]" :key="feature.code">
|
||||
<label class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isFeatureSelected(feature.code)"
|
||||
@change="toggleFeature(feature.code)"
|
||||
class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
>
|
||||
<div class="ml-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="feature.name"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description"></div>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="categories.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No features available.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="selectedFeatures.length"></span> features selected
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="closeFeaturePanel()"
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveFeatures()"
|
||||
:disabled="savingFeatures"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="savingFeatures" x-html="$icon('refresh', 'w-4 h-4 mr-2 animate-spin')"></span>
|
||||
<span x-text="savingFeatures ? 'Saving...' : 'Save Features'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('billing_static', path='admin/js/subscription-tiers.js') }}"></script>
|
||||
{% endblock %}
|
||||
329
app/modules/billing/templates/billing/admin/subscriptions.html
Normal file
329
app/modules/billing/templates/billing/admin/subscriptions.html
Normal file
@@ -0,0 +1,329 @@
|
||||
{# app/templates/admin/subscriptions.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_refresh %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header_custom, th_sortable %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
{% block title %}Vendor Subscriptions{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminSubscriptions(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header_refresh('Vendor Subscriptions') }}
|
||||
|
||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-6">
|
||||
<!-- Total -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.total_subscriptions || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.active_count || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trial -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Trial</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.trial_count || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past Due -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Past Due</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.past_due_count || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Cancelled</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats?.cancelled_count || 0">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MRR -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-emerald-500 bg-emerald-100 rounded-full dark:text-emerald-100 dark:bg-emerald-500">
|
||||
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">MRR</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats ? formatCurrency(stats.mrr_cents) : '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input.debounce.300ms="loadSubscriptions()"
|
||||
placeholder="Search vendor name..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="loadSubscriptions()"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="trial">Trial</option>
|
||||
<option value="past_due">Past Due</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
|
||||
<!-- Tier Filter -->
|
||||
<select
|
||||
x-model="filters.tier"
|
||||
@change="loadSubscriptions()"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="essential">Essential</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
|
||||
<!-- Reset -->
|
||||
<button
|
||||
@click="resetFilters()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
{% call table_wrapper() %}
|
||||
<table class="w-full whitespace-nowrap">
|
||||
{% call table_header_custom() %}
|
||||
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
|
||||
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
|
||||
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
|
||||
<th class="px-4 py-3 text-center">Orders</th>
|
||||
<th class="px-4 py-3 text-center">Products</th>
|
||||
<th class="px-4 py-3 text-center">Team</th>
|
||||
<th class="px-4 py-3">Period End</th>
|
||||
<th class="px-4 py-3 text-right">Actions</th>
|
||||
{% endcall %}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('refresh', 'inline w-6 h-6 animate-spin mr-2')"></span>
|
||||
Loading subscriptions...
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loading && subscriptions.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No subscriptions found.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="sub in subscriptions" :key="sub.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.vendor_name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="sub.vendor_code"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200': sub.tier === 'essential',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': sub.tier === 'professional',
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': sub.tier === 'business',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': sub.tier === 'enterprise'
|
||||
}"
|
||||
x-text="sub.tier.charAt(0).toUpperCase() + sub.tier.slice(1)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': sub.status === 'active',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200': sub.status === 'trial',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': sub.status === 'past_due',
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': sub.status === 'cancelled',
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': sub.status === 'expired'
|
||||
}"
|
||||
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span x-text="sub.orders_this_period"></span>
|
||||
<span class="text-gray-400">/</span>
|
||||
<span x-text="sub.orders_limit || '∞'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span x-text="sub.products_limit || '∞'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span x-text="sub.team_members_limit || '∞'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(sub.period_end)"></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="openEditModal(sub)" class="p-2 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400" title="Edit">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<a :href="'/admin/vendors/' + sub.vendor_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Vendor">
|
||||
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{# noqa: FE-004 - Inline modal required for complex subscription edit form #}
|
||||
{# noqa: FE-008 - Using raw number inputs for custom limit overrides #}
|
||||
<div x-show="showModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50">
|
||||
<div class="relative w-full max-w-lg p-6 mx-4 bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="closeModal()">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Subscription</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Vendor: ' + (editingSub?.vendor_name || '')"></p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Tier -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tier</label>
|
||||
<select
|
||||
x-model="formData.tier"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="essential">Essential</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
||||
<select
|
||||
x-model="formData.status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="trial">Trial</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="past_due">Past Due</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Custom Limits Section -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Custom Limit Overrides</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Orders/Month</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.custom_orders_limit"
|
||||
placeholder="Tier default"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Products</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.custom_products_limit"
|
||||
placeholder="Tier default"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-1">Team Members</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="formData.custom_team_limit"
|
||||
placeholder="Tier default"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Actions -->
|
||||
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="closeModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveSubscription()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!saving">Save Changes</span>
|
||||
<span x-show="saving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('billing_static', path='admin/js/subscriptions.js') }}"></script>
|
||||
{% endblock %}
|
||||
119
app/modules/billing/templates/billing/public/pricing.html
Normal file
119
app/modules/billing/templates/billing/public/pricing.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{# app/templates/platform/pricing.html #}
|
||||
{# Standalone Pricing Page #}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ _("platform.pricing.title") }} - Wizamart{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="{ annual: false }" class="py-16 lg:py-24">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{# Header #}
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("platform.pricing.title") }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{{ _("platform.pricing.trial_note", trial_days=trial_days) }}
|
||||
</p>
|
||||
|
||||
{# Billing Toggle #}
|
||||
<div class="flex items-center justify-center mt-8 space-x-4">
|
||||
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': !annual }">{{ _("platform.pricing.monthly") }}</span>
|
||||
<button @click="annual = !annual"
|
||||
class="relative w-14 h-7 rounded-full transition-colors"
|
||||
:class="annual ? 'bg-indigo-600' : 'bg-gray-300 dark:bg-gray-600'">
|
||||
<span class="absolute top-1 left-1 w-5 h-5 bg-white rounded-full shadow transition-transform"
|
||||
:class="annual ? 'translate-x-7' : ''"></span>
|
||||
</button>
|
||||
<span class="text-gray-700 dark:text-gray-300" :class="{ 'font-semibold': annual }">
|
||||
{{ _("platform.pricing.annual") }}
|
||||
<span class="text-green-600 text-sm font-medium ml-1">{{ _("platform.pricing.save_months") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Pricing Cards #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{% for tier in tiers %}
|
||||
<div class="relative bg-white dark:bg-gray-800 rounded-2xl p-6 border-2 transition-all hover:shadow-xl
|
||||
{% if tier.is_popular %}border-indigo-500 shadow-lg{% else %}border-gray-200 dark:border-gray-700{% endif %}">
|
||||
|
||||
{% if tier.is_popular %}
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span class="bg-indigo-600 text-white text-xs font-bold px-3 py-1 rounded-full">{{ _("platform.pricing.recommended") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">{{ tier.name }}</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<template x-if="!annual">
|
||||
<div>
|
||||
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ tier.price_monthly }}</span>
|
||||
<span class="text-gray-500">{{ _("platform.pricing.per_month") }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="annual">
|
||||
<div>
|
||||
{% if tier.price_annual %}
|
||||
<span class="text-4xl font-extrabold text-gray-900 dark:text-white">{{ (tier.price_annual / 12)|round(0)|int }}</span>
|
||||
<span class="text-gray-500">{{ _("platform.pricing.per_month") }}</span>
|
||||
<div class="text-sm text-gray-500">{{ tier.price_annual }}{{ _("platform.pricing.per_year") }}</div>
|
||||
{% else %}
|
||||
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("platform.pricing.custom") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 mb-8 text-sm">
|
||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{% if tier.orders_per_month %}{{ _("platform.pricing.orders_per_month", count=tier.orders_per_month) }}{% else %}{{ _("platform.pricing.unlimited_orders") }}{% endif %}
|
||||
</li>
|
||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{% if tier.products_limit %}{{ _("platform.pricing.products_limit", count=tier.products_limit) }}{% else %}{{ _("platform.pricing.unlimited_products") }}{% endif %}
|
||||
</li>
|
||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{% if tier.team_members %}{{ _("platform.pricing.team_members", count=tier.team_members) }}{% else %}{{ _("platform.pricing.unlimited_team") }}{% endif %}
|
||||
</li>
|
||||
<li class="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ _("platform.pricing.letzshop_sync") }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% if tier.is_enterprise %}
|
||||
<a href="/contact" class="block w-full py-3 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white font-semibold rounded-xl text-center hover:bg-gray-300 transition-colors">
|
||||
{{ _("platform.pricing.contact_sales") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a :href="'/signup?tier={{ tier.code }}&annual=' + annual"
|
||||
class="block w-full py-3 font-semibold rounded-xl text-center transition-colors
|
||||
{% if tier.is_popular %}bg-indigo-600 hover:bg-indigo-700 text-white{% else %}bg-indigo-100 text-indigo-700 hover:bg-indigo-200{% endif %}">
|
||||
{{ _("platform.pricing.start_trial") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Back to Home #}
|
||||
<div class="text-center mt-12">
|
||||
<a href="/" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
← {{ _("platform.pricing.back_home") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{# app/templates/platform/signup-success.html #}
|
||||
{# Signup Success Page #}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ _("platform.success.title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen py-16 bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div class="max-w-lg mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
|
||||
{# Success Icon #}
|
||||
<div class="w-24 h-24 mx-auto mb-8 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{# Welcome Message #}
|
||||
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ _("platform.success.title") }}
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 mb-8">
|
||||
{{ _("platform.success.subtitle", trial_days=trial_days) }}
|
||||
</p>
|
||||
|
||||
{# Next Steps #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 border border-gray-200 dark:border-gray-700 text-left mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("platform.success.what_next") }}</h2>
|
||||
<ul class="space-y-4">
|
||||
<li class="flex items-start">
|
||||
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">1</span>
|
||||
</div>
|
||||
<span class="ml-3 text-gray-700 dark:text-gray-300">
|
||||
<strong>{{ _("platform.success.step_connect") }}</strong> {{ _("platform.success.step_connect_desc") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">2</span>
|
||||
</div>
|
||||
<span class="ml-3 text-gray-700 dark:text-gray-300">
|
||||
<strong>{{ _("platform.success.step_invoicing") }}</strong> {{ _("platform.success.step_invoicing_desc") }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<div class="w-6 h-6 bg-indigo-100 dark:bg-indigo-900/30 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span class="text-indigo-600 dark:text-indigo-400 text-sm font-bold">3</span>
|
||||
</div>
|
||||
<span class="ml-3 text-gray-700 dark:text-gray-300">
|
||||
<strong>{{ _("platform.success.step_products") }}</strong> {{ _("platform.success.step_products_desc") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# CTA Button #}
|
||||
{% if vendor_code %}
|
||||
<a href="/vendor/{{ vendor_code }}/dashboard"
|
||||
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all hover:scale-105">
|
||||
{{ _("platform.success.go_to_dashboard") }}
|
||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/admin/login"
|
||||
class="inline-flex items-center px-8 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl shadow-lg transition-all">
|
||||
{{ _("platform.success.login_dashboard") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Support Link #}
|
||||
<p class="mt-8 text-gray-500 dark:text-gray-400">
|
||||
{{ _("platform.success.need_help") }}
|
||||
<a href="/contact" class="text-indigo-600 dark:text-indigo-400 hover:underline">{{ _("platform.success.contact_support") }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
534
app/modules/billing/templates/billing/public/signup.html
Normal file
534
app/modules/billing/templates/billing/public/signup.html
Normal file
@@ -0,0 +1,534 @@
|
||||
{# app/templates/platform/signup.html #}
|
||||
{# Multi-step Signup Wizard #}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}Start Your Free Trial - Wizamart{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{# Stripe.js for payment #}
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="signupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
{# Progress Steps #}
|
||||
<div class="mb-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
|
||||
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
|
||||
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
|
||||
<template x-if="currentStep > index + 1">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="currentStep <= index + 1">
|
||||
<span x-text="index + 1"></span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="ml-2 text-sm font-medium hidden sm:inline"
|
||||
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
|
||||
x-text="stepName"></span>
|
||||
<template x-if="index < 3">
|
||||
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
|
||||
<div class="h-full bg-indigo-600 rounded transition-all"
|
||||
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Form Card #}
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
|
||||
{# ===============================================================
|
||||
STEP 1: SELECT PLAN
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 1" class="p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
|
||||
|
||||
{# Billing Toggle #}
|
||||
<div class="flex items-center justify-center mb-8 space-x-4">
|
||||
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
|
||||
<button @click="isAnnual = !isAnnual"
|
||||
class="relative w-12 h-6 rounded-full transition-colors"
|
||||
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
|
||||
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
|
||||
:class="isAnnual ? 'translate-x-6' : ''"></span>
|
||||
</button>
|
||||
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
|
||||
Annual <span class="text-green-600 text-xs">Save 17%</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Tier Options #}
|
||||
<div class="space-y-4">
|
||||
{% for tier in tiers %}
|
||||
{% if not tier.is_enterprise %}
|
||||
<label class="block">
|
||||
<input type="radio" name="tier" value="{{ tier.code }}"
|
||||
x-model="selectedTier" class="hidden peer"/>
|
||||
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
|
||||
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
|
||||
border-gray-200 dark:border-gray-700 hover:border-gray-300">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/mo{% else %}Unlimited{% endif %}
|
||||
•
|
||||
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<template x-if="!isAnnual">
|
||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
|
||||
</template>
|
||||
<template x-if="isAnnual">
|
||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Free Trial Note #}
|
||||
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||
<p class="text-sm text-green-800 dark:text-green-300">
|
||||
<strong>{{ trial_days }}-day free trial.</strong>
|
||||
We'll collect your payment info, but you won't be charged until the trial ends.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button @click="startSignup()"
|
||||
:disabled="!selectedTier || loading"
|
||||
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 2: CLAIM LETZSHOP SHOP (Optional)
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 2" class="p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
x-model="letzshopUrl"
|
||||
placeholder="letzshop.lu/vendors/your-shop"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
|
||||
/>
|
||||
|
||||
<template x-if="letzshopVendor">
|
||||
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||||
<p class="text-green-800 dark:text-green-300">
|
||||
Found: <strong x-text="letzshopVendor.name"></strong>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="letzshopError">
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
||||
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex gap-4">
|
||||
<button @click="currentStep = 1"
|
||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||
Back
|
||||
</button>
|
||||
<button @click="claimVendor()"
|
||||
:disabled="loading"
|
||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 3: CREATE ACCOUNT
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 3" class="p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
<span class="text-red-500">*</span> Required fields
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="account.firstName" required
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="account.lastName" required
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Company Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" x-model="account.companyName" required
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="email" x-model="account.email" required
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" x-model="account.password" required minlength="8"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
|
||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<template x-if="accountError">
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
|
||||
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex gap-4">
|
||||
<button @click="currentStep = 2"
|
||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||
Back
|
||||
</button>
|
||||
<button @click="createAccount()"
|
||||
:disabled="loading || !isAccountValid()"
|
||||
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||
Continue to Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===============================================================
|
||||
STEP 4: PAYMENT
|
||||
=============================================================== #}
|
||||
<div x-show="currentStep === 4" class="p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
|
||||
|
||||
{# Stripe Card Element #}
|
||||
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
|
||||
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
|
||||
|
||||
<div class="mt-8 flex gap-4">
|
||||
<button @click="currentStep = 3"
|
||||
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
|
||||
Back
|
||||
</button>
|
||||
<button @click="submitPayment()"
|
||||
:disabled="loading || paymentProcessing"
|
||||
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
|
||||
<template x-if="paymentProcessing">
|
||||
<span>Processing...</span>
|
||||
</template>
|
||||
<template x-if="!paymentProcessing">
|
||||
<span>Start Free Trial</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function signupWizard() {
|
||||
return {
|
||||
currentStep: 1,
|
||||
loading: false,
|
||||
sessionId: null,
|
||||
|
||||
// Step 1: Plan
|
||||
selectedTier: '{{ selected_tier or "professional" }}',
|
||||
isAnnual: {{ 'true' if is_annual else 'false' }},
|
||||
|
||||
// Step 2: Letzshop
|
||||
letzshopUrl: '',
|
||||
letzshopVendor: null,
|
||||
letzshopError: null,
|
||||
|
||||
// Step 3: Account
|
||||
account: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
companyName: '',
|
||||
email: '',
|
||||
password: ''
|
||||
},
|
||||
accountError: null,
|
||||
|
||||
// Step 4: Payment
|
||||
stripe: null,
|
||||
cardElement: null,
|
||||
paymentProcessing: false,
|
||||
clientSecret: null,
|
||||
|
||||
init() {
|
||||
// Check URL params for pre-selection
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('tier')) {
|
||||
this.selectedTier = params.get('tier');
|
||||
}
|
||||
if (params.get('annual') === 'true') {
|
||||
this.isAnnual = true;
|
||||
}
|
||||
if (params.get('letzshop')) {
|
||||
this.letzshopUrl = params.get('letzshop');
|
||||
}
|
||||
|
||||
// Initialize Stripe when we get to step 4
|
||||
this.$watch('currentStep', (step) => {
|
||||
if (step === 4) {
|
||||
this.initStripe();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async startSignup() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/signup/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tier_code: this.selectedTier,
|
||||
is_annual: this.isAnnual
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.sessionId = data.session_id;
|
||||
this.currentStep = 2;
|
||||
} else {
|
||||
alert(data.detail || 'Failed to start signup');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to start signup. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async claimVendor() {
|
||||
if (this.letzshopUrl.trim()) {
|
||||
this.loading = true;
|
||||
this.letzshopError = null;
|
||||
|
||||
try {
|
||||
// First lookup the vendor
|
||||
const lookupResponse = await fetch('/api/v1/public/letzshop-vendors/lookup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: this.letzshopUrl })
|
||||
});
|
||||
|
||||
const lookupData = await lookupResponse.json();
|
||||
|
||||
if (lookupData.found && !lookupData.vendor.is_claimed) {
|
||||
this.letzshopVendor = lookupData.vendor;
|
||||
|
||||
// Claim the vendor
|
||||
const claimResponse = await fetch('/api/v1/public/signup/claim-vendor', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: this.sessionId,
|
||||
letzshop_slug: lookupData.vendor.slug
|
||||
})
|
||||
});
|
||||
|
||||
if (claimResponse.ok) {
|
||||
const claimData = await claimResponse.json();
|
||||
this.account.companyName = claimData.vendor_name || '';
|
||||
this.currentStep = 3;
|
||||
} else {
|
||||
const error = await claimResponse.json();
|
||||
this.letzshopError = error.detail || 'Failed to claim vendor';
|
||||
}
|
||||
} else if (lookupData.vendor?.is_claimed) {
|
||||
this.letzshopError = 'This shop has already been claimed.';
|
||||
} else {
|
||||
this.letzshopError = lookupData.error || 'Shop not found.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
this.letzshopError = 'Failed to lookup vendor.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
} else {
|
||||
// Skip this step
|
||||
this.currentStep = 3;
|
||||
}
|
||||
},
|
||||
|
||||
isAccountValid() {
|
||||
return this.account.firstName.trim() &&
|
||||
this.account.lastName.trim() &&
|
||||
this.account.companyName.trim() &&
|
||||
this.account.email.trim() &&
|
||||
this.account.password.length >= 8;
|
||||
},
|
||||
|
||||
async createAccount() {
|
||||
this.loading = true;
|
||||
this.accountError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/signup/create-account', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: this.sessionId,
|
||||
email: this.account.email,
|
||||
password: this.account.password,
|
||||
first_name: this.account.firstName,
|
||||
last_name: this.account.lastName,
|
||||
company_name: this.account.companyName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.currentStep = 4;
|
||||
} else {
|
||||
this.accountError = data.detail || 'Failed to create account';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
this.accountError = 'Failed to create account. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async initStripe() {
|
||||
{% if stripe_publishable_key %}
|
||||
this.stripe = Stripe('{{ stripe_publishable_key }}');
|
||||
const elements = this.stripe.elements();
|
||||
|
||||
this.cardElement = elements.create('card', {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#374151',
|
||||
'::placeholder': { color: '#9CA3AF' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.cardElement.mount('#card-element');
|
||||
this.cardElement.on('change', (event) => {
|
||||
const displayError = document.getElementById('card-errors');
|
||||
displayError.textContent = event.error ? event.error.message : '';
|
||||
});
|
||||
|
||||
// Get SetupIntent
|
||||
try {
|
||||
const response = await fetch('/api/v1/public/signup/setup-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: this.sessionId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
this.clientSecret = data.client_secret;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting SetupIntent:', error);
|
||||
}
|
||||
{% else %}
|
||||
console.warn('Stripe not configured');
|
||||
{% endif %}
|
||||
},
|
||||
|
||||
async submitPayment() {
|
||||
if (!this.stripe || !this.clientSecret) {
|
||||
alert('Payment not configured. Please contact support.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.paymentProcessing = true;
|
||||
|
||||
try {
|
||||
const { setupIntent, error } = await this.stripe.confirmCardSetup(
|
||||
this.clientSecret,
|
||||
{ payment_method: { card: this.cardElement } }
|
||||
);
|
||||
|
||||
if (error) {
|
||||
document.getElementById('card-errors').textContent = error.message;
|
||||
this.paymentProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete signup
|
||||
const response = await fetch('/api/v1/public/signup/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: this.sessionId,
|
||||
setup_intent_id: setupIntent.id
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
// Store access token for automatic login
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('vendor_token', data.access_token);
|
||||
localStorage.setItem('vendorCode', data.vendor_code);
|
||||
console.log('Vendor token stored for automatic login');
|
||||
}
|
||||
window.location.href = '/signup/success?vendor_code=' + data.vendor_code;
|
||||
} else {
|
||||
alert(data.detail || 'Failed to complete signup');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error);
|
||||
alert('Payment failed. Please try again.');
|
||||
} finally {
|
||||
this.paymentProcessing = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
428
app/modules/billing/templates/billing/vendor/billing.html
vendored
Normal file
428
app/modules/billing/templates/billing/vendor/billing.html
vendored
Normal file
@@ -0,0 +1,428 @@
|
||||
{# app/templates/vendor/billing.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Billing & Subscription{% endblock %}
|
||||
|
||||
{% block alpine_data %}vendorBilling(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Billing & Subscription') }}
|
||||
|
||||
<!-- Success/Cancel Messages -->
|
||||
<template x-if="showSuccessMessage">
|
||||
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
|
||||
<span>Your subscription has been updated successfully!</span>
|
||||
</div>
|
||||
<button @click="showSuccessMessage = false" class="text-green-700 hover:text-green-900">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="showCancelMessage">
|
||||
<div class="mb-6 p-4 bg-yellow-100 border border-yellow-400 text-yellow-700 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 mr-2')"></span>
|
||||
<span>Checkout was cancelled. No changes were made to your subscription.</span>
|
||||
</div>
|
||||
<button @click="showCancelMessage = false" class="text-yellow-700 hover:text-yellow-900">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="showAddonSuccessMessage">
|
||||
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
|
||||
<span>Add-on purchased successfully!</span>
|
||||
</div>
|
||||
<button @click="showAddonSuccessMessage = false" class="text-green-700 hover:text-green-900">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loading State -->
|
||||
<template x-if="loading">
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading">
|
||||
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<!-- Current Plan Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Current Plan</h3>
|
||||
<span :class="{
|
||||
'bg-green-100 text-green-800': subscription?.status === 'active',
|
||||
'bg-yellow-100 text-yellow-800': subscription?.status === 'trial',
|
||||
'bg-red-100 text-red-800': subscription?.status === 'past_due' || subscription?.status === 'cancelled',
|
||||
'bg-gray-100 text-gray-800': !['active', 'trial', 'past_due', 'cancelled'].includes(subscription?.status)
|
||||
}" class="px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span x-text="subscription?.status?.replace('_', ' ')?.toUpperCase() || 'INACTIVE'"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400" x-text="subscription?.tier_name || 'No Plan'"></div>
|
||||
<template x-if="subscription?.is_trial">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
Trial ends <span x-text="formatDate(subscription?.trial_ends_at)"></span>
|
||||
</p>
|
||||
</template>
|
||||
<template x-if="subscription?.cancelled_at">
|
||||
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
Cancels on <span x-text="formatDate(subscription?.period_end)"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<template x-if="subscription?.period_end && !subscription?.cancelled_at">
|
||||
<p>
|
||||
Next billing: <span class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(subscription?.period_end)"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-2">
|
||||
<template x-if="subscription?.stripe_customer_id">
|
||||
<button @click="openPortal()"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
|
||||
Manage Payment Method
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="subscription?.cancelled_at">
|
||||
<button @click="reactivate()"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||
Reactivate Subscription
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="!subscription?.cancelled_at && subscription?.status === 'active'">
|
||||
<button @click="showCancelModal = true"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300">
|
||||
Cancel Subscription
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Summary Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Usage This Period</h3>
|
||||
|
||||
<!-- Orders Usage -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Orders</span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="subscription?.orders_this_period || 0"></span>
|
||||
<span x-text="subscription?.orders_limit ? ` / ${subscription.orders_limit}` : ' (Unlimited)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="bg-purple-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${subscription?.orders_limit ? Math.min(100, (subscription.orders_this_period / subscription.orders_limit) * 100) : 0}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Usage -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Products</span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="subscription?.products_count || 0"></span>
|
||||
<span x-text="subscription?.products_limit ? ` / ${subscription.products_limit}` : ' (Unlimited)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${subscription?.products_limit ? Math.min(100, (subscription.products_count / subscription.products_limit) * 100) : 0}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Usage -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Team Members</span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="subscription?.team_count || 0"></span>
|
||||
<span x-text="subscription?.team_limit ? ` / ${subscription.team_limit}` : ' (Unlimited)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="bg-green-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${subscription?.team_limit ? Math.min(100, (subscription.team_count / subscription.team_limit) * 100) : 0}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="subscription?.last_payment_error">
|
||||
<div class="mt-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">
|
||||
<span x-html="$icon('exclamation-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Payment issue: <span x-text="subscription.last_payment_error"></span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Quick Actions</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button @click="showTiersModal = true"
|
||||
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('arrow-trending-up', 'w-5 h-5 text-purple-600 mr-3')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Change Plan</span>
|
||||
</div>
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
|
||||
<button @click="showAddonsModal = true"
|
||||
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('puzzle-piece', 'w-5 h-5 text-blue-600 mr-3')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Add-ons</span>
|
||||
</div>
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
|
||||
<a :href="`/vendor/${vendorCode}/invoices`"
|
||||
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5 text-green-600 mr-3')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">View Invoices</span>
|
||||
</div>
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice History Section -->
|
||||
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Recent Invoices</h3>
|
||||
|
||||
<template x-if="invoices.length === 0">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-center py-8">No invoices yet</p>
|
||||
</template>
|
||||
|
||||
<template x-if="invoices.length > 0">
|
||||
<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 border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Invoice</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Amount</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="invoice in invoices.slice(0, 5)" :key="invoice.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm font-medium" x-text="invoice.invoice_number || `#${invoice.id}`"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
|
||||
<td class="px-4 py-3 text-sm font-medium" x-text="formatCurrency(invoice.total_cents, invoice.currency)"></td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span :class="{
|
||||
'bg-green-100 text-green-800': invoice.status === 'paid',
|
||||
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
|
||||
'bg-red-100 text-red-800': invoice.status === 'uncollectible'
|
||||
}" class="px-2 py-1 text-xs font-semibold rounded-full" x-text="invoice.status.toUpperCase()"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="invoice.pdf_url">
|
||||
<a :href="invoice.pdf_url" target="_blank" class="text-purple-600 hover:text-purple-800">
|
||||
<span x-html="$icon('arrow-down-tray', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tiers Modal -->
|
||||
{% call modal_simple('tiersModal', 'Choose Your Plan', show_var='showTiersModal', size='xl') %}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<template x-for="tier in tiers" :key="tier.code">
|
||||
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
|
||||
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<template x-if="tier.is_current">
|
||||
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
|
||||
</template>
|
||||
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
|
||||
<span class="text-sm font-normal text-gray-500">/mo</span>
|
||||
</p>
|
||||
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<button @click="selectTier(tier)"
|
||||
:disabled="tier.is_current"
|
||||
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
|
||||
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
|
||||
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Add-ons Modal -->
|
||||
<div x-show="showAddonsModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="showAddonsModal = false">
|
||||
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add-ons</h3>
|
||||
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto">
|
||||
<!-- My Active Add-ons -->
|
||||
<template x-if="myAddons.length > 0">
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Your Active Add-ons</h4>
|
||||
<div class="space-y-3">
|
||||
<template x-for="addon in myAddons.filter(a => a.status === 'active')" :key="addon.id">
|
||||
<div class="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.addon_name"></h4>
|
||||
<template x-if="addon.domain_name">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.domain_name"></p>
|
||||
</template>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<span x-text="addon.period_end ? `Renews ${formatDate(addon.period_end)}` : 'Active'"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="cancelAddon(addon)"
|
||||
class="px-3 py-1 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900/50 dark:text-red-400">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Available Add-ons -->
|
||||
<h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Available Add-ons</h4>
|
||||
<template x-if="addons.length === 0">
|
||||
<p class="text-gray-500 text-center py-8">No add-ons available</p>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<template x-for="addon in addons" :key="addon.id">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.name"></h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.description"></p>
|
||||
<p class="text-sm font-medium text-purple-600 mt-1">
|
||||
<span x-text="formatCurrency(addon.price_cents, 'EUR')"></span>
|
||||
<span x-text="`/${addon.billing_period}`"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="purchaseAddon(addon)"
|
||||
:disabled="isAddonPurchased(addon.code) || purchasingAddon === addon.code"
|
||||
:class="isAddonPurchased(addon.code) ? 'bg-gray-200 text-gray-500 cursor-not-allowed' : 'bg-purple-100 text-purple-600 hover:bg-purple-200'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors">
|
||||
<template x-if="purchasingAddon === addon.code">
|
||||
<span class="flex items-center">
|
||||
<span class="-ml-1 mr-2 h-4 w-4" x-html="$icon('spinner', 'h-4 w-4 animate-spin')"></span>
|
||||
Processing...
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="purchasingAddon !== addon.code">
|
||||
<span x-text="isAddonPurchased(addon.code) ? 'Active' : 'Add'"></span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Subscription Modal -->
|
||||
<div x-show="showCancelModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="showCancelModal = false">
|
||||
<div class="w-full max-w-md mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Cancel Subscription</h3>
|
||||
<button @click="showCancelModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Reason for cancelling (optional)
|
||||
</label>
|
||||
<textarea x-model="cancelReason"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Tell us why you're leaving..."></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="showCancelModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
|
||||
Keep Subscription
|
||||
</button>
|
||||
<button @click="cancelSubscription()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
Cancel Subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/modules/billing/vendor/js/billing.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user