This commit is contained in:
IGNY8 VPS (Salman)
2025-12-08 08:52:44 +00:00
parent 3f2879d269
commit 8231c499c2
8 changed files with 215 additions and 90 deletions

View File

@@ -286,6 +286,11 @@ class RegisterSerializer(serializers.Serializer):
def create(self, validated_data): def create(self, validated_data):
from django.db import transaction from django.db import transaction
from igny8_core.business.billing.models import CreditTransaction from igny8_core.business.billing.models import CreditTransaction
from igny8_core.business.billing.models import Subscription
from igny8_core.business.billing.models import AccountPaymentMethod
from igny8_core.business.billing.services.invoice_service import InvoiceService
from django.utils import timezone
from datetime import timedelta
with transaction.atomic(): with transaction.atomic():
plan_slug = validated_data.get('plan_slug') plan_slug = validated_data.get('plan_slug')
@@ -300,6 +305,9 @@ class RegisterSerializer(serializers.Serializer):
}) })
account_status = 'pending_payment' account_status = 'pending_payment'
initial_credits = 0 initial_credits = 0
billing_period_start = timezone.now()
# simple monthly cycle; if annual needed, extend here
billing_period_end = billing_period_start + timedelta(days=30)
else: else:
try: try:
plan = Plan.objects.get(slug='free', is_active=True) plan = Plan.objects.get(slug='free', is_active=True)
@@ -309,6 +317,8 @@ class RegisterSerializer(serializers.Serializer):
}) })
account_status = 'trial' account_status = 'trial'
initial_credits = plan.get_effective_credits_per_month() initial_credits = plan.get_effective_credits_per_month()
billing_period_start = None
billing_period_end = None
# Generate account name if not provided # Generate account name if not provided
account_name = validated_data.get('account_name') account_name = validated_data.get('account_name')
@@ -381,6 +391,35 @@ class RegisterSerializer(serializers.Serializer):
user.account = account user.account = account
user.save() user.save()
# For paid plans, create subscription, invoice, and default bank transfer method
if plan_slug and plan_slug in paid_plans:
subscription = Subscription.objects.create(
account=account,
plan=plan,
status='pending_payment',
payment_method='bank_transfer',
external_payment_id=None,
current_period_start=billing_period_start,
current_period_end=billing_period_end,
cancel_at_period_end=False,
)
# Create pending invoice for the first period
InvoiceService.create_subscription_invoice(
subscription=subscription,
billing_period_start=billing_period_start,
billing_period_end=billing_period_end,
)
# Seed a default bank transfer payment method for the account
AccountPaymentMethod.objects.create(
account=account,
type='bank_transfer',
display_name='Bank Transfer (Manual)',
is_default=True,
is_enabled=True,
is_verified=False,
instructions='Please complete bank transfer and add your reference in Payments.',
)
return user return user

View File

@@ -3,7 +3,6 @@ import { Navigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../../store/authStore"; import { useAuthStore } from "../../store/authStore";
import { useErrorHandler } from "../../hooks/useErrorHandler"; import { useErrorHandler } from "../../hooks/useErrorHandler";
import { trackLoading } from "../common/LoadingStateMonitor"; import { trackLoading } from "../common/LoadingStateMonitor";
import { fetchAPI } from "../../services/api";
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: ReactNode; children: ReactNode;
@@ -19,12 +18,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { addError } = useErrorHandler('ProtectedRoute'); const { addError } = useErrorHandler('ProtectedRoute');
const [showError, setShowError] = useState(false); const [showError, setShowError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [paymentCheck, setPaymentCheck] = useState<{
loading: boolean;
hasDefault: boolean;
hasAny: boolean;
}>({ loading: true, hasDefault: false, hasAny: false });
const PLAN_ALLOWED_PATHS = [ const PLAN_ALLOWED_PATHS = [
'/account/plans', '/account/plans',
'/account/billing', '/account/billing',
@@ -33,6 +26,7 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
'/account/team', '/account/team',
'/account/usage', '/account/usage',
'/billing', '/billing',
'/payment',
]; ];
const isPlanAllowedPath = PLAN_ALLOWED_PATHS.some((prefix) => const isPlanAllowedPath = PLAN_ALLOWED_PATHS.some((prefix) =>
@@ -44,40 +38,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
trackLoading('auth-loading', loading); trackLoading('auth-loading', loading);
}, [loading]); }, [loading]);
// Fetch payment methods to confirm default method availability
useEffect(() => {
if (!isAuthenticated) {
setPaymentCheck({ loading: false, hasDefault: false, hasAny: false });
return;
}
let cancelled = false;
const loadPaymentMethods = async () => {
setPaymentCheck((prev) => ({ ...prev, loading: true }));
try {
const data = await fetchAPI('/v1/billing/payment-methods/');
const methods = data?.results || [];
const hasAny = methods.length > 0;
// Treat id 14 as the intended default, or any method marked default
const hasDefault = methods.some((m: any) => m.is_default) || methods.some((m: any) => String(m.id) === '14');
if (!cancelled) {
setPaymentCheck({ loading: false, hasDefault, hasAny });
}
} catch (err) {
if (!cancelled) {
setPaymentCheck({ loading: false, hasDefault: false, hasAny: false });
console.warn('ProtectedRoute: failed to fetch payment methods', err);
}
}
};
loadPaymentMethods();
return () => {
cancelled = true;
};
}, [isAuthenticated]);
// Validate account + plan whenever auth/user changes // Validate account + plan whenever auth/user changes
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
@@ -161,21 +121,16 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
// If authenticated but missing an active plan, keep user inside billing/onboarding // If authenticated but missing an active plan, keep user inside billing/onboarding
const accountStatus = user?.account?.status; const accountStatus = user?.account?.status;
const accountInactive = accountStatus && ['suspended', 'cancelled'].includes(accountStatus); const accountInactive = accountStatus && ['suspended', 'cancelled'].includes(accountStatus);
const missingPlan = user?.account && !user.account.plan; const pendingPayment = accountStatus === 'pending_payment';
const missingPayment = !paymentCheck.loading && (!paymentCheck.hasDefault || !paymentCheck.hasAny); const isPrivileged = user?.role === 'developer' || user?.is_superuser;
if ((missingPlan || accountInactive || missingPayment) && !isPlanAllowedPath) { if (!isPrivileged) {
if (paymentCheck.loading) { if (pendingPayment && !isPlanAllowedPath) {
return ( return <Navigate to="/account/billing" state={{ from: location }} replace />;
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900"> }
<div className="text-center max-w-md px-4"> if (accountInactive && !isPlanAllowedPath) {
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div> return <Navigate to="/account/plans" state={{ from: location }} replace />;
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Checking billing status...</p>
</div>
</div>
);
} }
return <Navigate to="/account/plans" state={{ from: location }} replace />;
} }
return <>{children}</>; return <>{children}</>;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
import Label from "../form/Label"; import Label from "../form/Label";
@@ -6,7 +6,7 @@ import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox"; import Checkbox from "../form/input/Checkbox";
import { useAuthStore } from "../../store/authStore"; import { useAuthStore } from "../../store/authStore";
export default function SignUpForm() { export default function SignUpForm({ planDetails: planDetailsProp, planLoading: planLoadingProp }: { planDetails?: any; planLoading?: boolean }) {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -18,17 +18,52 @@ export default function SignUpForm() {
accountName: "", accountName: "",
}); });
const [error, setError] = useState(""); const [error, setError] = useState("");
const [planDetails, setPlanDetails] = useState<any | null>(planDetailsProp || null);
const [planLoading, setPlanLoading] = useState(planLoadingProp || false);
const [planError, setPlanError] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { register, loading } = useAuthStore(); const { register, loading } = useAuthStore();
const planSlug = useMemo(() => new URLSearchParams(window.location.search).get("plan") || "", []);
const paidPlans = ["starter", "growth", "scale"];
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search);
const planSlug = params.get("plan");
const paidPlans = ["starter", "growth", "scale"];
if (planSlug && paidPlans.includes(planSlug)) { if (planSlug && paidPlans.includes(planSlug)) {
navigate(`/payment?plan=${planSlug}`, { replace: true }); setError("");
} }
}, [navigate]); }, [planSlug]);
useEffect(() => {
if (planDetailsProp) {
setPlanDetails(planDetailsProp);
setPlanLoading(!!planLoadingProp);
setPlanError("");
return;
}
const fetchPlan = async () => {
if (!planSlug) return;
setPlanLoading(true);
setPlanError("");
try {
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api";
const res = await fetch(`${API_BASE_URL}/v1/auth/plans/?slug=${planSlug}`);
const data = await res.json();
const plan = data?.results?.[0];
if (!plan) {
setPlanError("Plan not found or inactive.");
} else {
const features = Array.isArray(plan.features)
? plan.features.map((f: string) => f.charAt(0).toUpperCase() + f.slice(1))
: [];
setPlanDetails({ ...plan, features });
}
} catch (e: any) {
setPlanError("Unable to load plan details right now.");
} finally {
setPlanLoading(false);
}
};
fetchPlan();
}, [planSlug, planDetailsProp, planLoadingProp]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@@ -53,18 +88,22 @@ export default function SignUpForm() {
// Generate username from email if not provided // Generate username from email if not provided
const username = formData.username || formData.email.split("@")[0]; const username = formData.username || formData.email.split("@")[0];
// No plan_id needed - backend auto-assigns free trial const user = await register({
await register({
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
username: username, username: username,
first_name: formData.firstName, first_name: formData.firstName,
last_name: formData.lastName, last_name: formData.lastName,
account_name: formData.accountName, account_name: formData.accountName,
plan_slug: planSlug || undefined,
}); });
// Redirect to dashboard/sites instead of payment page const status = user?.account?.status;
navigate("/sites", { replace: true }); if (status === "pending_payment") {
navigate("/account/billing", { replace: true });
} else {
navigate("/sites", { replace: true });
}
} catch (err: any) { } catch (err: any) {
setError(err.message || "Registration failed. Please try again."); setError(err.message || "Registration failed. Please try again.");
} }
@@ -88,7 +127,9 @@ export default function SignUpForm() {
Start Your Free Trial Start Your Free Trial
</h1> </h1>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
No credit card required. 100 AI credits to get started. {planSlug && paidPlans.includes(planSlug)
? `You're signing up for the ${planSlug} plan. You'll be taken to billing to complete payment.`
: "No credit card required. 100 AI credits to get started."}
</p> </p>
</div> </div>
<div> <div>

View File

@@ -308,14 +308,14 @@ const Pricing: React.FC = () => {
{/* CTA Button */} {/* CTA Button */}
<div className="pt-4"> <div className="pt-4">
<a <a
href={`https://app.igny8.com/payment?plan=${tier.slug}`} href={`https://app.igny8.com/signup?plan=${tier.slug}`}
className={`inline-flex w-full items-center justify-center rounded-full px-6 py-3 text-sm font-semibold transition ${ className={`inline-flex w-full items-center justify-center rounded-full px-6 py-3 text-sm font-semibold transition ${
tier.featured tier.featured
? "bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white hover:from-[#0472b8] hover:to-[#0693e3] shadow-lg shadow-[#0693e3]/30" ? "bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white hover:from-[#0472b8] hover:to-[#0693e3] shadow-lg shadow-[#0693e3]/30"
: "border-2 border-slate-300 bg-white/50 backdrop-blur-sm text-slate-900 hover:border-[var(--color-primary)] hover:bg-white" : "border-2 border-slate-300 bg-white/50 backdrop-blur-sm text-slate-900 hover:border-[var(--color-primary)] hover:bg-white"
}`} }`}
> >
{tier.price === "Free" ? "Start free trial" : `Get ${tier.name} - ${tier.price}/mo`} {tier.price === "Free" ? "Start free trial" : `Continue to billing`}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -3,10 +3,22 @@ import GridShape from "../../components/common/GridShape";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import ThemeTogglerTwo from "../../components/common/ThemeTogglerTwo"; import ThemeTogglerTwo from "../../components/common/ThemeTogglerTwo";
interface PlanPreview {
name?: string;
price?: number | string;
billing_cycle?: string;
included_credits?: number;
max_sites?: number;
max_users?: number;
features?: string[];
}
export default function AuthLayout({ export default function AuthLayout({
children, children,
plan,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
plan?: PlanPreview | null;
}) { }) {
return ( return (
<div className="relative p-6 bg-white z-1 dark:bg-gray-900 sm:p-0"> <div className="relative p-6 bg-white z-1 dark:bg-gray-900 sm:p-0">
@@ -16,18 +28,52 @@ export default function AuthLayout({
<div className="relative flex items-center justify-center z-1"> <div className="relative flex items-center justify-center z-1">
{/* <!-- ===== Common Grid Shape Start ===== --> */} {/* <!-- ===== Common Grid Shape Start ===== --> */}
<GridShape /> <GridShape />
<div className="flex flex-col items-center max-w-xs"> <div className="flex flex-col items-center max-w-sm w-full gap-6 px-4">
<Link to="/" className="block mb-4"> <div className="flex flex-col items-center">
<img <Link to="/" className="block mb-4">
width={231} <img
height={48} width={231}
src="/images/logo/auth-logo.svg" height={48}
alt="Logo" src="/images/logo/auth-logo.svg"
/> alt="Logo"
</Link> />
<p className="text-center text-gray-400 dark:text-white/60"> </Link>
Free and Open-Source Tailwind CSS Admin Dashboard Template <p className="text-center text-gray-400 dark:text-white/60">
</p> Free and Open-Source Tailwind CSS Admin Dashboard Template
</p>
</div>
{plan && (
<div className="w-full rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm p-5 text-white shadow-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-lg font-semibold">{plan.name}</div>
{plan.price !== undefined && (
<div className="text-sm font-medium">
${Number(plan.price).toFixed(2)}
{plan.billing_cycle ? `/ ${plan.billing_cycle}` : ""}
</div>
)}
</div>
<div className="text-xs text-white/80 space-y-1">
{plan.included_credits !== undefined && (
<div>Credits: {plan.included_credits}</div>
)}
{(plan.max_sites !== undefined || plan.max_users !== undefined) && (
<div>
{plan.max_sites !== undefined ? `Sites: ${plan.max_sites}` : ""}
{plan.max_sites !== undefined && plan.max_users !== undefined ? " • " : ""}
{plan.max_users !== undefined ? `Users: ${plan.max_users}` : ""}
</div>
)}
</div>
{Array.isArray(plan.features) && plan.features.length > 0 && (
<ul className="mt-3 space-y-1 text-xs text-white/85 list-disc list-inside">
{plan.features.slice(0, 7).map((f) => (
<li key={f}>{f}</li>
))}
</ul>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,50 @@
import { useEffect, useMemo, useState } from "react";
import PageMeta from "../../components/common/PageMeta"; import PageMeta from "../../components/common/PageMeta";
import AuthLayout from "./AuthPageLayout"; import AuthLayout from "./AuthPageLayout";
import SignUpForm from "../../components/auth/SignUpForm"; import SignUpForm from "../../components/auth/SignUpForm";
export default function SignUp() { export default function SignUp() {
const planSlug = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("plan") || "";
}, []);
const [planDetails, setPlanDetails] = useState<any | null>(null);
const [planLoading, setPlanLoading] = useState(false);
useEffect(() => {
const fetchPlans = async () => {
if (!planSlug) return;
setPlanLoading(true);
try {
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api";
const res = await fetch(`${API_BASE_URL}/v1/auth/plans/`);
const data = await res.json();
const plans = data?.results || [];
const plan = plans.find((p: any) => p.slug === planSlug);
if (plan) {
const features = Array.isArray(plan.features)
? plan.features.map((f: string) => f.charAt(0).toUpperCase() + f.slice(1))
: [];
setPlanDetails({ ...plan, features });
}
} catch (e) {
// ignore; SignUpForm will handle lack of plan data gracefully
} finally {
setPlanLoading(false);
}
};
fetchPlans();
}, [planSlug]);
return ( return (
<> <>
<PageMeta <PageMeta
title="React.js SignUp Dashboard | TailAdmin - Next.js Admin Dashboard Template" title="React.js SignUp Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js SignUp Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template" description="This is React.js SignUp Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/> />
<AuthLayout> <AuthLayout plan={planDetails}>
<SignUpForm /> <SignUpForm planDetails={planDetails} planLoading={planLoading} />
</AuthLayout> </AuthLayout>
</> </>
); );

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, Link } from "react-router-dom"; import { useLocation, useNavigate, Link } from "react-router-dom";
import { useAuthStore } from "../store/authStore";
const PLAN_COPY: Record<string, { name: string; price: string; credits: string }> = { const PLAN_COPY: Record<string, { name: string; price: string; credits: string }> = {
starter: { name: "Starter", price: "$89/mo", credits: "1,000 credits/month" }, starter: { name: "Starter", price: "$89/mo", credits: "1,000 credits/month" },
@@ -10,24 +11,31 @@ const PLAN_COPY: Record<string, { name: string; price: string; credits: string }
export default function Payment() { export default function Payment() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const [contactEmail, setContactEmail] = useState(""); const [contactEmail, setContactEmail] = useState("");
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const planSlug = useMemo(() => new URLSearchParams(location.search).get("plan") || "", [location.search]); const planSlug = useMemo(() => new URLSearchParams(location.search).get("plan") || "", [location.search]);
const plan = planSlug ? PLAN_COPY[planSlug] : null; const plan = useMemo(() => {
const slugFromAccount = user?.account?.plan?.slug;
const slug = planSlug || slugFromAccount || "";
return slug ? PLAN_COPY[slug] : null;
}, [planSlug, user?.account?.plan?.slug]);
const mailtoHref = useMemo(() => { const mailtoHref = useMemo(() => {
if (!plan || !contactEmail.trim()) return ""; if (!plan || !contactEmail.trim()) return "";
const subject = encodeURIComponent(`Subscribe to ${plan.name}`); const subject = encodeURIComponent(`Payment submitted for ${plan.name}`);
const body = encodeURIComponent(`Plan: ${plan.name}\nEmail: ${contactEmail}\nNotes: ${note || "-"}`); const body = encodeURIComponent(
`Plan: ${plan.name}\nAccount: ${user?.account?.slug || user?.account?.id || "-"}\nEmail: ${contactEmail}\nNotes/Reference: ${note || "-"}`
);
return `mailto:sales@igny8.com?subject=${subject}&body=${body}`; return `mailto:sales@igny8.com?subject=${subject}&body=${body}`;
}, [plan, contactEmail, note]); }, [plan, contactEmail, note, user?.account?.slug, user?.account?.id]);
useEffect(() => { useEffect(() => {
if (!plan) { if (!plan || !user) {
navigate("/pricing", { replace: true }); navigate("/pricing", { replace: true });
} }
}, [plan, navigate]); }, [plan, navigate, user]);
const handleRequest = (e: React.MouseEvent<HTMLAnchorElement>) => { const handleRequest = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!plan) { if (!plan) {
@@ -62,7 +70,7 @@ export default function Payment() {
<p className="text-slate-700">{plan.price}</p> <p className="text-slate-700">{plan.price}</p>
<p className="text-sm text-slate-600">{plan.credits}</p> <p className="text-sm text-slate-600">{plan.credits}</p>
<p className="text-xs text-amber-700 mt-2"> <p className="text-xs text-amber-700 mt-2">
Payment is completed offline (bank transfer). Submit your email below and we will send payment instructions. Payment is completed offline (bank transfer). Submit your email and reference below; we will verify and activate your account.
</p> </p>
</div> </div>
)} )}

View File

@@ -134,6 +134,7 @@ export const useAuthStore = create<AuthState>()(
body: JSON.stringify({ body: JSON.stringify({
...registerData, ...registerData,
password_confirm: registerData.password, // Add password_confirm password_confirm: registerData.password, // Add password_confirm
plan_slug: registerData.plan_slug,
}), }),
}); });
@@ -161,6 +162,7 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
loading: false loading: false
}); });
return userData;
} catch (error: any) { } catch (error: any) {
// ALWAYS reset loading on error - critical to prevent stuck state // ALWAYS reset loading on error - critical to prevent stuck state
set({ loading: false }); set({ loading: false });