Store detail page now shows all platform subscriptions instead of always "No Subscription Found". Subscriptions listing page renamed from Store to Merchant throughout (template, JS, menu, i18n) with Platform column added. Tiers API supports platform_id filtering. Merchant detail page no longer hardcodes 'oms' platform — loads all platforms, shows subscription cards per platform with labels, and the Create Subscription modal includes a platform selector with platform-filtered tiers. Create button always accessible in Quick Actions. Edit modal on /admin/subscriptions loads tiers from API filtered by platform instead of hardcoded options, sends tier_code (not tier) to match PATCH schema, and shows platform context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
321 lines
16 KiB
HTML
321 lines
16 KiB
HTML
{# 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 %}Merchant Subscriptions{% endblock %}
|
|
|
|
{% block alpine_data %}adminSubscriptions(){% endblock %}
|
|
|
|
{% block content %}
|
|
{{ page_header_refresh('Merchant 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 merchant 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('merchant_name', 'Merchant', 'sortBy', 'sortOrder') }}
|
|
<th class="px-4 py-3">Platform</th>
|
|
{{ th_sortable('tier', 'Tier', 'sortBy', 'sortOrder') }}
|
|
{{ th_sortable('status', 'Status', 'sortBy', 'sortOrder') }}
|
|
<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="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 subscriptions...
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template x-if="!loading && subscriptions.length === 0">
|
|
<tr>
|
|
<td colspan="7" 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.merchant_name"></p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="sub.platform_name || '-'"></span>
|
|
</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 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">
|
|
<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/merchants/' + sub.merchant_id" class="p-2 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400" title="View Merchant">
|
|
<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-1" x-text="'Merchant: ' + (editingSub?.merchant_name || '')"></p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4" x-show="editingSub?.platform_name" x-text="'Platform: ' + (editingSub?.platform_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_code"
|
|
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="loadingTiers"
|
|
>
|
|
<template x-for="t in editTiers" :key="t.code">
|
|
<option :value="t.code" x-text="t.name"></option>
|
|
</template>
|
|
</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>
|
|
|
|
<!-- 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">Feature Overrides</h4>
|
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Leave empty to use tier defaults</p>
|
|
|
|
<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>
|
|
|
|
<!-- 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 %}
|