- 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>
529 lines
24 KiB
HTML
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 %}
|
|
•
|
|
{% 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 %}
|