Files
gh-christianlouis-docuelevate/frontend/templates/onboarding.html
copilot-swe-agent[bot] e0de0fd6fb feat: add multi-step user onboarding wizard
- 5-step wizard: Welcome → Profile → Plan → Storage → All Set!
- New migration 017: onboarding_completed, contact_email, preferred_destination fields
- REST API at /api/onboarding/{status,profile,plan,storage,complete}
- GET /onboarding view with configured-destinations helper
- OAuth callback redirects first-time users to onboarding
- 16 unit tests for all endpoints; 65 total tests pass

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-07 11:12:21 +00:00

478 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Welcome to DocuElevate{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 py-8 px-4"
x-data="onboardingWizard()" x-init="init()">
<div class="max-w-2xl mx-auto">
<!-- Brand header -->
<div class="text-center mb-8">
<div class="inline-flex items-center gap-2 text-indigo-600 font-bold text-2xl mb-2">
<i class="fas fa-file-alt" aria-hidden="true"></i>
DocuElevate
</div>
<p class="text-gray-500 text-sm">Let's get you set up</p>
</div>
<!-- Progress indicator -->
<div class="flex items-center justify-between mb-8 relative" role="list" aria-label="Onboarding steps">
<!-- Background track -->
<div class="absolute left-0 right-0 top-4 h-0.5 bg-gray-200" aria-hidden="true"></div>
<!-- Progress fill -->
<div class="absolute left-0 top-4 h-0.5 bg-indigo-500 transition-all duration-500"
:style="`width: ${progressPercent}%`" aria-hidden="true"></div>
<template x-for="(label, i) in stepLabels" :key="i">
<div class="flex flex-col items-center z-10" role="listitem">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-all duration-300 bg-white"
:class="i + 1 < step ? 'bg-indigo-600 border-indigo-600 text-white' : i + 1 === step ? 'border-indigo-600 text-indigo-600' : 'border-gray-300 text-gray-400'"
:aria-current="i + 1 === step ? 'step' : undefined">
<span x-show="i + 1 < step" aria-label="Completed">
<i class="fas fa-check text-xs" aria-hidden="true"></i>
</span>
<span x-show="i + 1 >= step" x-text="i + 1" aria-hidden="true"></span>
</div>
<span class="text-xs mt-1 hidden sm:block transition-all duration-300"
:class="i + 1 === step ? 'text-indigo-600 font-semibold' : 'text-gray-400'"
x-text="label"></span>
</div>
</template>
</div>
<!-- Wizard card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- ================================================================
Step 1: Welcome
================================================================ -->
<div x-show="step === 1"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-white text-center">
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-hand-wave text-3xl" aria-hidden="true"></i>
</div>
<h1 class="text-3xl font-extrabold mb-2">
Welcome, {{ user.given_name | default(user.name) | default("there") }}! 👋
</h1>
<p class="text-indigo-100 text-lg">You're just a few steps away from transforming how you handle documents.</p>
</div>
<div class="p-8">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<div class="text-center p-4 rounded-xl bg-blue-50">
<i class="fas fa-magic text-blue-500 text-2xl mb-2" aria-hidden="true"></i>
<h3 class="font-semibold text-gray-800 text-sm">Smart OCR</h3>
<p class="text-gray-500 text-xs mt-1">AI extracts text from any document</p>
</div>
<div class="text-center p-4 rounded-xl bg-indigo-50">
<i class="fas fa-tags text-indigo-500 text-2xl mb-2" aria-hidden="true"></i>
<h3 class="font-semibold text-gray-800 text-sm">Auto-tagging</h3>
<p class="text-gray-500 text-xs mt-1">Documents organised automatically</p>
</div>
<div class="text-center p-4 rounded-xl bg-purple-50">
<i class="fas fa-cloud-upload-alt text-purple-500 text-2xl mb-2" aria-hidden="true"></i>
<h3 class="font-semibold text-gray-800 text-sm">Cloud Sync</h3>
<p class="text-gray-500 text-xs mt-1">Instantly backed up to your storage</p>
</div>
</div>
<p class="text-gray-500 text-sm text-center mb-6">
This quick setup takes about 2 minutes. You can change everything later in your settings.
</p>
<button @click="step++"
class="w-full py-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition min-h-[44px]">
Let's get started <i class="fas fa-arrow-right ml-2" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- ================================================================
Step 2: Your Profile
================================================================ -->
<div x-show="step === 2"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-blue-500 to-indigo-600 px-8 py-6 text-white">
<i class="fas fa-user-circle text-4xl mb-2" aria-hidden="true"></i>
<h2 class="text-2xl font-bold">Your Profile</h2>
<p class="text-blue-100 text-sm">Tell us a little about yourself</p>
</div>
<div class="p-8">
<div class="mb-5">
<label for="displayName" class="block text-sm font-medium text-gray-700 mb-1">
Display Name
</label>
<input id="displayName"
type="text"
x-model="displayName"
placeholder="Your name"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
aria-describedby="displayNameHint" />
<p id="displayNameHint" class="text-gray-400 text-xs mt-1">This is how you'll appear in DocuElevate.</p>
</div>
<div class="mb-6">
<label for="contactEmail" class="block text-sm font-medium text-gray-700 mb-1">
Contact Email
</label>
<input id="contactEmail"
type="email"
x-model="contactEmail"
placeholder="you@example.com"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition"
aria-describedby="contactEmailHint" />
<p id="contactEmailHint" class="text-gray-400 text-xs mt-1">Used for notifications. Can be different from your login email.</p>
</div>
<div class="flex gap-3">
<button @click="goBack()"
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button @click="goNext()"
:disabled="loading"
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">Continue <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i></span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</div>
</div>
<!-- ================================================================
Step 3: Choose Your Plan
================================================================ -->
<div x-show="step === 3"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-purple-500 to-pink-600 px-8 py-6 text-white">
<i class="fas fa-layer-group text-4xl mb-2" aria-hidden="true"></i>
<h2 class="text-2xl font-bold">Choose Your Plan</h2>
<p class="text-purple-100 text-sm">Start free, upgrade when you're ready</p>
</div>
<div class="p-6">
<!-- Monthly / Annual toggle -->
<div class="flex items-center justify-center gap-3 mb-6" x-effect="billingCycle = annual ? 'yearly' : 'monthly'">
<span class="text-sm font-medium" :class="!annual ? 'text-indigo-600' : 'text-gray-500'">Monthly</span>
<button @click="annual = !annual"
class="relative w-12 h-6 rounded-full transition-colors duration-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="annual ? 'bg-indigo-600' : 'bg-gray-300'"
role="switch"
:aria-checked="annual.toString()"
aria-label="Toggle billing cycle">
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-300"
:class="annual ? 'translate-x-6' : 'translate-x-0'"></span>
</button>
<span class="text-sm font-medium" :class="annual ? 'text-indigo-600' : 'text-gray-500'">
Annual <span class="bg-green-100 text-green-700 text-xs px-1.5 py-0.5 rounded-full ml-1">2 months free</span>
</span>
</div>
<!-- Tier cards (server-rendered by Jinja2) -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
{% for tier in tiers %}
<button type="button"
@click="selectedTier = '{{ tier.id }}'"
class="text-left p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 min-h-[44px]"
:class="selectedTier === '{{ tier.id }}' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-white'"
:aria-pressed="(selectedTier === '{{ tier.id }}').toString()">
<div class="flex items-center justify-between mb-1">
<span class="font-bold text-gray-800">{{ tier.name }}</span>
<span class="text-lg font-extrabold text-indigo-600">
{% if tier.price_monthly == 0 %}
Free
{% else %}
<span x-show="!annual">${{ tier.price_monthly }}<span class="text-xs font-normal text-gray-500">/mo</span></span>
<span x-show="annual">
{% if tier.price_yearly == 0 %}Free
{% else %}${{ "%.2f"|format(tier.price_yearly) }}<span class="text-xs font-normal text-gray-500">/yr</span>
{% endif %}
</span>
{% endif %}
</span>
</div>
<p class="text-gray-500 text-xs">{{ tier.tagline }}</p>
{% if tier.id != "free" and tier.get("trial_days") %}
<span class="inline-block mt-1.5 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
{{ tier.trial_days }}-day free trial
</span>
{% endif %}
</button>
{% endfor %}
</div>
<!-- Beta notice -->
<div class="bg-amber-50 border border-amber-200 rounded-xl p-3 mb-5 flex gap-2 text-sm text-amber-700">
<i class="fas fa-info-circle mt-0.5 shrink-0" aria-hidden="true"></i>
<span>Payment processing is coming soon. You can select a plan now and billing will activate at launch.</span>
</div>
<div class="flex gap-3">
<button @click="goBack()"
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button @click="goNext()"
:disabled="loading"
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">Continue <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i></span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</div>
</div>
<!-- ================================================================
Step 4: Storage Destination
================================================================ -->
<div x-show="step === 4"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-green-500 to-teal-600 px-8 py-6 text-white">
<i class="fas fa-hdd text-4xl mb-2" aria-hidden="true"></i>
<h2 class="text-2xl font-bold">Storage Destination</h2>
<p class="text-green-100 text-sm">Where should your processed documents go?</p>
</div>
<div class="p-8">
{% if configured_destinations %}
<p class="text-gray-600 text-sm mb-4">Select where you'd like your documents stored. You can change this later in settings.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6" role="listbox" aria-label="Storage destinations">
{% for dest in configured_destinations %}
<button type="button"
@click="selectedDestination = '{{ dest.id }}'"
role="option"
:aria-selected="(selectedDestination === '{{ dest.id }}').toString()"
class="flex items-center gap-3 p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 min-h-[44px]"
:class="selectedDestination === '{{ dest.id }}' ? 'border-green-500 bg-green-50' : 'border-gray-200 hover:border-gray-300 bg-white'">
<i class="{{ dest.icon }} text-xl text-gray-600" aria-hidden="true"></i>
<span class="font-medium text-gray-800">{{ dest.name }}</span>
<span x-show="selectedDestination === '{{ dest.id }}'" class="ml-auto text-green-600">
<i class="fas fa-check-circle" aria-hidden="true"></i>
</span>
</button>
{% endfor %}
</div>
<p class="text-gray-400 text-xs mb-4">
Not seeing your provider?
{% if user.is_admin %}
<a href="/settings" class="text-indigo-500 hover:underline">Go to Settings</a> to configure more destinations.
{% else %}
Ask your administrator to configure additional storage providers.
{% endif %}
</p>
{% else %}
<div class="text-center py-8">
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-hdd text-2xl text-gray-400" aria-hidden="true"></i>
</div>
<h3 class="font-semibold text-gray-700 mb-2">No Storage Configured Yet</h3>
<p class="text-gray-500 text-sm mb-4">
No storage destinations have been configured for this instance yet.
You can skip this step and set one up later.
</p>
{% if user.is_admin %}
<a href="/settings"
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 transition text-sm font-semibold min-h-[44px]">
<i class="fas fa-cog" aria-hidden="true"></i> Configure Storage
</a>
{% else %}
<p class="text-gray-400 text-xs">Contact your administrator to set up a storage destination.</p>
{% endif %}
</div>
{% endif %}
<div class="flex gap-3">
<button @click="goBack()"
class="flex-1 py-2.5 px-4 border border-gray-300 text-gray-600 font-semibold rounded-xl hover:bg-gray-50 transition min-h-[44px]">
<i class="fas fa-arrow-left mr-2" aria-hidden="true"></i> Back
</button>
<button @click="goNext()"
:disabled="loading"
class="flex-1 py-2.5 px-4 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">
{% if configured_destinations %}Continue{% else %}Skip{% endif %}
<i class="fas fa-arrow-right ml-1" aria-hidden="true"></i>
</span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</div>
</div>
<!-- ================================================================
Step 5: All Set!
================================================================ -->
<div x-show="step === 5"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0">
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-10 text-white text-center">
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-circle text-4xl" aria-hidden="true"></i>
</div>
<h2 class="text-3xl font-extrabold mb-2">You're all set! 🎉</h2>
<p class="text-indigo-100">Your account is configured and ready to go.</p>
</div>
<div class="p-8">
<ul class="space-y-3 mb-8" aria-label="Setup summary">
<li class="flex items-center gap-3 text-gray-700">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center shrink-0">
<i class="fas fa-check text-green-600 text-sm" aria-hidden="true"></i>
</div>
<span>Profile saved</span>
</li>
<li class="flex items-center gap-3 text-gray-700">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center shrink-0">
<i class="fas fa-check text-green-600 text-sm" aria-hidden="true"></i>
</div>
<span>Plan selected: <strong x-text="selectedTier"></strong></span>
</li>
<li class="flex items-center gap-3 text-gray-700">
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
:class="selectedDestination ? 'bg-green-100' : 'bg-gray-100'">
<i class="fas text-sm"
:class="selectedDestination ? 'fa-check text-green-600' : 'fa-minus text-gray-400'"
aria-hidden="true"></i>
</div>
<span x-show="selectedDestination">Storage: <strong x-text="selectedDestination"></strong></span>
<span x-show="!selectedDestination" class="text-gray-400">No storage destination selected (can be set later)</span>
</li>
</ul>
<button @click="goNext()"
:disabled="loading"
class="w-full py-3 px-6 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold rounded-xl transition min-h-[44px]">
<span x-show="!loading">
<i class="fas fa-rocket mr-2" aria-hidden="true"></i> Start uploading documents
</span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Setting up…</span>
</button>
</div>
</div>
</div><!-- /card -->
<!-- Error display -->
<div x-show="error"
x-text="error"
role="alert"
aria-live="polite"
class="mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm text-center">
</div>
</div><!-- /max-w-2xl -->
</div><!-- /min-h-screen -->
<script>
function onboardingWizard() {
return {
step: 1,
totalSteps: 5,
loading: false,
error: '',
// Form data pre-populated from Jinja2 context
displayName: '{{ user.name | default("") | e }}',
contactEmail: '{{ user.email | default("") | e }}',
selectedTier: 'free',
billingCycle: 'monthly',
annual: false,
selectedDestination: '',
get progressPercent() {
return ((this.step - 1) / (this.totalSteps - 1)) * 100;
},
stepLabels: ['Welcome', 'Your Profile', 'Your Plan', 'Storage', 'All Done!'],
async init() {
try {
const r = await fetch('/api/onboarding/status');
if (r.ok) {
const data = await r.json();
if (data.completed) {
window.location.href = '/upload';
return;
}
if (data.profile) {
if (data.profile.display_name) this.displayName = data.profile.display_name;
if (data.profile.contact_email) this.contactEmail = data.profile.contact_email;
if (data.profile.subscription_tier) this.selectedTier = data.profile.subscription_tier;
if (data.profile.subscription_billing_cycle) this.billingCycle = data.profile.subscription_billing_cycle;
if (data.profile.preferred_destination) this.selectedDestination = data.profile.preferred_destination;
}
}
} catch (_) {
// Non-fatal: proceed with defaults
}
},
async goNext() {
if (this.step === 2) await this.saveProfile();
else if (this.step === 3) await this.savePlan();
else if (this.step === 4) await this.saveStorage();
else if (this.step === 5) { await this.completeOnboarding(); return; }
if (!this.error) this.step++;
},
goBack() {
if (this.step > 1) this.step--;
},
async saveProfile() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: this.displayName, contact_email: this.contactEmail })
});
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save profile'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
},
async savePlan() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_tier: this.selectedTier, billing_cycle: this.billingCycle })
});
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save plan'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
},
async saveStorage() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/storage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferred_destination: this.selectedDestination || null })
});
if (!r.ok) { const d = await r.json(); this.error = d.detail || 'Failed to save storage preference'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
},
async completeOnboarding() {
this.loading = true; this.error = '';
try {
const r = await fetch('/api/onboarding/complete', { method: 'POST' });
if (r.ok) {
const data = await r.json();
window.location.href = data.redirect_url || '/upload';
} else { const d = await r.json(); this.error = d.detail || 'Failed to complete onboarding'; }
} catch (_) { this.error = 'Network error. Please try again.'; }
finally { this.loading = false; }
}
};
}
</script>
{% endblock %}