22 KiB
Free Trial Signup Flow - Complete Fix
Problem: Complex signup with plan selection; Need: Simple free trial
Current Flow Analysis
Frontend (SignUpForm.tsx)
Problems:
- Lines 29-64: Loads all plans from API
- Lines 257-279: Shows plan selection dropdown (required)
- Line 85-88: Validates plan is selected
- Line 101: Passes
plan_idto register - Line 105: Redirects to
/account/plansafter signup (payment/plan page)
Backend (RegisterSerializer)
Problems:
- Line 282-290: If no plan_id, tries to find 'free' plan or cheapest
- Line 332-337: Creates account but NO credit seeding
- No default status='trial' set
- No automatic trial period setup
Current User Journey (Messy)
Marketing → Click "Sign Up"
→ /signup page loads plans API
→ User must select plan from dropdown
→ Submit registration with plan_id
→ Backend creates account (0 credits!)
→ Redirect to /account/plans (payment page)
→ User confused, no clear trial
Solution: Simple Free Trial Signup
Desired User Journey (Clean)
Marketing → Click "Sign Up"
→ /signup page (no plan selection!)
→ User fills: name, email, password
→ Submit → Backend auto-assigns "Free Trial" plan
→ Credits seeded automatically
→ Status = 'trial'
→ Redirect to /sites (dashboard)
→ User starts using app immediately
Implementation Steps
Step 1: Create Free Trial Plan (Database)
Run in Django shell or create migration:
from igny8_core.auth.models import Plan
# Create or update Free Trial plan
Plan.objects.update_or_create(
slug='free-trial',
defaults={
'name': 'Free Trial',
'price': 0.00,
'billing_cycle': 'monthly',
'included_credits': 2000, # Enough for testing
'max_sites': 1,
'max_users': 1,
'max_industries': 3,
'is_active': True,
'features': ['ai_writer', 'planner', 'basic_support']
}
)
Verify:
python manage.py shell
>>> from igny8_core.auth.models import Plan
>>> Plan.objects.get(slug='free-trial')
<Plan: Free Trial>
Step 2: Update Backend Registration to Auto-Assign Free Trial
File: backend/igny8_core/auth/serializers.py:276
Current code (lines 280-343): Has issues - no credits, tries to find plan
Replace with:
def create(self, validated_data):
from django.db import transaction
from igny8_core.business.billing.models import CreditTransaction
with transaction.atomic():
# ALWAYS assign Free Trial plan for /signup route
# Ignore plan_id if provided - this route is for free trials only
try:
plan = Plan.objects.get(slug='free-trial', is_active=True)
except Plan.DoesNotExist:
# Fallback to 'free' if free-trial doesn't exist
try:
plan = Plan.objects.get(slug='free', is_active=True)
except Plan.DoesNotExist:
raise serializers.ValidationError({
"plan": "Free trial plan not configured. Please contact support."
})
# Generate account name
account_name = validated_data.get('account_name')
if not account_name:
first_name = validated_data.get('first_name', '')
last_name = validated_data.get('last_name', '')
if first_name or last_name:
account_name = f"{first_name} {last_name}".strip() or \
validated_data['email'].split('@')[0]
else:
account_name = validated_data['email'].split('@')[0]
# Generate username if not provided
username = validated_data.get('username')
if not username:
username = validated_data['email'].split('@')[0]
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Create user first
user = User.objects.create_user(
username=username,
email=validated_data['email'],
password=validated_data['password'],
first_name=validated_data.get('first_name', ''),
last_name=validated_data.get('last_name', ''),
account=None,
role='owner'
)
# Create account with unique slug
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
slug = base_slug
counter = 1
while Account.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
# Get trial credits from plan
trial_credits = plan.get_effective_credits_per_month()
account = Account.objects.create(
name=account_name,
slug=slug,
owner=user,
plan=plan,
credits=trial_credits, # CRITICAL: Seed credits
status='trial' # CRITICAL: Set as trial account
)
# Log initial credit transaction
CreditTransaction.objects.create(
account=account,
transaction_type='subscription',
amount=trial_credits,
balance_after=trial_credits,
description=f'Free trial credits from {plan.name}',
metadata={
'plan_slug': plan.slug,
'registration': True,
'trial': True
}
)
# Link user to account
user.account = account
user.save()
return user
Changes:
- Line 283: Force free-trial plan, ignore plan_id
- Line 352: Set
credits=trial_credits - Line 353: Set
status='trial' - Lines 356-365: Log credit transaction
Step 3: Update Frontend to Remove Plan Selection
File: frontend/src/components/auth/SignUpForm.tsx
Remove lines 29-64 (plan loading logic) Remove lines 257-279 (plan selection dropdown) Remove lines 85-88 (plan validation) Remove line 101 (plan_id from register call)
Replace handleSubmit (lines 71-115):
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
setError("Please fill in all required fields");
return;
}
if (!isChecked) {
setError("Please agree to the Terms and Conditions");
return;
}
try {
// Generate username from email if not provided
const username = formData.username || formData.email.split("@")[0];
// NO plan_id - backend will auto-assign free trial
await register({
email: formData.email,
password: formData.password,
username: username,
first_name: formData.firstName,
last_name: formData.lastName,
account_name: formData.accountName,
});
// Redirect to dashboard/sites instead of payment page
navigate("/sites", { replace: true });
} catch (err: any) {
setError(err.message || "Registration failed. Please try again.");
}
};
Remove state:
// DELETE these lines:
const [plans, setPlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<number | null>(null);
const [plansLoading, setPlansLoading] = useState(true);
Remove useEffect (lines 41-64 - plan loading)
Step 4: Update Auth Store
File: frontend/src/store/authStore.ts:120
No changes needed - it already handles registration without plan_id correctly.
Step 5: Update Middleware to Allow 'trial' Status
File: backend/igny8_core/auth/middleware.py:132
Ensure trial accounts can login - current code should already allow this.
Check validation logic allows status='trial':
# In validate_account_and_plan helper (to be created)
# Allow 'trial' status along with 'active'
if account.status in ['suspended', 'cancelled']:
# Block only suspended/cancelled
# Allow: 'trial', 'active', 'pending_payment'
return (False, f'Account is {account.status}', 403)
Complete Code Changes
Change 1: Update RegisterSerializer
File: backend/igny8_core/auth/serializers.py
Replace lines 276-343 with:
def create(self, validated_data):
from django.db import transaction
from igny8_core.business.billing.models import CreditTransaction
with transaction.atomic():
# ALWAYS assign Free Trial plan for simple signup
# Ignore plan_id parameter - this is for free trial signups only
try:
plan = Plan.objects.get(slug='free-trial', is_active=True)
except Plan.DoesNotExist:
try:
plan = Plan.objects.get(slug='free', is_active=True)
except Plan.DoesNotExist:
raise serializers.ValidationError({
"plan": "Free trial plan not configured. Please contact support."
})
# Generate account name if not provided
account_name = validated_data.get('account_name')
if not account_name:
first_name = validated_data.get('first_name', '')
last_name = validated_data.get('last_name', '')
if first_name or last_name:
account_name = f"{first_name} {last_name}".strip() or \
validated_data['email'].split('@')[0]
else:
account_name = validated_data['email'].split('@')[0]
# Generate username if not provided
username = validated_data.get('username')
if not username:
username = validated_data['email'].split('@')[0]
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Create user first without account
user = User.objects.create_user(
username=username,
email=validated_data['email'],
password=validated_data['password'],
first_name=validated_data.get('first_name', ''),
last_name=validated_data.get('last_name', ''),
account=None,
role='owner'
)
# Generate unique slug for account
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
slug = base_slug
counter = 1
while Account.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
# Get trial credits from plan
trial_credits = plan.get_effective_credits_per_month()
# Create account with trial status and credits
account = Account.objects.create(
name=account_name,
slug=slug,
owner=user,
plan=plan,
credits=trial_credits,
status='trial'
)
# Log initial credit transaction
CreditTransaction.objects.create(
account=account,
transaction_type='subscription',
amount=trial_credits,
balance_after=trial_credits,
description=f'Free trial credits from {plan.name}',
metadata={
'plan_slug': plan.slug,
'registration': True,
'trial': True
}
)
# Update user to reference account
user.account = account
user.save()
return user
Change 2: Simplify SignUpForm
File: frontend/src/components/auth/SignUpForm.tsx
Replace entire component with:
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
import Label from "../form/Label";
import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox";
import { useAuthStore } from "../../store/authStore";
export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
password: "",
username: "",
accountName: "",
});
const [error, setError] = useState("");
const navigate = useNavigate();
const { register, loading } = useAuthStore();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
setError("Please fill in all required fields");
return;
}
if (!isChecked) {
setError("Please agree to the Terms and Conditions");
return;
}
try {
const username = formData.username || formData.email.split("@")[0];
// No plan_id needed - backend auto-assigns free trial
await register({
email: formData.email,
password: formData.password,
username: username,
first_name: formData.firstName,
last_name: formData.lastName,
account_name: formData.accountName,
});
// Redirect to dashboard/sites instead of payment page
navigate("/sites", { replace: true });
} catch (err: any) {
setError(err.message || "Registration failed. Please try again.");
}
};
return (
<div className="flex flex-col flex-1 w-full overflow-y-auto lg:w-1/2 no-scrollbar">
<div className="w-full max-w-md mx-auto mb-5 sm:pt-10">
<Link
to="/"
className="inline-flex items-center text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<ChevronLeftIcon className="size-5" />
Back to dashboard
</Link>
</div>
<div className="flex flex-col justify-center flex-1 w-full max-w-md mx-auto">
<div>
<div className="mb-5 sm:mb-8">
<h1 className="mb-2 font-semibold text-gray-800 text-title-sm dark:text-white/90 sm:text-title-md">
Start Your Free Trial
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
No credit card required. 2,000 AI credits to get started.
</p>
</div>
<div>
<form onSubmit={handleSubmit}>
<div className="space-y-5">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:text-red-400 dark:border-red-800">
{error}
</div>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="sm:col-span-1">
<Label>
First Name<span className="text-error-500">*</span>
</Label>
<Input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter your first name"
required
/>
</div>
<div className="sm:col-span-1">
<Label>
Last Name<span className="text-error-500">*</span>
</Label>
<Input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter your last name"
required
/>
</div>
</div>
<div>
<Label>
Email<span className="text-error-500">*</span>
</Label>
<Input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter your email"
required
/>
</div>
<div>
<Label>Account Name (optional)</Label>
<Input
type="text"
id="accountName"
name="accountName"
value={formData.accountName}
onChange={handleChange}
placeholder="Workspace / Company name"
/>
</div>
<div>
<Label>
Password<span className="text-error-500">*</span>
</Label>
<div className="relative">
<Input
placeholder="Enter your password"
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
<span
onClick={() => setShowPassword(!showPassword)}
className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2"
>
{showPassword ? (
<EyeIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
) : (
<EyeCloseIcon className="fill-gray-500 dark:fill-gray-400 size-5" />
)}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<Checkbox
className="w-5 h-5"
checked={isChecked}
onChange={setIsChecked}
/>
<p className="inline-block font-normal text-gray-500 dark:text-gray-400">
By creating an account means you agree to the{" "}
<span className="text-gray-800 dark:text-white/90">
Terms and Conditions,
</span>{" "}
and our{" "}
<span className="text-gray-800 dark:text-white">
Privacy Policy
</span>
</p>
</div>
<div>
<button
type="submit"
disabled={loading}
className="flex items-center justify-center w-full px-4 py-3 text-sm font-medium text-white transition rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Creating your account..." : "Start Free Trial"}
</button>
</div>
</div>
</form>
<div className="mt-5">
<p className="text-sm font-normal text-center text-gray-700 dark:text-gray-400 sm:text-start">
Already have an account?{" "}
<Link
to="/signin"
className="text-brand-500 hover:text-brand-600 dark:text-brand-400"
>
Sign In
</Link>
</p>
</div>
</div>
</div>
</div>
</div>
);
}
Key changes:
- Removed all plan-related code
- Changed heading to "Start Your Free Trial"
- Added "No credit card required" subtext
- Changed button text to "Start Free Trial"
- Redirect to
/sitesinstead of/account/plans - No plan_id sent to backend
Verification Steps
1. Create Free Trial Plan
python manage.py shell
>>> from igny8_core.auth.models import Plan
>>> Plan.objects.create(
slug='free-trial',
name='Free Trial',
price=0.00,
billing_cycle='monthly',
included_credits=2000,
max_sites=1,
max_users=1,
max_industries=3,
is_active=True
)
>>> exit()
2. Test Registration Flow
# Visit https://app.igny8.com/signup
# Fill form: name, email, password
# Submit
# Should:
# 1. Create account with status='trial'
# 2. Set credits=2000
# 3. Redirect to /sites
# 4. User can immediately use app
3. Verify Database
python manage.py shell
>>> from igny8_core.auth.models import User
>>> u = User.objects.get(email='test@example.com')
>>> u.account.status
'trial'
>>> u.account.credits
2000
>>> u.account.plan.slug
'free-trial'
Summary of Changes
| File | Action | Lines |
|---|---|---|
| Database | Add free-trial plan | Create via shell/migration |
auth/serializers.py |
Force free-trial plan, seed credits | 276-343 (68 lines) |
auth/SignUpForm.tsx |
Remove plan selection, simplify | 29-279 (removed ~80 lines) |
Result: Clean, simple free trial signup with zero payment friction.
Before vs After
Before (Messy)
User → Signup page
→ Must select plan
→ Submit with plan_id
→ Account created with 0 credits
→ Redirect to /account/plans (payment)
→ Confused user
After (Clean)
User → Signup page
→ Fill name, email, password
→ Submit (no plan selection)
→ Account created with:
- status='trial'
- plan='free-trial'
- credits=2000
→ Redirect to /sites
→ User starts using app immediately
Next: Paid Plans (Future)
For users who want paid plans:
- Create separate
/pricingpage - After selecting paid plan, route to
/signup?plan=growth - Backend checks query param and assigns that plan instead
- OR keep /signup as free trial only and create
/subscribefor paid
For now: /signup = 100% free trial, zero friction.