feat(billing): migrate frontend templates to feature provider system
Replace hardcoded subscription fields (orders_limit, products_limit,
team_members_limit) across 5 frontend pages with dynamic feature
provider APIs. Add admin convenience endpoint for store subscription
lookup. Remove legacy stubs (StoreSubscription, FeatureCode, Feature,
TIER_LIMITS, FeatureInfo, FeatureUpgradeInfo) and schema aliases.
Pages updated:
- Admin subscriptions: dynamic feature overrides editor
- Admin tiers: correct feature catalog/limits API URLs
- Store billing: usage metrics from /store/billing/usage
- Merchant subscription detail: tier.feature_limits rendering
- Admin store detail: new GET /admin/subscriptions/store/{id} endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -91,9 +91,6 @@
|
||||
{{ 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>
|
||||
@@ -101,7 +98,7 @@
|
||||
<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">
|
||||
<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 tiers...
|
||||
</td>
|
||||
@@ -109,7 +106,7 @@
|
||||
</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">
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No subscription tiers found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -131,11 +128,8 @@
|
||||
<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>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="(tier.feature_codes || 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>
|
||||
@@ -225,39 +219,6 @@
|
||||
>
|
||||
</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>
|
||||
@@ -310,7 +271,7 @@
|
||||
</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>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Public (visible to stores)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,18 +381,32 @@
|
||||
<!-- 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>
|
||||
<div class="flex items-start px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<label class="flex items-start cursor-pointer flex-1">
|
||||
<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_key || feature.name"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400" x-text="feature.description_key || feature.description"></div>
|
||||
</div>
|
||||
</label>
|
||||
<template x-if="feature.feature_type === 'quantitative' && isFeatureSelected(feature.code)">
|
||||
<div class="ml-2 flex-shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
:value="getFeatureLimitValue(feature.code)"
|
||||
@input="setFeatureLimitValue(feature.code, $event.target.value)"
|
||||
placeholder="Unlimited"
|
||||
class="w-24 px-2 py-1 text-sm border border-gray-300 rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
min="0"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
{% 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 title %}Store Subscriptions{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminSubscriptions(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header_refresh('Vendor Subscriptions') }}
|
||||
{{ page_header_refresh('Store Subscriptions') }}
|
||||
|
||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input.debounce.300ms="loadSubscriptions()"
|
||||
placeholder="Search vendor name..."
|
||||
placeholder="Search store 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>
|
||||
@@ -140,19 +140,17 @@
|
||||
{% call table_wrapper() %}
|
||||
<table class="w-full whitespace-nowrap">
|
||||
{% call table_header_custom() %}
|
||||
{{ th_sortable('vendor_name', 'Vendor', 'sortBy', 'sortOrder') }}
|
||||
{{ th_sortable('store_name', 'Store', '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 text-center">Features</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">
|
||||
<td colspan="6" 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>
|
||||
@@ -160,7 +158,7 @@
|
||||
</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">
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No subscriptions found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,8 +168,8 @@
|
||||
<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>
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.store_name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="sub.store_code"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -197,15 +195,8 @@
|
||||
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>
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded"
|
||||
x-text="(sub.feature_codes || []).length"></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">
|
||||
@@ -213,7 +204,7 @@
|
||||
<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">
|
||||
<a :href="'/admin/stores/' + sub.store_code" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Store">
|
||||
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -233,7 +224,7 @@
|
||||
<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>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-text="'Store: ' + (editingSub?.store_name || '')"></p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Tier -->
|
||||
@@ -265,39 +256,35 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Custom Limits Section -->
|
||||
<!-- Feature Overrides 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>
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Feature 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"
|
||||
>
|
||||
<template x-if="loadingOverrides">
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 animate-spin text-purple-600')"></span>
|
||||
<span class="ml-2 text-sm text-gray-500">Loading features...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="!loadingOverrides" class="space-y-3 max-h-48 overflow-y-auto">
|
||||
<template x-for="feature in quantitativeFeatures" :key="feature.code">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex-1 text-xs text-gray-600 dark:text-gray-400"
|
||||
x-text="feature.name_key.replace(/_/g, ' ')"></label>
|
||||
<input
|
||||
type="number"
|
||||
:placeholder="'Tier default'"
|
||||
:value="getOverrideValue(feature.code)"
|
||||
@input="setOverrideValue(feature.code, $event.target.value)"
|
||||
class="w-28 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="quantitativeFeatures.length === 0 && !loadingOverrides">
|
||||
<p class="text-xs text-gray-400 text-center py-2">No quantitative features available</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
{# app/modules/billing/templates/billing/merchant/subscription-detail.html #}
|
||||
{% extends "merchant/base.html" %}
|
||||
|
||||
{% block title %}Subscription Details{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="merchantSubscriptionDetail()">
|
||||
|
||||
<!-- Back link and header -->
|
||||
<div class="mb-8">
|
||||
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Subscriptions
|
||||
</a>
|
||||
<h2 class="text-2xl font-bold text-gray-900">Subscription Details</h2>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-800" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Info -->
|
||||
<div x-show="!loading && subscription" class="space-y-6">
|
||||
|
||||
<!-- Main Details Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || subscription?.store_name || 'Subscription'"></h3>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': subscription?.status === 'active',
|
||||
'bg-blue-100 text-blue-800': subscription?.status === 'trial',
|
||||
'bg-yellow-100 text-yellow-800': subscription?.status === 'past_due',
|
||||
'bg-red-100 text-red-800': subscription?.status === 'cancelled',
|
||||
'bg-gray-100 text-gray-600': subscription?.status === 'expired'
|
||||
}"
|
||||
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Tier</dt>
|
||||
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="capitalize(subscription?.tier)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Billing Period</dt>
|
||||
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Period End</dt>
|
||||
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Store Code</dt>
|
||||
<dd class="mt-1 text-sm font-mono text-gray-700" x-text="subscription?.store_code || '-'"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700" x-text="formatDate(subscription?.created_at)"></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Auto Renew</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Limits Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Plan Features</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code">
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
|
||||
<p class="text-xl font-bold text-gray-900" x-text="fl.limit_value || 'Unlimited'"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
|
||||
<div class="p-4 bg-gray-50 rounded-lg sm:col-span-3">
|
||||
<p class="text-sm text-gray-500 text-center">No feature limits configured for this tier</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade Action -->
|
||||
<div class="flex justify-end" x-show="subscription?.status === 'active' || subscription?.status === 'trial'">
|
||||
<button @click="requestUpgrade()"
|
||||
:disabled="upgrading"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||
</svg>
|
||||
<span x-text="upgrading ? 'Processing...' : 'Upgrade Plan'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function merchantSubscriptionDetail() {
|
||||
return {
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
subscription: null,
|
||||
upgrading: false,
|
||||
|
||||
init() {
|
||||
this.loadSubscription();
|
||||
},
|
||||
|
||||
getToken() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
|
||||
getSubscriptionId() {
|
||||
// Extract ID from URL: /merchants/billing/subscriptions/{id}
|
||||
const parts = window.location.pathname.split('/');
|
||||
return parts[parts.length - 1];
|
||||
},
|
||||
|
||||
async loadSubscription() {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
window.location.href = '/merchants/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const subId = this.getSubscriptionId();
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/merchants/login';
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error('Failed to load subscription');
|
||||
this.subscription = await resp.json();
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
this.error = 'Failed to load subscription details.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async requestUpgrade() {
|
||||
this.upgrading = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
const token = this.getToken();
|
||||
const subId = this.getSubscriptionId();
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${subId}/upgrade`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
throw new Error(data.detail || 'Upgrade request failed');
|
||||
}
|
||||
this.successMessage = 'Upgrade request submitted. You will be contacted with available options.';
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.upgrading = false;
|
||||
}
|
||||
},
|
||||
|
||||
capitalize(str) {
|
||||
if (!str) return '-';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
406
app/modules/billing/templates/billing/store/billing.html
Normal file
406
app/modules/billing/templates/billing/store/billing.html
Normal file
@@ -0,0 +1,406 @@
|
||||
{# app/templates/store/billing.html #}
|
||||
{% extends "store/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 %}storeBilling(){% 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>
|
||||
|
||||
<template x-if="usageMetrics.length === 0">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">No usage data available</p>
|
||||
</template>
|
||||
|
||||
<template x-for="metric in usageMetrics" :key="metric.name">
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400" x-text="metric.name"></span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="metric.current"></span>
|
||||
<span x-text="metric.is_unlimited ? ' (Unlimited)' : ` / ${metric.limit}`"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="h-2 rounded-full transition-all duration-300"
|
||||
:class="metric.percentage >= 90 ? 'bg-red-600' : metric.percentage >= 70 ? 'bg-yellow-600' : 'bg-purple-600'"
|
||||
:style="`width: ${Math.min(100, metric.percentage || 0)}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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="`/store/${storeCode}/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">
|
||||
<template x-for="code in (tier.feature_codes || []).slice(0, 5)" :key="code">
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="code.replace(/_/g, ' ')"></span>
|
||||
</li>
|
||||
</template>
|
||||
<template x-if="(tier.feature_codes || []).length > 5">
|
||||
<li class="text-xs text-gray-400">
|
||||
+ <span x-text="(tier.feature_codes || []).length - 5"></span> more features
|
||||
</li>
|
||||
</template>
|
||||
<template x-if="(tier.feature_codes || []).length === 0">
|
||||
<li class="text-xs text-gray-400">No features listed</li>
|
||||
</template>
|
||||
</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/store/js/billing.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user