Files
orion/app/templates/platform/signup.html
Samir Boulahtit 18d7ef3e5b feat: add required field indicators to signup form
- Add red asterisk (*) to all mandatory fields in Step 3
- Add "Required fields" legend at top of form
- Fields marked: First Name, Last Name, Company Name, Email, Password

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 20:13:24 +01:00

529 lines
24 KiB
HTML

{# app/templates/platform/signup.html #}
{# Multi-step Signup Wizard #}
{% extends "platform/base.html" %}
{% block title %}Start Your Free Trial - Wizamart{% endblock %}
{% block extra_head %}
{# Stripe.js for payment #}
<script src="https://js.stripe.com/v3/"></script>
{% endblock %}
{% block content %}
<div x-data="signupWizard()" class="min-h-screen py-12 bg-gray-50 dark:bg-gray-900">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
{# Progress Steps #}
<div class="mb-12">
<div class="flex items-center justify-between">
<template x-for="(stepName, index) in ['Select Plan', 'Claim Shop', 'Account', 'Payment']" :key="index">
<div class="flex items-center" :class="index < 3 ? 'flex-1' : ''">
<div class="flex items-center justify-center w-10 h-10 rounded-full font-semibold transition-colors"
:class="currentStep > index + 1 ? 'bg-green-500 text-white' : currentStep === index + 1 ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'">
<template x-if="currentStep > index + 1">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</template>
<template x-if="currentStep <= index + 1">
<span x-text="index + 1"></span>
</template>
</div>
<span class="ml-2 text-sm font-medium hidden sm:inline"
:class="currentStep >= index + 1 ? 'text-gray-900 dark:text-white' : 'text-gray-500'"
x-text="stepName"></span>
<template x-if="index < 3">
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 rounded">
<div class="h-full bg-indigo-600 rounded transition-all"
:style="'width: ' + (currentStep > index + 1 ? '100%' : '0%')"></div>
</div>
</template>
</div>
</template>
</div>
</div>
{# Form Card #}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{# ===============================================================
STEP 1: SELECT PLAN
=============================================================== #}
<div x-show="currentStep === 1" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Choose Your Plan</h2>
{# Billing Toggle #}
<div class="flex items-center justify-center mb-8 space-x-4">
<span :class="!isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">Monthly</span>
<button @click="isAnnual = !isAnnual"
class="relative w-12 h-6 rounded-full transition-colors"
:class="isAnnual ? 'bg-indigo-600' : 'bg-gray-300'">
<span class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform"
:class="isAnnual ? 'translate-x-6' : ''"></span>
</button>
<span :class="isAnnual ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-500'">
Annual <span class="text-green-600 text-xs">Save 17%</span>
</span>
</div>
{# Tier Options #}
<div class="space-y-4">
{% for tier in tiers %}
{% if not tier.is_enterprise %}
<label class="block">
<input type="radio" name="tier" value="{{ tier.code }}"
x-model="selectedTier" class="hidden peer"/>
<div class="p-4 border-2 rounded-xl cursor-pointer transition-all
peer-checked:border-indigo-500 peer-checked:bg-indigo-50 dark:peer-checked:bg-indigo-900/20
border-gray-200 dark:border-gray-700 hover:border-gray-300">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tier.name }}</h3>
<p class="text-sm text-gray-500">
{% if tier.orders_per_month %}{{ tier.orders_per_month }} orders/mo{% else %}Unlimited{% endif %}
&bull;
{% if tier.team_members %}{{ tier.team_members }} user{% if tier.team_members > 1 %}s{% endif %}{% else %}Unlimited{% endif %}
</p>
</div>
<div class="text-right">
<template x-if="!isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ tier.price_monthly|int }}€/mo</span>
</template>
<template x-if="isAnnual">
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ ((tier.price_annual or tier.price_monthly * 12) / 12)|round(0)|int }}€/mo</span>
</template>
</div>
</div>
</div>
</label>
{% endif %}
{% endfor %}
</div>
{# Free Trial Note #}
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-sm text-green-800 dark:text-green-300">
<strong>{{ trial_days }}-day free trial.</strong>
We'll collect your payment info, but you won't be charged until the trial ends.
</p>
</div>
<button @click="startSignup()"
:disabled="!selectedTier || loading"
class="mt-8 w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-colors disabled:opacity-50">
Continue
</button>
</div>
{# ===============================================================
STEP 2: CLAIM LETZSHOP SHOP (Optional)
=============================================================== #}
<div x-show="currentStep === 2" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Connect Your Letzshop Shop</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">Optional: Link your Letzshop account to sync orders automatically.</p>
<div class="space-y-4">
<input
type="text"
x-model="letzshopUrl"
placeholder="letzshop.lu/vendors/your-shop"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"
/>
<template x-if="letzshopVendor">
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
<p class="text-green-800 dark:text-green-300">
Found: <strong x-text="letzshopVendor.name"></strong>
</p>
</div>
</template>
<template x-if="letzshopError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="letzshopError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 1"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="claimVendor()"
:disabled="loading"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
<span x-text="letzshopUrl.trim() ? 'Connect & Continue' : 'Skip This Step'"></span>
</button>
</div>
</div>
{# ===============================================================
STEP 3: CREATE ACCOUNT
=============================================================== #}
<div x-show="currentStep === 3" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Create Your Account</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
<span class="text-red-500">*</span> Required fields
</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.firstName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.lastName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Company Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="account.companyName" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input type="email" x-model="account.email" required
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-red-500">*</span>
</label>
<input type="password" x-model="account.password" required minlength="8"
class="w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white"/>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<template x-if="accountError">
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-xl">
<p class="text-red-800 dark:text-red-300" x-text="accountError"></p>
</div>
</template>
</div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 2"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="createAccount()"
:disabled="loading || !isAccountValid()"
class="flex-1 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl disabled:opacity-50">
Continue to Payment
</button>
</div>
</div>
{# ===============================================================
STEP 4: PAYMENT
=============================================================== #}
<div x-show="currentStep === 4" class="p-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Add Payment Method</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">You won't be charged until your {{ trial_days }}-day trial ends.</p>
{# Stripe Card Element #}
<div id="card-element" class="p-4 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-900"></div>
<div id="card-errors" class="text-red-600 text-sm mt-2"></div>
<div class="mt-8 flex gap-4">
<button @click="currentStep = 3"
class="flex-1 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 font-semibold rounded-xl">
Back
</button>
<button @click="submitPayment()"
:disabled="loading || paymentProcessing"
class="flex-1 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-xl disabled:opacity-50">
<template x-if="paymentProcessing">
<span>Processing...</span>
</template>
<template x-if="!paymentProcessing">
<span>Start Free Trial</span>
</template>
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function signupWizard() {
return {
currentStep: 1,
loading: false,
sessionId: null,
// Step 1: Plan
selectedTier: '{{ selected_tier or "professional" }}',
isAnnual: {{ 'true' if is_annual else 'false' }},
// Step 2: Letzshop
letzshopUrl: '',
letzshopVendor: null,
letzshopError: null,
// Step 3: Account
account: {
firstName: '',
lastName: '',
companyName: '',
email: '',
password: ''
},
accountError: null,
// Step 4: Payment
stripe: null,
cardElement: null,
paymentProcessing: false,
clientSecret: null,
init() {
// Check URL params for pre-selection
const params = new URLSearchParams(window.location.search);
if (params.get('tier')) {
this.selectedTier = params.get('tier');
}
if (params.get('annual') === 'true') {
this.isAnnual = true;
}
if (params.get('letzshop')) {
this.letzshopUrl = params.get('letzshop');
}
// Initialize Stripe when we get to step 4
this.$watch('currentStep', (step) => {
if (step === 4) {
this.initStripe();
}
});
},
async startSignup() {
this.loading = true;
try {
const response = await fetch('/api/v1/platform/signup/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier_code: this.selectedTier,
is_annual: this.isAnnual
})
});
const data = await response.json();
if (response.ok) {
this.sessionId = data.session_id;
this.currentStep = 2;
} else {
alert(data.detail || 'Failed to start signup');
}
} catch (error) {
console.error('Error:', error);
alert('Failed to start signup. Please try again.');
} finally {
this.loading = false;
}
},
async claimVendor() {
if (this.letzshopUrl.trim()) {
this.loading = true;
this.letzshopError = null;
try {
// First lookup the vendor
const lookupResponse = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.letzshopUrl })
});
const lookupData = await lookupResponse.json();
if (lookupData.found && !lookupData.vendor.is_claimed) {
this.letzshopVendor = lookupData.vendor;
// Claim the vendor
const claimResponse = await fetch('/api/v1/platform/signup/claim-vendor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
letzshop_slug: lookupData.vendor.slug
})
});
if (claimResponse.ok) {
const claimData = await claimResponse.json();
this.account.companyName = claimData.vendor_name || '';
this.currentStep = 3;
} else {
const error = await claimResponse.json();
this.letzshopError = error.detail || 'Failed to claim vendor';
}
} else if (lookupData.vendor?.is_claimed) {
this.letzshopError = 'This shop has already been claimed.';
} else {
this.letzshopError = lookupData.error || 'Shop not found.';
}
} catch (error) {
console.error('Error:', error);
this.letzshopError = 'Failed to lookup vendor.';
} finally {
this.loading = false;
}
} else {
// Skip this step
this.currentStep = 3;
}
},
isAccountValid() {
return this.account.firstName.trim() &&
this.account.lastName.trim() &&
this.account.companyName.trim() &&
this.account.email.trim() &&
this.account.password.length >= 8;
},
async createAccount() {
this.loading = true;
this.accountError = null;
try {
const response = await fetch('/api/v1/platform/signup/create-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
email: this.account.email,
password: this.account.password,
first_name: this.account.firstName,
last_name: this.account.lastName,
company_name: this.account.companyName
})
});
const data = await response.json();
if (response.ok) {
this.currentStep = 4;
} else {
this.accountError = data.detail || 'Failed to create account';
}
} catch (error) {
console.error('Error:', error);
this.accountError = 'Failed to create account. Please try again.';
} finally {
this.loading = false;
}
},
async initStripe() {
{% if stripe_publishable_key %}
this.stripe = Stripe('{{ stripe_publishable_key }}');
const elements = this.stripe.elements();
this.cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#374151',
'::placeholder': { color: '#9CA3AF' }
}
}
});
this.cardElement.mount('#card-element');
this.cardElement.on('change', (event) => {
const displayError = document.getElementById('card-errors');
displayError.textContent = event.error ? event.error.message : '';
});
// Get SetupIntent
try {
const response = await fetch('/api/v1/platform/signup/setup-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId })
});
const data = await response.json();
if (response.ok) {
this.clientSecret = data.client_secret;
}
} catch (error) {
console.error('Error getting SetupIntent:', error);
}
{% else %}
console.warn('Stripe not configured');
{% endif %}
},
async submitPayment() {
if (!this.stripe || !this.clientSecret) {
alert('Payment not configured. Please contact support.');
return;
}
this.paymentProcessing = true;
try {
const { setupIntent, error } = await this.stripe.confirmCardSetup(
this.clientSecret,
{ payment_method: { card: this.cardElement } }
);
if (error) {
document.getElementById('card-errors').textContent = error.message;
this.paymentProcessing = false;
return;
}
// Complete signup
const response = await fetch('/api/v1/platform/signup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: this.sessionId,
setup_intent_id: setupIntent.id
})
});
const data = await response.json();
if (response.ok) {
window.location.href = '/signup/success?vendor_code=' + data.vendor_code;
} else {
alert(data.detail || 'Failed to complete signup');
}
} catch (error) {
console.error('Payment error:', error);
alert('Payment failed. Please try again.');
} finally {
this.paymentProcessing = false;
}
}
};
}
</script>
{% endblock %}