feat(i18n): localize admin plans page
Add 108 admin_plans.* translation keys to en.json and update admin_plans.html to use _() for all static text and window.__i18nAdminPlans for dynamic Alpine.js / JavaScript strings. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+137
-102
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Plan Designer — DocuElevate Admin{% endblock %}
|
{% block title %}{{ _("admin_plans.page_title") }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
|
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
|
||||||
@@ -10,43 +10,41 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Plan Designer</h1>
|
<h1 class="text-2xl font-bold text-gray-900">{{ _("admin_plans.heading") }}</h1>
|
||||||
<p class="text-sm text-gray-500 mt-1">Manage subscription plans shown on the public pricing page.</p>
|
<p class="text-sm text-gray-500 mt-1">{{ _("admin_plans.subheading") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/admin/stripe-wizard"
|
<a href="/admin/stripe-wizard"
|
||||||
class="inline-flex items-center px-4 py-2 border border-indigo-300 rounded-md text-sm font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
|
class="inline-flex items-center px-4 py-2 border border-indigo-300 rounded-md text-sm font-medium text-indigo-700 bg-indigo-50 hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400"
|
||||||
title="Open the Stripe Setup Wizard to configure API keys and sync plans"
|
title="{{ _('admin_plans.btn_stripe_setup_title') }}"
|
||||||
>
|
>
|
||||||
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> Stripe Setup
|
<i class="fab fa-stripe mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_stripe_setup") }}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@click="seedDefaults()"
|
@click="seedDefaults()"
|
||||||
:disabled="seeding"
|
:disabled="seeding"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 disabled:opacity-50"
|
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 disabled:opacity-50"
|
||||||
title="Restore all four default plans (only if no plans exist yet)"
|
title="{{ _('admin_plans.btn_restore_defaults_title') }}"
|
||||||
>
|
>
|
||||||
<i class="fas fa-undo mr-2 text-gray-400" aria-hidden="true"></i>
|
<i class="fas fa-undo mr-2 text-gray-400" aria-hidden="true"></i>
|
||||||
<span x-show="!seeding">Restore Defaults</span>
|
<span x-show="!seeding">{{ _("admin_plans.btn_restore_defaults") }}</span>
|
||||||
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Restoring…</span>
|
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_restoring") }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="openCreateModal()"
|
@click="openCreateModal()"
|
||||||
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
|
||||||
>
|
>
|
||||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Plan
|
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("admin_plans.btn_add_plan") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Overage callout -->
|
<!-- Overage callout -->
|
||||||
<div class="mb-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-800" role="note">
|
<div class="mb-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-800" role="note">
|
||||||
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> About the Overage Buffer</p>
|
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> {{ _("admin_plans.overage_buffer_title") }}</p>
|
||||||
<p>
|
<p>
|
||||||
The overage buffer is <strong>invisible to users</strong>. We advertise X docs/month but only enforce
|
{{ _("admin_plans.overage_buffer_body_prefix") }} <strong>{{ _("admin_plans.overage_buffer_invisible") }}</strong>{{ _("admin_plans.overage_buffer_body_suffix") }}
|
||||||
at <strong>X × (1 + buffer%)</strong> docs. For example, a 150-doc/month plan with a 20% buffer
|
<strong>{{ _("admin_plans.overage_buffer_formula") }}</strong> {{ _("admin_plans.overage_buffer_tail") }}
|
||||||
enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a
|
|
||||||
graceful soft landing.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,29 +61,29 @@
|
|||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div x-show="loading" class="text-center py-12 text-gray-400">
|
<div x-show="loading" class="text-center py-12 text-gray-400">
|
||||||
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
|
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
|
||||||
<p class="mt-2 text-sm">Loading plans…</p>
|
<p class="mt-2 text-sm">{{ _("admin_plans.loading") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plans table -->
|
<!-- Plans table -->
|
||||||
<div x-show="!loading" class="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
|
<div x-show="!loading" class="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
|
||||||
<table class="min-w-full divide-y divide-gray-200" aria-label="Subscription plans">
|
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('admin_plans.table_aria_label') }}">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Order</th>
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_order") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_plan") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly</th>
|
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_monthly") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly</th>
|
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_yearly") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Limit</th>
|
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_monthly_limit") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Overage %</th>
|
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_overage_pct") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Active</th>
|
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_active") }}</th>
|
||||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("admin_plans.col_actions") }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-100">
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
<template x-if="plans.length === 0">
|
<template x-if="plans.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400">
|
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400">
|
||||||
No plans yet. Click <strong>Restore Defaults</strong> to seed the four built-in plans.
|
{{ _("admin_plans.no_plans_intro") }} <strong>{{ _("admin_plans.btn_restore_defaults") }}</strong> {{ _("admin_plans.no_plans_suffix") }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -97,13 +95,13 @@
|
|||||||
@click="moveUp(idx)"
|
@click="moveUp(idx)"
|
||||||
:disabled="idx === 0"
|
:disabled="idx === 0"
|
||||||
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
|
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
|
||||||
:aria-label="'Move ' + plan.name + ' up'"
|
:aria-label="i18n.ariaMoveUp.replace('{name}', plan.name)"
|
||||||
><i class="fas fa-chevron-up text-xs" aria-hidden="true"></i></button>
|
><i class="fas fa-chevron-up text-xs" aria-hidden="true"></i></button>
|
||||||
<button
|
<button
|
||||||
@click="moveDown(idx)"
|
@click="moveDown(idx)"
|
||||||
:disabled="idx === plans.length - 1"
|
:disabled="idx === plans.length - 1"
|
||||||
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
|
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
|
||||||
:aria-label="'Move ' + plan.name + ' down'"
|
:aria-label="i18n.ariaMoveDown.replace('{name}', plan.name)"
|
||||||
><i class="fas fa-chevron-down text-xs" aria-hidden="true"></i></button>
|
><i class="fas fa-chevron-down text-xs" aria-hidden="true"></i></button>
|
||||||
<span x-text="idx + 1" class="w-5 text-center text-xs text-gray-400"></span>
|
<span x-text="idx + 1" class="w-5 text-center text-xs text-gray-400"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,13 +113,13 @@
|
|||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium" x-text="plan.badge_text"></span>
|
<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium" x-text="plan.badge_text"></span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="plan.is_highlighted">
|
<template x-if="plan.is_highlighted">
|
||||||
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> Featured</span>
|
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> <span x-text="i18n.featuredBadge"></span></span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-400 mt-0.5" x-text="plan.plan_id"></div>
|
<div class="text-xs text-gray-400 mt-0.5" x-text="plan.plan_id"></div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right text-sm text-gray-700">
|
<td class="px-4 py-3 text-right text-sm text-gray-700">
|
||||||
<span x-text="plan.price_monthly === 0 ? 'Free' : '$' + plan.price_monthly.toFixed(2)"></span>
|
<span x-text="plan.price_monthly === 0 ? i18n.freeLabel : '$' + plan.price_monthly.toFixed(2)"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right text-sm text-gray-700">
|
<td class="px-4 py-3 text-right text-sm text-gray-700">
|
||||||
<span x-text="plan.price_yearly === 0 ? '—' : '$' + plan.price_yearly.toFixed(2)"></span>
|
<span x-text="plan.price_yearly === 0 ? '—' : '$' + plan.price_yearly.toFixed(2)"></span>
|
||||||
@@ -136,7 +134,7 @@
|
|||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
:class="plan.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
:class="plan.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
|
||||||
x-text="plan.is_active ? 'Active' : 'Inactive'"
|
x-text="plan.is_active ? i18n.statusActive : i18n.statusInactive"
|
||||||
></span>
|
></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
@@ -144,16 +142,16 @@
|
|||||||
<button
|
<button
|
||||||
@click="openEditModal(plan)"
|
@click="openEditModal(plan)"
|
||||||
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||||
:aria-label="'Edit ' + plan.name"
|
:aria-label="i18n.ariaEditPlan.replace('{name}', plan.name)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-pencil-alt" aria-hidden="true"></i> Edit
|
<i class="fas fa-pencil-alt" aria-hidden="true"></i> <span x-text="i18n.btnEdit"></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="deletePlan(plan.plan_id)"
|
@click="deletePlan(plan.plan_id)"
|
||||||
class="text-sm text-red-500 hover:text-red-700 font-medium"
|
class="text-sm text-red-500 hover:text-red-700 font-medium"
|
||||||
:aria-label="'Delete ' + plan.name"
|
:aria-label="i18n.ariaDeletePlan.replace('{name}', plan.name)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> Delete
|
<i class="fas fa-trash-alt" aria-hidden="true"></i> <span x-text="i18n.btnDelete"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -171,8 +169,8 @@
|
|||||||
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<i class="fas fa-save mr-2" aria-hidden="true"></i>
|
<i class="fas fa-save mr-2" aria-hidden="true"></i>
|
||||||
<span x-show="!saving">Save Order</span>
|
<span x-show="!saving">{{ _("admin_plans.btn_save_order") }}</span>
|
||||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_saving") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,8 +193,8 @@
|
|||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl my-auto" @click.outside="modalOpen = false">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl my-auto" @click.outside="modalOpen = false">
|
||||||
<!-- Modal header -->
|
<!-- Modal header -->
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b">
|
<div class="flex items-center justify-between px-6 py-4 border-b">
|
||||||
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? 'Add Plan' : 'Edit Plan: ' + form.name"></h2>
|
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? i18n.modalCreateTitle : i18n.modalEditTitlePrefix + form.name"></h2>
|
||||||
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="Close modal">
|
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="{{ _('admin_plans.modal_close_aria') }}">
|
||||||
<i class="fas fa-times" aria-hidden="true"></i>
|
<i class="fas fa-times" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,66 +203,66 @@
|
|||||||
|
|
||||||
<!-- Basic Info -->
|
<!-- Basic Info -->
|
||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Basic Info</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_basic_info") }}</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">Name <span class="text-red-500" aria-hidden="true">*</span></label>
|
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_name") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||||
<input id="f-name" type="text" x-model="form.name" required
|
<input id="f-name" type="text" x-model="form.name" required
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">Plan ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_plan_id") }} <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||||
<input id="f-plan-id" type="text" x-model="form.plan_id" :readonly="!isCreate"
|
<input id="f-plan-id" type="text" x-model="form.plan_id" :readonly="!isCreate"
|
||||||
:class="!isCreate ? 'bg-gray-50 text-gray-400 cursor-not-allowed' : ''"
|
:class="!isCreate ? 'bg-gray-50 text-gray-400 cursor-not-allowed' : ''"
|
||||||
required pattern="[a-z0-9_-]+" title="Lowercase letters, numbers, _ and - only"
|
required pattern="[a-z0-9_-]+" title="Lowercase letters, numbers, _ and - only"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
<p class="text-xs text-gray-400 mt-0.5">Lowercase slug, cannot be changed after creation.</p>
|
<p class="text-xs text-gray-400 mt-0.5">{{ _("admin_plans.hint_plan_id") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">Tagline</label>
|
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_tagline") }}</label>
|
||||||
<input id="f-tagline" type="text" x-model="form.tagline" maxlength="255"
|
<input id="f-tagline" type="text" x-model="form.tagline" maxlength="255"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">Sort Order</label>
|
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_sort_order") }}</label>
|
||||||
<input id="f-sort-order" type="number" x-model.number="form.sort_order" min="0"
|
<input id="f-sort-order" type="number" x-model.number="form.sort_order" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 pt-6">
|
<div class="flex items-center gap-3 pt-6">
|
||||||
<input id="f-active" type="checkbox" x-model="form.is_active"
|
<input id="f-active" type="checkbox" x-model="form.is_active"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
<label for="f-active" class="text-sm font-medium text-gray-700">Active</label>
|
<label for="f-active" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_active") }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 pt-6">
|
<div class="flex items-center gap-3 pt-6">
|
||||||
<input id="f-highlighted" type="checkbox" x-model="form.is_highlighted"
|
<input id="f-highlighted" type="checkbox" x-model="form.is_highlighted"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
<label for="f-highlighted" class="text-sm font-medium text-gray-700">Featured / Highlighted</label>
|
<label for="f-highlighted" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_featured") }}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pricing -->
|
<!-- Pricing -->
|
||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Pricing</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_pricing") }}</h3>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">Monthly Price ($)</label>
|
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_price_monthly") }}</label>
|
||||||
<input id="f-price-monthly" type="number" x-model.number="form.price_monthly" min="0" step="0.01"
|
<input id="f-price-monthly" type="number" x-model.number="form.price_monthly" min="0" step="0.01"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">Yearly Price ($)</label>
|
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_price_yearly") }}</label>
|
||||||
<input id="f-price-yearly" type="number" x-model.number="form.price_yearly" min="0" step="0.01"
|
<input id="f-price-yearly" type="number" x-model.number="form.price_yearly" min="0" step="0.01"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
<p class="text-xs mt-0.5"
|
<p class="text-xs mt-0.5"
|
||||||
:class="yearlySavingPct > 0 ? 'text-green-600 font-medium' : 'text-gray-400'"
|
:class="yearlySavingPct > 0 ? 'text-green-600 font-medium' : 'text-gray-400'"
|
||||||
x-text="yearlySavingPct > 0 ? 'Save ' + yearlySavingPct.toFixed(0) + '% vs monthly' : 'Enter yearly price to show savings'">
|
x-text="yearlySavingPct > 0 ? i18n.yearlySave.replace('{pct}', yearlySavingPct.toFixed(0)) : i18n.yearlyEnter">
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">Trial Days</label>
|
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_trial_days") }}</label>
|
||||||
<input id="f-trial-days" type="number" x-model.number="form.trial_days" min="0"
|
<input id="f-trial-days" type="number" x-model.number="form.trial_days" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -274,21 +272,21 @@
|
|||||||
<!-- Stripe Integration -->
|
<!-- Stripe Integration -->
|
||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Stripe Integration</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_stripe") }}</h3>
|
||||||
<a href="/admin/stripe-wizard" target="_blank"
|
<a href="/admin/stripe-wizard" target="_blank"
|
||||||
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded"
|
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded"
|
||||||
aria-label="Open Stripe Setup Wizard in a new tab"
|
aria-label="{{ _('admin_plans.stripe_wizard_aria') }}"
|
||||||
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>Stripe Wizard</a>
|
><i class="fab fa-stripe mr-1" aria-hidden="true"></i>{{ _("admin_plans.stripe_wizard_link") }}</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-500">
|
||||||
Enter the Stripe Price IDs for this plan, or use the
|
{{ _("admin_plans.stripe_desc_before") }}
|
||||||
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">Stripe Setup Wizard</a>
|
<a href="/admin/stripe-wizard" target="_blank" class="text-indigo-600 hover:text-indigo-800 underline">{{ _("admin_plans.stripe_wizard_text") }}</a>
|
||||||
to auto-create them. Free plans do not need Stripe Price IDs.
|
{{ _("admin_plans.stripe_desc_after") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="f-stripe-monthly" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="f-stripe-monthly" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Stripe Price ID (monthly)
|
{{ _("admin_plans.field_stripe_monthly") }}
|
||||||
</label>
|
</label>
|
||||||
<input id="f-stripe-monthly" type="text" x-model="form.stripe_price_id_monthly"
|
<input id="f-stripe-monthly" type="text" x-model="form.stripe_price_id_monthly"
|
||||||
placeholder="price_1OtAbc…"
|
placeholder="price_1OtAbc…"
|
||||||
@@ -296,7 +294,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-stripe-yearly" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="f-stripe-yearly" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Stripe Price ID (yearly)
|
{{ _("admin_plans.field_stripe_yearly") }}
|
||||||
</label>
|
</label>
|
||||||
<input id="f-stripe-yearly" type="text" x-model="form.stripe_price_id_yearly"
|
<input id="f-stripe-yearly" type="text" x-model="form.stripe_price_id_yearly"
|
||||||
placeholder="price_1OtAbc… (optional)"
|
placeholder="price_1OtAbc… (optional)"
|
||||||
@@ -307,36 +305,36 @@
|
|||||||
|
|
||||||
<!-- Volume Limits -->
|
<!-- Volume Limits -->
|
||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_volume") }}</h3>
|
||||||
<p class="text-xs text-gray-500">Enter <strong>0</strong> for unlimited.</p>
|
<p class="text-xs text-gray-500">{{ _("admin_plans.hint_zero_unlimited") }}</p>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">Docs / Month</label>
|
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_docs_month") }}</label>
|
||||||
<input id="f-monthly-limit" type="number" x-model.number="form.monthly_upload_limit" min="0"
|
<input id="f-monthly-limit" type="number" x-model.number="form.monthly_upload_limit" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">Lifetime Docs</label>
|
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_lifetime_docs") }}</label>
|
||||||
<input id="f-lifetime-limit" type="number" x-model.number="form.lifetime_file_limit" min="0"
|
<input id="f-lifetime-limit" type="number" x-model.number="form.lifetime_file_limit" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">OCR Pages / Month</label>
|
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_ocr_pages") }}</label>
|
||||||
<input id="f-ocr-pages" type="number" x-model.number="form.max_ocr_pages_monthly" min="0"
|
<input id="f-ocr-pages" type="number" x-model.number="form.max_ocr_pages_monthly" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">Storage Destinations</label>
|
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_storage_dests") }}</label>
|
||||||
<input id="f-dests" type="number" x-model.number="form.max_storage_destinations" min="0"
|
<input id="f-dests" type="number" x-model.number="form.max_storage_destinations" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">Email Mailboxes</label>
|
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_mailboxes") }}</label>
|
||||||
<input id="f-mailboxes" type="number" x-model.number="form.max_mailboxes" min="0"
|
<input id="f-mailboxes" type="number" x-model.number="form.max_mailboxes" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">Max File Size (MB)</label>
|
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_max_file_size") }}</label>
|
||||||
<input id="f-filesize" type="number" x-model.number="form.max_file_size_mb" min="0"
|
<input id="f-filesize" type="number" x-model.number="form.max_file_size_mb" min="0"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
@@ -345,15 +343,15 @@
|
|||||||
|
|
||||||
<!-- Overage Designer -->
|
<!-- Overage Designer -->
|
||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Overage Designer</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_overage") }}</h3>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-overage-pct" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="f-overage-pct" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Buffer:
|
{{ _("admin_plans.field_buffer") }}
|
||||||
<span class="text-indigo-700 font-semibold" x-text="form.overage_percent + '%'"></span>
|
<span class="text-indigo-700 font-semibold" x-text="form.overage_percent + '%'"></span>
|
||||||
<template x-if="form.monthly_upload_limit > 0">
|
<template x-if="form.monthly_upload_limit > 0">
|
||||||
<span class="text-gray-500 font-normal ml-2">
|
<span class="text-gray-500 font-normal ml-2">
|
||||||
→ announce <strong x-text="form.monthly_upload_limit"></strong> docs,
|
{{ _("admin_plans.overage_announce") }} <strong x-text="form.monthly_upload_limit"></strong> {{ _("admin_plans.overage_docs") }}
|
||||||
enforce at <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> docs
|
{{ _("admin_plans.overage_enforce_at") }} <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> {{ _("admin_plans.overage_docs_end") }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</label>
|
</label>
|
||||||
@@ -368,27 +366,27 @@
|
|||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
<div class="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
<span>0% (exact)</span>
|
<span>{{ _("admin_plans.overage_0pct") }}</span>
|
||||||
<span>50%</span>
|
<span>{{ _("admin_plans.overage_50pct") }}</span>
|
||||||
<span>100%</span>
|
<span>{{ _("admin_plans.overage_100pct") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 opacity-60">
|
<div class="grid grid-cols-2 gap-4 opacity-60">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Allow Overage Billing</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_allow_overage") }}</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input type="checkbox" disabled class="h-4 w-4 rounded border-gray-300 text-indigo-600" />
|
<input type="checkbox" disabled class="h-4 w-4 rounded border-gray-300 text-indigo-600" />
|
||||||
<span class="text-xs text-gray-400">Coming soon</span>
|
<span class="text-xs text-gray-400">{{ _("admin_plans.coming_soon") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / doc ($)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_overage_doc_price") }}</label>
|
||||||
<input type="number" disabled placeholder="Coming soon"
|
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
|
||||||
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
|
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
|
||||||
</div>
|
</div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / OCR page ($)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_overage_ocr_price") }}</label>
|
||||||
<input type="number" disabled placeholder="Coming soon"
|
<input type="number" disabled placeholder="{{ _('admin_plans.coming_soon') }}"
|
||||||
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
|
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,18 +394,18 @@
|
|||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
<div class="px-6 py-5 space-y-3">
|
<div class="px-6 py-5 space-y-3">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Features List</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_features") }}</h3>
|
||||||
<p class="text-xs text-gray-500">These bullet points appear on the pricing page card for this plan.</p>
|
<p class="text-xs text-gray-500">{{ _("admin_plans.hint_features") }}</p>
|
||||||
<template x-for="(feat, i) in form.features" :key="i">
|
<template x-for="(feat, i) in form.features" :key="i">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
x-model="form.features[i]"
|
x-model="form.features[i]"
|
||||||
:aria-label="'Feature ' + (i+1)"
|
:aria-label="i18n.ariaFeatureN.replace('{n}', i + 1)"
|
||||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||||
/>
|
/>
|
||||||
<button type="button" @click="form.features.splice(i, 1)"
|
<button type="button" @click="form.features.splice(i, 1)"
|
||||||
:aria-label="'Remove feature ' + (i+1)"
|
:aria-label="i18n.ariaRemoveFeatureN.replace('{n}', i + 1)"
|
||||||
class="text-red-400 hover:text-red-600 px-2 py-2">
|
class="text-red-400 hover:text-red-600 px-2 py-2">
|
||||||
<i class="fas fa-times" aria-hidden="true"></i>
|
<i class="fas fa-times" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -415,21 +413,21 @@
|
|||||||
</template>
|
</template>
|
||||||
<button type="button" @click="form.features.push('')"
|
<button type="button" @click="form.features.push('')"
|
||||||
class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
||||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Feature
|
<i class="fas fa-plus mr-1" aria-hidden="true"></i> {{ _("admin_plans.btn_add_feature") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display -->
|
<!-- Display -->
|
||||||
<div class="px-6 py-5 space-y-4">
|
<div class="px-6 py-5 space-y-4">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Display</h3>
|
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">{{ _("admin_plans.section_display") }}</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">CTA Button Text</label>
|
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_cta_text") }}</label>
|
||||||
<input id="f-cta" type="text" x-model="form.cta_text" maxlength="100"
|
<input id="f-cta" type="text" x-model="form.cta_text" maxlength="100"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">Badge Text</label>
|
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">{{ _("admin_plans.field_badge_text") }}</label>
|
||||||
<input id="f-badge" type="text" x-model="form.badge_text" maxlength="50"
|
<input id="f-badge" type="text" x-model="form.badge_text" maxlength="50"
|
||||||
placeholder="e.g. Most Popular"
|
placeholder="e.g. Most Popular"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
|
||||||
@@ -438,7 +436,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input id="f-api" type="checkbox" x-model="form.api_access"
|
<input id="f-api" type="checkbox" x-model="form.api_access"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
<label for="f-api" class="text-sm font-medium text-gray-700">API Access</label>
|
<label for="f-api" class="text-sm font-medium text-gray-700">{{ _("admin_plans.field_api_access") }}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -449,15 +447,15 @@
|
|||||||
@click="modalOpen = false"
|
@click="modalOpen = false"
|
||||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
|
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ _("admin_plans.btn_cancel") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span x-show="!saving" x-text="isCreate ? 'Create Plan' : 'Save Changes'"></span>
|
<span x-show="!saving" x-text="isCreate ? i18n.btnCreate : i18n.btnSaveChanges"></span>
|
||||||
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
|
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> <span x-text="i18n.btnSaving"></span></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -467,9 +465,46 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__i18nAdminPlans = {
|
||||||
|
modalCreateTitle: {{ _("admin_plans.modal_create_title") | tojson }},
|
||||||
|
modalEditTitlePrefix: {{ _("admin_plans.modal_edit_title_prefix") | tojson }},
|
||||||
|
statusActive: {{ _("admin_plans.status_active") | tojson }},
|
||||||
|
statusInactive: {{ _("admin_plans.status_inactive") | tojson }},
|
||||||
|
featuredBadge: {{ _("admin_plans.featured_badge") | tojson }},
|
||||||
|
freeLabel: {{ _("admin_plans.free_label") | tojson }},
|
||||||
|
btnEdit: {{ _("admin_plans.btn_edit") | tojson }},
|
||||||
|
btnDelete: {{ _("admin_plans.btn_delete") | tojson }},
|
||||||
|
btnCreate: {{ _("admin_plans.btn_create") | tojson }},
|
||||||
|
btnSaveChanges: {{ _("admin_plans.btn_save_changes") | tojson }},
|
||||||
|
btnSaving: {{ _("admin_plans.btn_saving") | tojson }},
|
||||||
|
ariaMoveUp: {{ _("admin_plans.aria_move_up") | tojson }},
|
||||||
|
ariaMoveDown: {{ _("admin_plans.aria_move_down") | tojson }},
|
||||||
|
ariaEditPlan: {{ _("admin_plans.aria_edit_plan") | tojson }},
|
||||||
|
ariaDeletePlan: {{ _("admin_plans.aria_delete_plan") | tojson }},
|
||||||
|
ariaFeatureN: {{ _("admin_plans.aria_feature_n") | tojson }},
|
||||||
|
ariaRemoveFeatureN: {{ _("admin_plans.aria_remove_feature_n") | tojson }},
|
||||||
|
yearlySave: {{ _("admin_plans.js_yearly_save") | tojson }},
|
||||||
|
yearlyEnter: {{ _("admin_plans.js_yearly_enter") | tojson }},
|
||||||
|
jsFailedLoad: {{ _("admin_plans.js_failed_load") | tojson }},
|
||||||
|
jsPlanCreated: {{ _("admin_plans.js_plan_created") | tojson }},
|
||||||
|
jsPlanUpdated: {{ _("admin_plans.js_plan_updated") | tojson }},
|
||||||
|
jsDeleteConfirm: {{ _("admin_plans.js_delete_confirm") | tojson }},
|
||||||
|
jsDeleteFailed: {{ _("admin_plans.js_delete_failed") | tojson }},
|
||||||
|
jsPlanDeleted: {{ _("admin_plans.js_plan_deleted") | tojson }},
|
||||||
|
jsSeedConfirm: {{ _("admin_plans.js_seed_confirm") | tojson }},
|
||||||
|
jsSeedFailed: {{ _("admin_plans.js_seed_failed") | tojson }},
|
||||||
|
jsReorderFailed: {{ _("admin_plans.js_reorder_failed") | tojson }},
|
||||||
|
jsOrderSaved: {{ _("admin_plans.js_order_saved") | tojson }},
|
||||||
|
jsSaveFailed: {{ _("admin_plans.js_save_failed") | tojson }},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function planDesigner() {
|
function planDesigner() {
|
||||||
|
const i18n = window.__i18nAdminPlans;
|
||||||
return {
|
return {
|
||||||
|
i18n,
|
||||||
plans: [],
|
plans: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -524,7 +559,7 @@ function planDesigner() {
|
|||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/plans/admin');
|
const resp = await fetch('/api/plans/admin');
|
||||||
if (!resp.ok) throw new Error('Failed to load plans');
|
if (!resp.ok) throw new Error(i18n.jsFailedLoad);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
this.plans = data.plans || [];
|
this.plans = data.plans || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -568,9 +603,9 @@ function planDesigner() {
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
throw new Error(err.detail || 'Save failed');
|
throw new Error(err.detail || i18n.jsSaveFailed);
|
||||||
}
|
}
|
||||||
this.successMsg = this.isCreate ? 'Plan created!' : 'Plan updated!';
|
this.successMsg = this.isCreate ? i18n.jsPlanCreated : i18n.jsPlanUpdated;
|
||||||
this.modalOpen = false;
|
this.modalOpen = false;
|
||||||
await this.loadPlans();
|
await this.loadPlans();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -581,15 +616,15 @@ function planDesigner() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deletePlan(planId) {
|
async deletePlan(planId) {
|
||||||
if (!confirm(`Delete plan "${planId}"? This cannot be undone.`)) return;
|
if (!confirm(i18n.jsDeleteConfirm.replace('{id}', planId))) return;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
|
const resp = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
|
||||||
if (!resp.ok && resp.status !== 204) {
|
if (!resp.ok && resp.status !== 204) {
|
||||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||||
throw new Error(err.detail || 'Delete failed');
|
throw new Error(err.detail || i18n.jsDeleteFailed);
|
||||||
}
|
}
|
||||||
this.successMsg = `Plan "${planId}" deleted.`;
|
this.successMsg = i18n.jsPlanDeleted.replace('{id}', planId);
|
||||||
await this.loadPlans();
|
await this.loadPlans();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errorMsg = e.message;
|
this.errorMsg = e.message;
|
||||||
@@ -597,12 +632,12 @@ function planDesigner() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async seedDefaults() {
|
async seedDefaults() {
|
||||||
if (!confirm('Seed the four default plans? This is a no-op if plans already exist.')) return;
|
if (!confirm(i18n.jsSeedConfirm)) return;
|
||||||
this.seeding = true;
|
this.seeding = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/plans/seed', { method: 'POST' });
|
const resp = await fetch('/api/plans/seed', { method: 'POST' });
|
||||||
if (!resp.ok) throw new Error('Seed failed');
|
if (!resp.ok) throw new Error(i18n.jsSeedFailed);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
this.successMsg = data.message;
|
this.successMsg = data.message;
|
||||||
await this.loadPlans();
|
await this.loadPlans();
|
||||||
@@ -635,8 +670,8 @@ function planDesigner() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ order }),
|
body: JSON.stringify({ order }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Reorder failed');
|
if (!resp.ok) throw new Error(i18n.jsReorderFailed);
|
||||||
this.successMsg = 'Order saved!';
|
this.successMsg = i18n.jsOrderSaved;
|
||||||
this.orderDirty = false;
|
this.orderDirty = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errorMsg = e.message;
|
this.errorMsg = e.message;
|
||||||
|
|||||||
@@ -41,6 +41,114 @@
|
|||||||
"about.story_heading": "Our Story",
|
"about.story_heading": "Our Story",
|
||||||
"about.story_p1": "DocuElevate was created with one goal in mind: to simplify and streamline document management for everyone, whether you’re a small startup or a large enterprise.",
|
"about.story_p1": "DocuElevate was created with one goal in mind: to simplify and streamline document management for everyone, whether you’re a small startup or a large enterprise.",
|
||||||
"about.story_p2": "We harness the power of pluggable AI providers (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement, integrate seamlessly with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions.",
|
"about.story_p2": "We harness the power of pluggable AI providers (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement, integrate seamlessly with Dropbox, Nextcloud, and Paperless NGX for storage and indexing, leverage Azure Document Intelligence for OCR, and even use Gotenberg for file-to-PDF conversions.",
|
||||||
|
"admin_plans.aria_delete_plan": "Delete {name}",
|
||||||
|
"admin_plans.aria_edit_plan": "Edit {name}",
|
||||||
|
"admin_plans.aria_feature_n": "Feature {n}",
|
||||||
|
"admin_plans.aria_move_down": "Move {name} down",
|
||||||
|
"admin_plans.aria_move_up": "Move {name} up",
|
||||||
|
"admin_plans.aria_remove_feature_n": "Remove feature {n}",
|
||||||
|
"admin_plans.btn_add_feature": "Add Feature",
|
||||||
|
"admin_plans.btn_add_plan": "Add Plan",
|
||||||
|
"admin_plans.btn_cancel": "Cancel",
|
||||||
|
"admin_plans.btn_create": "Create Plan",
|
||||||
|
"admin_plans.btn_delete": "Delete",
|
||||||
|
"admin_plans.btn_edit": "Edit",
|
||||||
|
"admin_plans.btn_restore_defaults": "Restore Defaults",
|
||||||
|
"admin_plans.btn_restore_defaults_title": "Restore all four default plans (only if no plans exist yet)",
|
||||||
|
"admin_plans.btn_restoring": "Restoring\u2026",
|
||||||
|
"admin_plans.btn_save_changes": "Save Changes",
|
||||||
|
"admin_plans.btn_save_order": "Save Order",
|
||||||
|
"admin_plans.btn_saving": "Saving\u2026",
|
||||||
|
"admin_plans.btn_stripe_setup": "Stripe Setup",
|
||||||
|
"admin_plans.btn_stripe_setup_title": "Open the Stripe Setup Wizard to configure API keys and sync plans",
|
||||||
|
"admin_plans.col_actions": "Actions",
|
||||||
|
"admin_plans.col_active": "Active",
|
||||||
|
"admin_plans.col_monthly": "Monthly",
|
||||||
|
"admin_plans.col_monthly_limit": "Monthly Limit",
|
||||||
|
"admin_plans.col_order": "Order",
|
||||||
|
"admin_plans.col_overage_pct": "Overage %",
|
||||||
|
"admin_plans.col_plan": "Plan",
|
||||||
|
"admin_plans.col_yearly": "Yearly",
|
||||||
|
"admin_plans.coming_soon": "Coming soon",
|
||||||
|
"admin_plans.featured_badge": "Featured",
|
||||||
|
"admin_plans.field_active": "Active",
|
||||||
|
"admin_plans.field_allow_overage": "Allow Overage Billing",
|
||||||
|
"admin_plans.field_api_access": "API Access",
|
||||||
|
"admin_plans.field_badge_text": "Badge Text",
|
||||||
|
"admin_plans.field_buffer": "Buffer:",
|
||||||
|
"admin_plans.field_cta_text": "CTA Button Text",
|
||||||
|
"admin_plans.field_docs_month": "Docs / Month",
|
||||||
|
"admin_plans.field_featured": "Featured / Highlighted",
|
||||||
|
"admin_plans.field_lifetime_docs": "Lifetime Docs",
|
||||||
|
"admin_plans.field_mailboxes": "Email Mailboxes",
|
||||||
|
"admin_plans.field_max_file_size": "Max File Size (MB)",
|
||||||
|
"admin_plans.field_name": "Name",
|
||||||
|
"admin_plans.field_ocr_pages": "OCR Pages / Month",
|
||||||
|
"admin_plans.field_overage_doc_price": "Overage price / doc ($)",
|
||||||
|
"admin_plans.field_overage_ocr_price": "Overage price / OCR page ($)",
|
||||||
|
"admin_plans.field_plan_id": "Plan ID",
|
||||||
|
"admin_plans.field_price_monthly": "Monthly Price ($)",
|
||||||
|
"admin_plans.field_price_yearly": "Yearly Price ($)",
|
||||||
|
"admin_plans.field_sort_order": "Sort Order",
|
||||||
|
"admin_plans.field_storage_dests": "Storage Destinations",
|
||||||
|
"admin_plans.field_stripe_monthly": "Stripe Price ID (monthly)",
|
||||||
|
"admin_plans.field_stripe_yearly": "Stripe Price ID (yearly)",
|
||||||
|
"admin_plans.field_tagline": "Tagline",
|
||||||
|
"admin_plans.field_trial_days": "Trial Days",
|
||||||
|
"admin_plans.free_label": "Free",
|
||||||
|
"admin_plans.heading": "Plan Designer",
|
||||||
|
"admin_plans.hint_features": "These bullet points appear on the pricing page card for this plan.",
|
||||||
|
"admin_plans.hint_plan_id": "Lowercase slug, cannot be changed after creation.",
|
||||||
|
"admin_plans.hint_zero_unlimited": "Enter 0 for unlimited.",
|
||||||
|
"admin_plans.js_delete_confirm": "Delete plan \"{id}\"? This cannot be undone.",
|
||||||
|
"admin_plans.js_delete_failed": "Delete failed",
|
||||||
|
"admin_plans.js_failed_load": "Failed to load plans",
|
||||||
|
"admin_plans.js_order_saved": "Order saved!",
|
||||||
|
"admin_plans.js_plan_created": "Plan created!",
|
||||||
|
"admin_plans.js_plan_deleted": "Plan \"{id}\" deleted.",
|
||||||
|
"admin_plans.js_plan_updated": "Plan updated!",
|
||||||
|
"admin_plans.js_reorder_failed": "Reorder failed",
|
||||||
|
"admin_plans.js_save_failed": "Save failed",
|
||||||
|
"admin_plans.js_seed_confirm": "Seed the four default plans? This is a no-op if plans already exist.",
|
||||||
|
"admin_plans.js_seed_failed": "Seed failed",
|
||||||
|
"admin_plans.js_yearly_enter": "Enter yearly price to show savings",
|
||||||
|
"admin_plans.js_yearly_save": "Save {pct}% vs monthly",
|
||||||
|
"admin_plans.loading": "Loading plans\u2026",
|
||||||
|
"admin_plans.modal_close_aria": "Close modal",
|
||||||
|
"admin_plans.modal_create_title": "Add Plan",
|
||||||
|
"admin_plans.modal_edit_title_prefix": "Edit Plan: ",
|
||||||
|
"admin_plans.no_plans_intro": "No plans yet. Click",
|
||||||
|
"admin_plans.no_plans_suffix": "to seed the four built-in plans.",
|
||||||
|
"admin_plans.overage_0pct": "0% (exact)",
|
||||||
|
"admin_plans.overage_100pct": "100%",
|
||||||
|
"admin_plans.overage_50pct": "50%",
|
||||||
|
"admin_plans.overage_announce": "\u2192 announce",
|
||||||
|
"admin_plans.overage_buffer_body_prefix": "The overage buffer is",
|
||||||
|
"admin_plans.overage_buffer_body_suffix": ". We advertise X docs/month but only enforce at",
|
||||||
|
"admin_plans.overage_buffer_formula": "X \u00d7 (1 + buffer%)",
|
||||||
|
"admin_plans.overage_buffer_invisible": "invisible to users",
|
||||||
|
"admin_plans.overage_buffer_tail": "docs. For example, a 150-doc/month plan with a 20% buffer enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a graceful soft landing.",
|
||||||
|
"admin_plans.overage_buffer_title": "About the Overage Buffer",
|
||||||
|
"admin_plans.overage_docs": "docs,",
|
||||||
|
"admin_plans.overage_docs_end": "docs",
|
||||||
|
"admin_plans.overage_enforce_at": "enforce at",
|
||||||
|
"admin_plans.page_title": "Plan Designer \u2014 DocuElevate Admin",
|
||||||
|
"admin_plans.section_basic_info": "Basic Info",
|
||||||
|
"admin_plans.section_display": "Display",
|
||||||
|
"admin_plans.section_features": "Features List",
|
||||||
|
"admin_plans.section_overage": "Overage Designer",
|
||||||
|
"admin_plans.section_pricing": "Pricing",
|
||||||
|
"admin_plans.section_stripe": "Stripe Integration",
|
||||||
|
"admin_plans.section_volume": "Volume Limits",
|
||||||
|
"admin_plans.status_active": "Active",
|
||||||
|
"admin_plans.status_inactive": "Inactive",
|
||||||
|
"admin_plans.stripe_desc_after": "to auto-create them. Free plans do not need Stripe Price IDs.",
|
||||||
|
"admin_plans.stripe_desc_before": "Enter the Stripe Price IDs for this plan, or use the",
|
||||||
|
"admin_plans.stripe_wizard_aria": "Open Stripe Setup Wizard in a new tab",
|
||||||
|
"admin_plans.stripe_wizard_link": "Stripe Wizard",
|
||||||
|
"admin_plans.stripe_wizard_text": "Stripe Setup Wizard",
|
||||||
|
"admin_plans.subheading": "Manage subscription plans shown on the public pricing page.",
|
||||||
|
"admin_plans.table_aria_label": "Subscription plans",
|
||||||
"admin_users.add_user_profile_btn": "Add User Profile",
|
"admin_users.add_user_profile_btn": "Add User Profile",
|
||||||
"admin_users.admin_only_badge": "Admin Only",
|
"admin_users.admin_only_badge": "Admin Only",
|
||||||
"admin_users.btn_password": "Password",
|
"admin_users.btn_password": "Password",
|
||||||
|
|||||||
Reference in New Issue
Block a user