Files
koin_ping_0.2.0/frontend/src/pages/subscribe/Subscribe.jsx
2026-03-29 16:05:47 -04:00

340 lines
10 KiB
JavaScript

import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { useUserProperties } from "../../contexts/UserPropertiesContext";
import {
createOnboardingCheckout,
verifyCheckoutSession,
activateFreeTier,
} from "../../api/stripe";
import Input from "../../components/Input";
import Button from "../../components/Button";
import TierPicker from "../../components/TierPicker";
import "./Subscribe.css";
const STEPS = ["Create Account", "Choose Plan"];
export default function Subscribe() {
const { currentUser, signup } = useAuth();
const { state: userProps, dispatch, ACTION_TYPES } = useUserProperties();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const hasPaymentReturn = searchParams.get("payment") === "success";
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState("");
const [data, setData] = useState({
selectedTier: "",
email: "",
password: "",
confirmPassword: "",
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
// Handle Stripe payment returns
useEffect(() => {
const payment = searchParams.get("payment");
const sessionId = searchParams.get("session_id");
const signupLockKey = sessionId
? `kp_onboard_signup_started_${sessionId}`
: null;
if (payment === "cancelled") {
if (signupLockKey) sessionStorage.removeItem(signupLockKey);
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_pw");
setSearchParams({}, { replace: true });
setStep(2);
setError("Payment was cancelled. Please try again.");
return;
}
if (payment !== "success" || !sessionId) return;
// Phase 1: no account yet — create it. Auth state change remounts the
// component (App.jsx swaps route trees) and Phase 2 runs on the next mount.
if (!currentUser) {
const savedEmail = sessionStorage.getItem("kp_onboard_email");
const savedPw = sessionStorage.getItem("kp_onboard_pw");
if (!savedEmail || !savedPw) {
if (signupLockKey) sessionStorage.removeItem(signupLockKey);
setSearchParams({}, { replace: true });
setError("Session expired. Please start the signup process again.");
setStep(1);
setLoading(false);
return;
}
// Guard against duplicate signup attempts caused by repeated effects.
if (signupLockKey && sessionStorage.getItem(signupLockKey) === "1") {
setLoading(true);
return;
}
setLoading(true);
if (signupLockKey) sessionStorage.setItem(signupLockKey, "1");
signup(savedEmail, savedPw).catch((err) => {
if (signupLockKey) sessionStorage.removeItem(signupLockKey);
setSearchParams({}, { replace: true });
setError("Account creation failed: " + err.message);
setStep(1);
setLoading(false);
});
return;
}
// Phase 2: authenticated — verify the checkout and go to the dashboard.
setSearchParams({}, { replace: true });
setLoading(true);
dispatch({ type: ACTION_TYPES.CLEAR_USER_PROPERTIES });
verifyCheckoutSession(sessionId)
.then(() => {
navigate("/addresses", { replace: true });
})
.catch((err) => {
setError("Payment verification failed: " + err.message);
setStep(2);
})
.finally(() => setLoading(false));
}, [currentUser, searchParams, setSearchParams, signup, navigate, userProps, dispatch, ACTION_TYPES]);
// ── Step handlers ─────────────────────────────────────────────────────────
function handleStep1() {
setError("");
if (!data.email || !data.password || !data.confirmPassword) {
setError("Please fill in all fields");
return;
}
if (data.password !== data.confirmPassword) {
setError("Passwords do not match");
return;
}
if (data.password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setStep(2);
}
async function handleStep2() {
setError("");
if (!data.selectedTier) {
setError("Please select a plan to continue");
return;
}
try {
setLoading(true);
if (data.selectedTier === "free") {
if (!currentUser) {
await signup(data.email, data.password);
}
await activateFreeTier();
navigate("/addresses", { replace: true });
return;
}
dispatch({
type: ACTION_TYPES.SET_USER_PROPERTIES,
payload: { email: data.email, password: data.password },
});
const { url } = await createOnboardingCheckout(
data.email,
data.selectedTier,
);
window.location.href = url;
} catch (err) {
if (err.code === "auth/email-already-in-use") {
setError("Email already in use. Try logging in instead.");
} else if (err.code === "auth/invalid-email") {
setError("Invalid email address");
} else if (err.code === "auth/weak-password") {
setError("Password is too weak");
} else {
setError("Failed to process plan selection: " + err.message);
}
} finally {
setLoading(false);
}
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
return (
<div className="progress-bar">
{STEPS.map((label, i) => {
const stepNum = i + 1;
const done = step > stepNum;
const active = step === stepNum;
const dotClass = done
? "progress-bar__dot--done"
: active
? "progress-bar__dot--active"
: "progress-bar__dot--pending";
return (
<div key={label} className="progress-bar__step">
{i > 0 && (
<div
className={`progress-bar__connector ${
done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
/>
)}
<div>
<div className={`progress-bar__dot ${dotClass}`}>
{done ? "\u2713" : stepNum}
</div>
<div
className={`progress-bar__label ${
active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
>
{label}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Step content ──────────────────────────────────────────────────────────
function StepCreateAccount() {
return (
<>
<h2 className="mb-lg">Create your account</h2>
<Input
label="Email"
type="email"
value={data.email}
onChange={(v) => set("email", v)}
disabled={loading}
placeholder="you@example.com"
/>
<Input
label="Password"
type="password"
value={data.password}
onChange={(v) => set("password", v)}
disabled={loading}
placeholder="At least 6 characters"
/>
<Input
label="Confirm Password"
type="password"
value={data.confirmPassword}
onChange={(v) => set("confirmPassword", v)}
disabled={loading}
placeholder="Repeat your password"
className="form-field--last"
/>
</>
);
}
function StepChoosePlan() {
return (
<>
<h2 className="mb-sm">Choose your monitoring plan</h2>
<p className="subscribe__subtitle">
Select the plan that works best for you. You can upgrade anytime.
</p>
<TierPicker
onSelect={(tier) => set("selectedTier", tier)}
selectedTier={data.selectedTier}
/>
</>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
async function handleNext() {
if (step === 1) handleStep1();
else if (step === 2) await handleStep2();
}
function handleBack() {
setError("");
setStep((s) => s - 1);
}
const nextLabel =
step === 1
? "Create Account"
: !data.selectedTier
? "Continue"
: data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue";
return (
<div className="subscribe__footer">
<div>
{step === 2 && (
<Button onClick={handleBack} disabled={loading} variant="ghost">
Back
</Button>
)}
</div>
<Button
onClick={handleNext}
disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
</Button>
</div>
);
}
// ── Render ────────────────────────────────────────────────────────────────
const stepContent = {
1: StepCreateAccount(),
2: StepChoosePlan(),
};
return (
<div className="subscribe">
<div
className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}
>
<h1 className="subscribe__title">Koin Ping</h1>
{ProgressBar()}
{error && <div className="alert alert--error">{error}</div>}
<div className="subscribe__card">
{stepContent[step]}
{Footer()}
</div>
{step === 1 && (
<p className="subscribe__login-link">
Already have an account? <a href="/login">Log in here</a>
</p>
)}
</div>
</div>
);
}