708 lines
22 KiB
Markdown
708 lines
22 KiB
Markdown
# Free Trial Signup Flow - Complete Fix
|
|
## Problem: Complex signup with plan selection; Need: Simple free trial
|
|
|
|
---
|
|
|
|
## Current Flow Analysis
|
|
|
|
### Frontend ([`SignUpForm.tsx`](frontend/src/components/auth/SignUpForm.tsx))
|
|
**Problems:**
|
|
1. Lines 29-64: Loads all plans from API
|
|
2. Lines 257-279: Shows plan selection dropdown (required)
|
|
3. Line 85-88: Validates plan is selected
|
|
4. Line 101: Passes `plan_id` to register
|
|
5. Line 105: Redirects to `/account/plans` after signup (payment/plan page)
|
|
|
|
### Backend ([`RegisterSerializer`](backend/igny8_core/auth/serializers.py:276))
|
|
**Problems:**
|
|
1. Line 282-290: If no plan_id, tries to find 'free' plan or cheapest
|
|
2. Line 332-337: Creates account but **NO credit seeding**
|
|
3. No default status='trial' set
|
|
4. 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:
|
|
```python
|
|
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:**
|
|
```bash
|
|
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`](backend/igny8_core/auth/serializers.py:276)
|
|
|
|
**Current code (lines 280-343):** Has issues - no credits, tries to find plan
|
|
|
|
**Replace with:**
|
|
```python
|
|
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`](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):**
|
|
```typescript
|
|
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:**
|
|
```typescript
|
|
// 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`](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`](backend/igny8_core/auth/middleware.py:132)
|
|
|
|
Ensure trial accounts can login - current code should already allow this.
|
|
|
|
Check validation logic allows status='trial':
|
|
```python
|
|
# 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:
|
|
```python
|
|
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:
|
|
```typescript
|
|
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 `/sites` instead of `/account/plans`
|
|
- No plan_id sent to backend
|
|
|
|
---
|
|
|
|
## Verification Steps
|
|
|
|
### 1. Create Free Trial Plan
|
|
```bash
|
|
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
|
|
```bash
|
|
# 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
|
|
```bash
|
|
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 `/pricing` page
|
|
- 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 `/subscribe` for paid
|
|
|
|
**For now: /signup = 100% free trial, zero friction.** |