340 lines
10 KiB
JavaScript
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>
|
|
);
|
|
}
|