Files
gh-christianlouis-docuelevate/frontend/templates/admin_stripe_wizard.html

417 lines
20 KiB
HTML

{% extends "base.html" %}
{% block title %}Stripe Setup Wizard — DocuElevate Admin{% endblock %}
{% block content %}
<main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
x-data="stripeWizard()"
x-init="init()">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">
<i class="fab fa-stripe text-indigo-600 mr-2" aria-hidden="true"></i>Stripe Setup Wizard
</h1>
<p class="text-sm text-gray-500 mt-1">Configure Stripe billing for DocuElevate in three steps.</p>
</div>
<a href="/admin/plans"
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"
>
<i class="fas fa-arrow-left mr-2 text-gray-400" aria-hidden="true"></i>Back to Plans
</a>
</div>
<!-- Loading -->
<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>
<p class="mt-2 text-sm">Checking Stripe status…</p>
</div>
<!-- Main wizard content -->
<div x-show="!loading" x-cloak>
<!-- Step progress bar -->
<nav aria-label="Wizard steps" class="mb-8">
<ol class="flex items-center space-x-2">
<template x-for="(step, i) in steps" :key="i">
<li class="flex items-center">
<button
@click="currentStep = i"
class="flex items-center focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 rounded"
:aria-current="currentStep === i ? 'step' : null"
:aria-label="'Step ' + (i + 1) + ': ' + step.title"
>
<span
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold border-2 transition"
:class="currentStep === i
? 'bg-indigo-600 text-white border-indigo-600'
: stepDone(i)
? 'bg-green-100 text-green-700 border-green-400'
: 'bg-white text-gray-400 border-gray-300'"
x-text="stepDone(i) ? '✓' : (i + 1)"
></span>
<span class="ml-2 text-sm font-medium hidden sm:block"
:class="currentStep === i ? 'text-indigo-700' : 'text-gray-500'"
x-text="step.title"></span>
</button>
<template x-if="i < steps.length - 1">
<div class="flex-1 h-px bg-gray-200 mx-3 hidden sm:block" style="min-width:2rem;"></div>
</template>
</li>
</template>
</ol>
</nav>
<!-- ── Step 0: Verify API Keys ──────────────────────────────────────── -->
<section x-show="currentStep === 0" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 1 — Verify Stripe API Keys</h2>
<p class="text-sm text-gray-600">
Set your Stripe keys in your environment (or <code>.env</code> file) then click
<strong>Check Connection</strong> to verify.
</p>
</div>
<!-- Config snippet -->
<div class="bg-gray-50 rounded-md p-4 text-sm font-mono text-gray-700 border border-gray-200 space-y-1" role="note">
<p>STRIPE_SECRET_KEY=<span class="text-indigo-600">sk_test_…</span></p>
<p>STRIPE_PUBLISHABLE_KEY=<span class="text-indigo-600">pk_test_…</span></p>
<p>STRIPE_WEBHOOK_SECRET=<span class="text-indigo-600">whsec_…</span></p>
</div>
<!-- Status display -->
<div x-show="status !== null">
<!-- Not configured -->
<div x-show="status && !status.configured"
class="rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700" role="alert">
<i class="fas fa-times-circle mr-1" aria-hidden="true"></i>
<strong>STRIPE_SECRET_KEY</strong> is not set.
Add it to your environment and restart the server.
</div>
<!-- Configured — connection result -->
<div x-show="status && status.configured">
<div x-show="status && status.connection === 'ok'"
class="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
Connected to Stripe
<span class="font-semibold" x-text="status && status.mode === 'test' ? '(Test mode)' : '(Live mode)'"></span>.
</div>
<div x-show="status && status.connection !== 'ok'"
class="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<span x-text="'Connection error: ' + (status && status.connection)"></span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
@click="checkStatus()"
:disabled="checking"
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"
style="min-height:44px"
>
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
<span x-show="!checking">Check Connection</span>
<span x-show="checking"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Checking…</span>
</button>
<button
x-show="status && status.configured && status.connection === 'ok'"
@click="currentStep = 1"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
style="min-height:44px"
>
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
</button>
</div>
</section>
<!-- ── Step 1: Sync Plans ───────────────────────────────────────────── -->
<section x-show="currentStep === 1" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 2 — Sync Plans to Stripe</h2>
<p class="text-sm text-gray-600">
DocuElevate can auto-create Stripe <strong>Products</strong> and <strong>Prices</strong> for each
paid plan. Free plans are skipped. Plans that already have a Stripe price ID are left unchanged.
</p>
</div>
<!-- Plan grid -->
<div x-show="status && status.plans && status.plans.length > 0">
<table class="min-w-full text-sm divide-y divide-gray-200">
<caption class="sr-only">Plan sync status</caption>
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Price ID</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly Price ID</th>
<th scope="col" class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<template x-for="plan in (status && status.plans || [])" :key="plan.plan_id">
<tr>
<td class="px-3 py-2">
<span class="font-medium text-gray-900" x-text="plan.name"></span>
<span class="block text-xs text-gray-400" x-text="plan.plan_id"></span>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600">
<span x-show="plan.stripe_price_id_monthly" x-text="plan.stripe_price_id_monthly" class="text-green-700"></span>
<span x-show="!plan.stripe_price_id_monthly && plan.price_monthly > 0" class="text-red-500">not set</span>
<span x-show="!plan.stripe_price_id_monthly && plan.price_monthly === 0" class="text-gray-400"></span>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600">
<span x-show="plan.stripe_price_id_yearly" x-text="plan.stripe_price_id_yearly" class="text-green-700"></span>
<span x-show="!plan.stripe_price_id_yearly && plan.price_yearly > 0" class="text-red-500">not set</span>
<span x-show="!plan.stripe_price_id_yearly && plan.price_yearly === 0" class="text-gray-400"></span>
</td>
<td class="px-3 py-2 text-center">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="plan.synced ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'"
x-text="plan.synced ? 'Synced' : 'Needs sync'"
></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Sync result -->
<div x-show="syncResults !== null" class="space-y-2" aria-live="polite">
<template x-for="r in (syncResults || [])" :key="r.plan_id">
<div class="flex items-center gap-2 text-sm">
<i class="fas"
:class="r.status === 'created' ? 'fa-check-circle text-green-500'
: r.status === 'already_synced' ? 'fa-check text-gray-400'
: r.status === 'skipped_free' ? 'fa-minus text-gray-300'
: 'fa-times-circle text-red-500'"
aria-hidden="true"></i>
<span class="font-medium" x-text="r.name"></span>
<span class="text-gray-500"
x-text="r.status === 'created' ? '— created in Stripe'
: r.status === 'already_synced' ? '— already synced'
: r.status === 'skipped_free' ? '— free plan, skipped'
: ('— error: ' + r.detail)"></span>
</div>
</template>
</div>
<div class="flex items-center gap-3">
<button
@click="currentStep = 0"
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-gray-400"
style="min-height:44px"
>
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button
@click="syncPlans()"
:disabled="syncing || !(status && status.configured)"
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"
style="min-height:44px"
>
<i class="fas fa-sync-alt mr-2" aria-hidden="true"></i>
<span x-show="!syncing">Sync Plans to Stripe</span>
<span x-show="syncing"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Syncing…</span>
</button>
<button
x-show="syncResults !== null"
@click="currentStep = 2; checkStatus()"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
style="min-height:44px"
>
Next <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
</button>
</div>
</section>
<!-- ── Step 2: Webhook ──────────────────────────────────────────────── -->
<section x-show="currentStep === 2" class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Step 3 — Configure Stripe Webhook</h2>
<p class="text-sm text-gray-600">
Webhooks let Stripe notify DocuElevate of subscription changes in real time.
Add the endpoint below in your
<a href="https://dashboard.stripe.com/webhooks" target="_blank" rel="noopener noreferrer"
class="text-indigo-600 hover:text-indigo-800 underline focus:outline-none focus:ring-1 focus:ring-indigo-400 rounded">
Stripe Dashboard → Developers → Webhooks
</a>.
</p>
</div>
<!-- Webhook URL -->
<div>
<label for="webhook-url" class="block text-sm font-medium text-gray-700 mb-1">Webhook Endpoint URL</label>
<div class="flex items-center gap-2">
<input
id="webhook-url"
type="text"
:value="status && status.webhook_endpoint"
readonly
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm bg-gray-50 font-mono focus:outline-none"
aria-label="Webhook endpoint URL to register in Stripe"
/>
<button
@click="copyWebhook()"
class="inline-flex items-center px-3 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"
style="min-height:44px"
aria-label="Copy webhook URL to clipboard"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- Events list -->
<div>
<p class="text-sm font-medium text-gray-700 mb-2">Subscribe to these events:</p>
<ul class="list-none space-y-1 text-sm font-mono text-gray-600">
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>checkout.session.completed</li>
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>customer.subscription.updated</li>
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>customer.subscription.deleted</li>
<li><i class="fas fa-check-circle text-green-500 mr-2" aria-hidden="true"></i>invoice.payment_failed</li>
</ul>
</div>
<!-- Webhook secret instructions -->
<div class="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800" role="note">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
After adding the endpoint, copy the <strong>Signing secret</strong> from Stripe and set
<code class="bg-blue-100 px-1 rounded">STRIPE_WEBHOOK_SECRET=whsec_…</code> in your environment.
This protects against spoofed webhook events.
</div>
<!-- Webhook secret status -->
<div x-show="status">
<div x-show="status && status.webhook_secret_set"
class="rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
<strong>STRIPE_WEBHOOK_SECRET</strong> is configured. Webhook signatures are verified.
</div>
<div x-show="status && !status.webhook_secret_set"
class="rounded-md bg-yellow-50 border border-yellow-200 px-4 py-3 text-sm text-yellow-800" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<strong>STRIPE_WEBHOOK_SECRET</strong> is not set. Webhook events are accepted without
signature verification. Set it in your environment for production security.
</div>
</div>
<!-- Local testing -->
<details class="border border-gray-200 rounded-md">
<summary class="px-4 py-3 text-sm font-medium text-gray-700 cursor-pointer hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 rounded-md">
<i class="fas fa-terminal mr-1" aria-hidden="true"></i> Local testing with Stripe CLI
</summary>
<div class="px-4 pb-4 pt-2 bg-gray-50 rounded-b-md">
<pre class="text-xs text-gray-700 overflow-x-auto space-y-1"><code># Install Stripe CLI and log in
stripe login
# Forward webhooks to your local server
stripe listen --forward-to http://localhost:8000/api/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted</code></pre>
</div>
</details>
<div class="flex items-center gap-3">
<button
@click="currentStep = 1"
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-gray-400"
style="min-height:44px"
>
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<a href="/admin/plans"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500"
style="min-height:44px"
>
<i class="fas fa-check mr-2" aria-hidden="true"></i> Done — Go to Plan Designer
</a>
</div>
</section>
</div><!-- /!loading -->
</main>
<script>
function stripeWizard() {
return {
loading: true,
checking: false,
syncing: false,
currentStep: 0,
status: null,
syncResults: null,
copied: false,
steps: [
{ title: 'Verify API Keys' },
{ title: 'Sync Plans' },
{ title: 'Configure Webhook' },
],
stepDone(i) {
if (i === 0) return this.status && this.status.configured && this.status.connection === 'ok';
if (i === 1) return this.syncResults !== null;
return false;
},
async init() {
await this.checkStatus();
this.loading = false;
},
async checkStatus() {
this.checking = true;
try {
const resp = await fetch('/api/billing/stripe/status');
if (!resp.ok) throw new Error('Failed to fetch Stripe status');
this.status = await resp.json();
// Determine if webhook secret is set by checking the status returned from the API
this.status.webhook_secret_set = this.status.webhook_secret_configured ?? false;
} catch (e) {
this.status = { configured: false, connection: e.message, mode: null, plans: [] };
} finally {
this.checking = false;
}
},
async syncPlans() {
this.syncing = true;
this.syncResults = null;
try {
const resp = await fetch('/api/billing/stripe/sync-plans', { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Sync failed');
}
const data = await resp.json();
this.syncResults = data.results || [];
// Refresh status to update price IDs shown in the table
await this.checkStatus();
} catch (e) {
this.syncResults = [{ plan_id: '_error', name: 'Error', status: 'error', detail: e.message }];
} finally {
this.syncing = false;
}
},
copyWebhook() {
const url = this.status && this.status.webhook_endpoint;
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
}).catch(() => {});
},
};
}
</script>
{% endblock %}