From 8231c499c27aaaec1f945dec3ffac4053faebaa6 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 8 Dec 2025 08:52:44 +0000 Subject: [PATCH] dasdas --- backend/igny8_core/auth/serializers.py | 39 +++++++++++ .../src/components/auth/ProtectedRoute.tsx | 63 +++-------------- frontend/src/components/auth/SignUpForm.tsx | 67 ++++++++++++++---- frontend/src/marketing/pages/Pricing.tsx | 4 +- .../src/pages/AuthPages/AuthPageLayout.tsx | 70 +++++++++++++++---- frontend/src/pages/AuthPages/SignUp.tsx | 38 +++++++++- frontend/src/pages/Payment.tsx | 22 ++++-- frontend/src/store/authStore.ts | 2 + 8 files changed, 215 insertions(+), 90 deletions(-) diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 17baf0f6..375e36f9 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -286,6 +286,11 @@ class RegisterSerializer(serializers.Serializer): def create(self, validated_data): from django.db import transaction 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(): plan_slug = validated_data.get('plan_slug') @@ -300,6 +305,9 @@ class RegisterSerializer(serializers.Serializer): }) account_status = 'pending_payment' 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: try: plan = Plan.objects.get(slug='free', is_active=True) @@ -309,6 +317,8 @@ class RegisterSerializer(serializers.Serializer): }) account_status = 'trial' initial_credits = plan.get_effective_credits_per_month() + billing_period_start = None + billing_period_end = None # Generate account name if not provided account_name = validated_data.get('account_name') @@ -380,6 +390,35 @@ class RegisterSerializer(serializers.Serializer): # Update user to reference the new account user.account = account 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 diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index f55f851d..4d0759a5 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -3,7 +3,6 @@ import { Navigate, useLocation } from "react-router-dom"; import { useAuthStore } from "../../store/authStore"; import { useErrorHandler } from "../../hooks/useErrorHandler"; import { trackLoading } from "../common/LoadingStateMonitor"; -import { fetchAPI } from "../../services/api"; interface ProtectedRouteProps { children: ReactNode; @@ -19,12 +18,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { const { addError } = useErrorHandler('ProtectedRoute'); const [showError, setShowError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); - const [paymentCheck, setPaymentCheck] = useState<{ - loading: boolean; - hasDefault: boolean; - hasAny: boolean; - }>({ loading: true, hasDefault: false, hasAny: false }); - const PLAN_ALLOWED_PATHS = [ '/account/plans', '/account/billing', @@ -33,6 +26,7 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { '/account/team', '/account/usage', '/billing', + '/payment', ]; const isPlanAllowedPath = PLAN_ALLOWED_PATHS.some((prefix) => @@ -44,40 +38,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { trackLoading('auth-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 useEffect(() => { if (!isAuthenticated) { @@ -161,21 +121,16 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { // If authenticated but missing an active plan, keep user inside billing/onboarding const accountStatus = user?.account?.status; const accountInactive = accountStatus && ['suspended', 'cancelled'].includes(accountStatus); - const missingPlan = user?.account && !user.account.plan; - const missingPayment = !paymentCheck.loading && (!paymentCheck.hasDefault || !paymentCheck.hasAny); + const pendingPayment = accountStatus === 'pending_payment'; + const isPrivileged = user?.role === 'developer' || user?.is_superuser; - if ((missingPlan || accountInactive || missingPayment) && !isPlanAllowedPath) { - if (paymentCheck.loading) { - return ( -
-
-
-

Checking billing status...

-
-
- ); + if (!isPrivileged) { + if (pendingPayment && !isPlanAllowedPath) { + return ; + } + if (accountInactive && !isPlanAllowedPath) { + return ; } - return ; } return <>{children}; diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index b16c5021..e3e16b41 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; import Label from "../form/Label"; @@ -6,7 +6,7 @@ import Input from "../form/input/InputField"; import Checkbox from "../form/input/Checkbox"; 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 [isChecked, setIsChecked] = useState(false); const [formData, setFormData] = useState({ @@ -18,17 +18,52 @@ export default function SignUpForm() { accountName: "", }); const [error, setError] = useState(""); + const [planDetails, setPlanDetails] = useState(planDetailsProp || null); + const [planLoading, setPlanLoading] = useState(planLoadingProp || false); + const [planError, setPlanError] = useState(""); const navigate = useNavigate(); const { register, loading } = useAuthStore(); + const planSlug = useMemo(() => new URLSearchParams(window.location.search).get("plan") || "", []); + const paidPlans = ["starter", "growth", "scale"]; useEffect(() => { - const params = new URLSearchParams(window.location.search); - const planSlug = params.get("plan"); - const paidPlans = ["starter", "growth", "scale"]; 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) => { const { name, value } = e.target; @@ -53,18 +88,22 @@ export default function SignUpForm() { // Generate username from email if not provided const username = formData.username || formData.email.split("@")[0]; - // No plan_id needed - backend auto-assigns free trial - await register({ + const user = await register({ email: formData.email, password: formData.password, username: username, first_name: formData.firstName, last_name: formData.lastName, account_name: formData.accountName, + plan_slug: planSlug || undefined, }); - - // Redirect to dashboard/sites instead of payment page - navigate("/sites", { replace: true }); + + const status = user?.account?.status; + if (status === "pending_payment") { + navigate("/account/billing", { replace: true }); + } else { + navigate("/sites", { replace: true }); + } } catch (err: any) { setError(err.message || "Registration failed. Please try again."); } @@ -88,7 +127,9 @@ export default function SignUpForm() { Start Your Free Trial

- 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."}

diff --git a/frontend/src/marketing/pages/Pricing.tsx b/frontend/src/marketing/pages/Pricing.tsx index df5c1a4e..820efd16 100644 --- a/frontend/src/marketing/pages/Pricing.tsx +++ b/frontend/src/marketing/pages/Pricing.tsx @@ -308,14 +308,14 @@ const Pricing: React.FC = () => { {/* CTA Button */}
diff --git a/frontend/src/pages/AuthPages/AuthPageLayout.tsx b/frontend/src/pages/AuthPages/AuthPageLayout.tsx index 1acfed55..aace0424 100644 --- a/frontend/src/pages/AuthPages/AuthPageLayout.tsx +++ b/frontend/src/pages/AuthPages/AuthPageLayout.tsx @@ -3,10 +3,22 @@ import GridShape from "../../components/common/GridShape"; import { Link } from "react-router-dom"; 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({ children, + plan, }: { children: React.ReactNode; + plan?: PlanPreview | null; }) { return (
@@ -16,18 +28,52 @@ export default function AuthLayout({
{/* */} -
- - Logo - -

- Free and Open-Source Tailwind CSS Admin Dashboard Template -

+
+
+ + Logo + +

+ Free and Open-Source Tailwind CSS Admin Dashboard Template +

+
+ {plan && ( +
+
+
{plan.name}
+ {plan.price !== undefined && ( +
+ ${Number(plan.price).toFixed(2)} + {plan.billing_cycle ? `/ ${plan.billing_cycle}` : ""} +
+ )} +
+
+ {plan.included_credits !== undefined && ( +
Credits: {plan.included_credits}
+ )} + {(plan.max_sites !== undefined || plan.max_users !== undefined) && ( +
+ {plan.max_sites !== undefined ? `Sites: ${plan.max_sites}` : ""} + {plan.max_sites !== undefined && plan.max_users !== undefined ? " • " : ""} + {plan.max_users !== undefined ? `Users: ${plan.max_users}` : ""} +
+ )} +
+ {Array.isArray(plan.features) && plan.features.length > 0 && ( +
    + {plan.features.slice(0, 7).map((f) => ( +
  • {f}
  • + ))} +
+ )} +
+ )}
diff --git a/frontend/src/pages/AuthPages/SignUp.tsx b/frontend/src/pages/AuthPages/SignUp.tsx index edcc20be..1f2decfe 100644 --- a/frontend/src/pages/AuthPages/SignUp.tsx +++ b/frontend/src/pages/AuthPages/SignUp.tsx @@ -1,16 +1,50 @@ +import { useEffect, useMemo, useState } from "react"; import PageMeta from "../../components/common/PageMeta"; import AuthLayout from "./AuthPageLayout"; import SignUpForm from "../../components/auth/SignUpForm"; export default function SignUp() { + const planSlug = useMemo(() => { + const params = new URLSearchParams(window.location.search); + return params.get("plan") || ""; + }, []); + + const [planDetails, setPlanDetails] = useState(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 ( <> - - + + ); diff --git a/frontend/src/pages/Payment.tsx b/frontend/src/pages/Payment.tsx index c647c34a..fb151eca 100644 --- a/frontend/src/pages/Payment.tsx +++ b/frontend/src/pages/Payment.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate, Link } from "react-router-dom"; +import { useAuthStore } from "../store/authStore"; const PLAN_COPY: Record = { starter: { name: "Starter", price: "$89/mo", credits: "1,000 credits/month" }, @@ -10,24 +11,31 @@ const PLAN_COPY: Record s.user); const [contactEmail, setContactEmail] = useState(""); const [note, setNote] = useState(""); const [error, setError] = useState(""); 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(() => { if (!plan || !contactEmail.trim()) return ""; - const subject = encodeURIComponent(`Subscribe to ${plan.name}`); - const body = encodeURIComponent(`Plan: ${plan.name}\nEmail: ${contactEmail}\nNotes: ${note || "-"}`); + const subject = encodeURIComponent(`Payment submitted for ${plan.name}`); + 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}`; - }, [plan, contactEmail, note]); + }, [plan, contactEmail, note, user?.account?.slug, user?.account?.id]); useEffect(() => { - if (!plan) { + if (!plan || !user) { navigate("/pricing", { replace: true }); } - }, [plan, navigate]); + }, [plan, navigate, user]); const handleRequest = (e: React.MouseEvent) => { if (!plan) { @@ -62,7 +70,7 @@ export default function Payment() {

{plan.price}

{plan.credits}

- 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.

)} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 724bb496..d690821a 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -134,6 +134,7 @@ export const useAuthStore = create()( body: JSON.stringify({ ...registerData, password_confirm: registerData.password, // Add password_confirm + plan_slug: registerData.plan_slug, }), }); @@ -161,6 +162,7 @@ export const useAuthStore = create()( isAuthenticated: true, loading: false }); + return userData; } catch (error: any) { // ALWAYS reset loading on error - critical to prevent stuck state set({ loading: false });