Compare commits

..

4 Commits

Author SHA1 Message Date
KS Jannette
cde9f52f52 final steps 2026-03-29 15:39:20 -04:00
KS Jannette
0d7bc65995 adjust flow 2026-03-28 23:09:03 -04:00
KS Jannette
05453895b9 m 2026-03-28 22:54:52 -04:00
S Jannette
14ed0a23a6 Merge pull request #28 from kjannette/mega-blast
Mega blast
2026-03-28 22:34:56 -04:00
5 changed files with 164 additions and 621 deletions

View File

@@ -0,0 +1,20 @@
-- Migration 010: enforce unique emails in users
-- Matches runtime expectation for idx_users_email_unique.
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM users
GROUP BY email
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'Cannot create unique index idx_users_email_unique: duplicate emails exist in users';
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email);
COMMIT;

View File

@@ -5,6 +5,7 @@ import (
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend/internal/domain"
)
@@ -37,10 +38,55 @@ func scanUser(row pgx.Row) (*domain.User, error) {
return &u, nil
}
func (m *UserModel) getByFirebaseUID(ctx context.Context, firebaseUID string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE firebase_uid = $1`,
firebaseUID,
)
return scanUser(row)
}
func (m *UserModel) getByEmail(ctx context.Context, email string) (*domain.User, error) {
row := m.pool.QueryRow(ctx,
`SELECT `+userColumns+` FROM users WHERE email = $1`,
email,
)
return scanUser(row)
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
// creating one if it doesn't exist yet. On conflict (returning user) the
// updated_at timestamp is refreshed.
// creating one if it doesn't exist yet.
//
// For onboarding races or legacy duplicate-identity states, this method
// gracefully falls back to an existing row by email instead of failing
// with a unique-email violation.
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
existingByUID, err := m.getByFirebaseUID(ctx, firebaseUID)
if err != nil {
return nil, err
}
if existingByUID != nil {
return existingByUID, nil
}
if email != "" {
existingByEmail, err := m.getByEmail(ctx, email)
if err != nil {
return nil, err
}
if existingByEmail != nil {
return existingByEmail, nil
}
}
row := m.pool.QueryRow(ctx,
`INSERT INTO users (firebase_uid, email)
VALUES ($1, $2)
@@ -48,7 +94,33 @@ func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID,
RETURNING `+userColumns,
firebaseUID, email,
)
return scanUser(row)
user, err := scanUser(row)
if err == nil {
return user, nil
}
// If a concurrent request inserted by email or firebase_uid first,
// read the existing record and continue without surfacing a 500.
if isUniqueViolation(err) {
existingByUID, readErr := m.getByFirebaseUID(ctx, firebaseUID)
if readErr != nil {
return nil, readErr
}
if existingByUID != nil {
return existingByUID, nil
}
if email != "" {
existingByEmail, readErr := m.getByEmail(ctx, email)
if readErr != nil {
return nil, readErr
}
if existingByEmail != nil {
return existingByEmail, nil
}
}
}
return nil, err
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {

View File

@@ -11,11 +11,7 @@ const TIERS = [
"Configure alerts to fire on trigger events",
"1 transaction alert type per trigger (email digest)",
],
disabledFeatures: [
"Real-time Discord alerts",
"Real-time Telegram alerts",
"Real-time Slack alerts",
],
disabledFeatures: [],
},
{
id: "premium",
@@ -24,15 +20,12 @@ const TIERS = [
period: "/month",
features: [
"Monitor 3 blockchain addresses",
"Configure alerts to fire on trigger events",
"Configure two types of rule-based alerts to fire on trigger events for each of the three addresses",
"Daily email digest alert",
"Real-time Discord alerts",
"Real-time Telegram alerts",
"2 transaction alert types per monitored address",
],
disabledFeatures: [
"Real-time Slack alerts",
],
disabledFeatures: [],
highlighted: true,
},
{
@@ -42,11 +35,11 @@ const TIERS = [
period: "/month",
features: [
"Monitor unlimited blockchain addresses",
"Configure alerts to fire on trigger events",
"Configure unlimited alert rules to fire on unlimited events on any address",
"Daily email digest alert",
"Real-time Discord alerts",
"Real-time Telegram alerts",
"Real-time Slack alerts",
"Real-time Slack alerts configurable for multiple Slack groups or channels",
"Unlimited transaction alert types per monitored address",
],
disabledFeatures: [],

View File

@@ -111,52 +111,6 @@
color: #888;
}
/* Step 5 summary */
.subscribe__summary {
background-color: #1e2e1e;
border: 1px solid #2d5a2d;
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
}
.subscribe__summary-title {
margin: 0 0 0.5rem;
color: #90ee90;
font-weight: bold;
}
.subscribe__summary-list {
margin: 0;
padding-left: 1.25rem;
color: var(--color-text-label);
line-height: 1.8;
}
/* Checkbox rows */
.checkbox-row {
margin-bottom: 1rem;
}
.checkbox-row__label {
display: flex;
align-items: center;
gap: 0.6rem;
cursor: pointer;
color: #ddd;
}
.checkbox-row__input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.checkbox-row__nested {
margin-top: 0.5rem;
margin-left: 1.75rem;
}
/* Footer navigation */
.subscribe__footer {
display: flex;
@@ -167,20 +121,6 @@
border-top: 1px solid var(--color-border-light);
}
/* Test results */
.test-result {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.test-result--success {
color: #90ee90;
}
.test-result--failure {
color: var(--color-error);
}
/* Step subtitle */
.subscribe__subtitle {
color: #aaa;
@@ -188,7 +128,7 @@
font-size: 0.9rem;
}
/* Subscribe card (Step 2) */
/* Subscribe card (Step 2 tier cards) */
.subscribe-card {
background-color: var(--color-bg-card, #1a1a2e);
border: 1px solid var(--color-border-light);
@@ -241,29 +181,6 @@
font-style: italic;
}
/* Tier limit hints */
.subscribe__tier-hint {
font-size: 0.82rem;
color: #ffcc44;
margin-top: 0.5rem;
}
/* Disabled channel group */
.subscribe__channel-disabled {
opacity: 0.5;
pointer-events: none;
}
.subscribe__channel-disabled .subscribe__tier-hint {
pointer-events: auto;
opacity: 1;
}
/* Disabled checkbox row */
.checkbox-row--disabled {
opacity: 0.5;
}
/* ── Subscribe Responsive ────────────────────────────────── */
@media (max-width: 480px) {
@@ -284,4 +201,4 @@
height: 26px;
font-size: 0.75rem;
}
}
}

View File

@@ -1,32 +1,17 @@
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../../contexts/AuthContext";
import { createAddress, getAddresses } from "../../api/addresses";
import { createAlert } from "../../api/alerts";
import {
updateNotificationConfig,
testNotificationChannels,
} from "../../api/notificationConfig";
import { createOnboardingCheckout, verifyCheckoutSession, activateFreeTier } from "../../api/stripe";
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 TIER_LIMITS = {
free: { maxAlertTypes: 1, channels: ["email"] },
premium: { maxAlertTypes: 2, channels: ["email", "discord", "telegram"] },
pro: { maxAlertTypes: 4, channels: ["email", "discord", "telegram", "slack"] },
};
const STEPS = [
"Create Account",
"Choose Plan",
"Add Wallet",
"Alert Rules",
"Notifications",
"Done",
];
const STEPS = ["Create Account", "Choose Plan"];
export default function Subscribe() {
const { currentUser, signup } = useAuth();
@@ -34,57 +19,33 @@ export default function Subscribe() {
const [searchParams, setSearchParams] = useSearchParams();
const hasPaymentReturn = searchParams.get("payment") === "success";
const isUpgrade = searchParams.get("upgrade") === "true";
const [step, setStep] = useState(hasPaymentReturn || currentUser ? 2 : 1);
const [loading, setLoading] = useState(hasPaymentReturn);
const [error, setError] = useState("");
const [skipWarning, setSkipWarning] = useState("");
const [testResults, setTestResults] = useState(null);
const [testLoading, setTestLoading] = useState(false);
const [data, setData] = useState({
selectedTier: "",
email: "",
password: "",
confirmPassword: "",
walletAddress: "",
walletLabel: "",
createdAddressId: null,
alertIncomingTx: false,
alertOutgoingTx: false,
alertLargeTransfer: false,
largeTransferThreshold: "",
alertBalanceBelow: false,
balanceBelowThreshold: "",
discordWebhookUrl: "",
slackWebhookUrl: "",
notificationEmail: "",
alertsCreated: [],
notificationConfigured: false,
});
function set(field, value) {
setData((prev) => ({ ...prev, [field]: value }));
}
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
useEffect(() => {
if (!currentUser || isUpgrade) return;
getAddresses()
.then((addresses) => {
if (addresses.length > 0) {
navigate("/addresses", { replace: true });
}
})
.catch(() => { });
}, [currentUser, navigate, isUpgrade]);
// 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.");
@@ -93,21 +54,30 @@ export default function Subscribe() {
if (payment !== "success" || !sessionId) return;
// Phase 1: no account yet — create it. This triggers an auth state change
// which remounts the component (App.jsx swaps route trees). The URL params
// are preserved so Phase 2 runs on the next mount.
// 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);
@@ -116,22 +86,23 @@ export default function Subscribe() {
return;
}
// Phase 2: authenticated — verify the checkout and activate subscription.
// Phase 2: authenticated — verify the checkout and go to the dashboard.
setSearchParams({}, { replace: true });
setLoading(true);
if (signupLockKey) sessionStorage.removeItem(signupLockKey);
sessionStorage.removeItem("kp_onboard_email");
sessionStorage.removeItem("kp_onboard_pw");
verifyCheckoutSession(sessionId)
.then(() => {
setStep(3);
navigate("/addresses", { replace: true });
})
.catch((err) => {
setError("Payment verification failed: " + err.message);
setStep(2);
})
.finally(() => setLoading(false));
}, [currentUser, searchParams, setSearchParams, signup]);
}, [currentUser, searchParams, setSearchParams, signup, navigate]);
// ── Step handlers ─────────────────────────────────────────────────────────
@@ -166,13 +137,16 @@ export default function Subscribe() {
await signup(data.email, data.password);
}
await activateFreeTier();
setStep(3);
navigate("/addresses", { replace: true });
return;
}
sessionStorage.setItem("kp_onboard_email", data.email);
sessionStorage.setItem("kp_onboard_pw", data.password);
const { url } = await createOnboardingCheckout(data.email, data.selectedTier);
const { url } = await createOnboardingCheckout(
data.email,
data.selectedTier,
);
window.location.href = url;
} catch (err) {
if (err.code === "auth/email-already-in-use") {
@@ -189,125 +163,6 @@ export default function Subscribe() {
}
}
async function handleStep3() {
setError("");
if (!data.walletAddress) {
setError("Please enter a wallet address");
return;
}
if (!/^0x[0-9a-fA-F]{40}$/.test(data.walletAddress)) {
setError("Invalid ETH address (must be 0x followed by 40 hex characters)");
return;
}
try {
setLoading(true);
const created = await createAddress({
address: data.walletAddress,
label: data.walletLabel || undefined,
});
set("createdAddressId", created.id);
setStep(4);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep4() {
setError("");
const rules = [];
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
if (data.alertOutgoingTx) rules.push({ type: "outgoing_tx" });
if (data.alertLargeTransfer) {
if (!data.largeTransferThreshold) {
setError("Please enter a threshold for large transfers");
return;
}
rules.push({ type: "large_transfer", threshold: data.largeTransferThreshold });
}
if (data.alertBalanceBelow) {
if (!data.balanceBelowThreshold) {
setError("Please enter a threshold for balance below");
return;
}
rules.push({ type: "balance_below", threshold: data.balanceBelowThreshold });
}
if (rules.length === 0) {
setStep(5);
return;
}
try {
setLoading(true);
const created = [];
for (const rule of rules) {
const result = await createAlert(data.createdAddressId, rule);
created.push(result);
}
set("alertsCreated", created);
setStep(5);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleStep5() {
setError("");
const hasAny =
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
if (!hasAny) {
setStep(6);
return;
}
try {
setLoading(true);
await updateNotificationConfig({
notification_enabled: true,
discord_webhook_url: data.discordWebhookUrl || undefined,
slack_webhook_url: data.slackWebhookUrl || undefined,
email: data.notificationEmail || undefined,
});
set("notificationConfigured", true);
setStep(6);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleTestChannels() {
setTestLoading(true);
setTestResults(null);
try {
const results = await testNotificationChannels();
setTestResults(results);
} catch (err) {
setTestResults({ error: err.message });
} finally {
setTestLoading(false);
}
}
// ── Alert type limit helpers ──────────────────────────────────────────────
function countSelectedAlerts() {
let count = 0;
if (data.alertIncomingTx) count++;
if (data.alertOutgoingTx) count++;
if (data.alertLargeTransfer) count++;
if (data.alertBalanceBelow) count++;
return count;
}
function canSelectMoreAlerts() {
return tierLimits.maxAlertTypes > countSelectedAlerts();
}
// ── Progress bar ──────────────────────────────────────────────────────────
function ProgressBar() {
@@ -327,10 +182,11 @@ export default function Subscribe() {
<div key={label} className="progress-bar__step">
{i > 0 && (
<div
className={`progress-bar__connector ${done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
className={`progress-bar__connector ${
done || active
? "progress-bar__connector--active"
: "progress-bar__connector--inactive"
}`}
/>
)}
<div>
@@ -338,10 +194,11 @@ export default function Subscribe() {
{done ? "\u2713" : stepNum}
</div>
<div
className={`progress-bar__label ${active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
className={`progress-bar__label ${
active
? "progress-bar__label--active"
: "progress-bar__label--inactive"
}`}
>
{label}
</div>
@@ -403,352 +260,45 @@ export default function Subscribe() {
);
}
function StepAddWallet() {
return (
<>
<h2 className="mb-sm">Add a wallet address</h2>
<p className="subscribe__subtitle">
Enter the Ethereum address you want to monitor.
</p>
<Input
label="ETH Address"
value={data.walletAddress}
onChange={(v) => set("walletAddress", v)}
disabled={loading}
placeholder="0x..."
/>
<Input
label="Label (optional)"
value={data.walletLabel}
onChange={(v) => set("walletLabel", v)}
disabled={loading}
placeholder="e.g. My main wallet"
className="form-field--last"
/>
</>
);
}
function StepAlertRules() {
const atLimit = !canSelectMoreAlerts();
const maxTypes = tierLimits.maxAlertTypes;
return (
<>
<h2 className="mb-sm">Configure alert rules</h2>
<p className="subscribe__subtitle">
Choose which events trigger notifications ({countSelectedAlerts()}/{maxTypes} selected).
You can change these later.
</p>
<CheckboxRow
checked={data.alertIncomingTx}
onChange={(v) => set("alertIncomingTx", v)}
label="Incoming transaction"
disabled={!data.alertIncomingTx && atLimit}
/>
<CheckboxRow
checked={data.alertOutgoingTx}
onChange={(v) => set("alertOutgoingTx", v)}
label="Outgoing transaction"
disabled={!data.alertOutgoingTx && atLimit}
/>
<CheckboxRow
checked={data.alertLargeTransfer}
onChange={(v) => set("alertLargeTransfer", v)}
label="Large transfer"
disabled={!data.alertLargeTransfer && atLimit}
>
{data.alertLargeTransfer && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.largeTransferThreshold}
onChange={(v) => set("largeTransferThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
<CheckboxRow
checked={data.alertBalanceBelow}
onChange={(v) => set("alertBalanceBelow", v)}
label="Balance below"
disabled={!data.alertBalanceBelow && atLimit}
>
{data.alertBalanceBelow && (
<div className="checkbox-row__nested">
<Input
type="number"
label=""
value={data.balanceBelowThreshold}
onChange={(v) => set("balanceBelowThreshold", v)}
placeholder="Threshold (ETH)"
min="0"
step="0.01"
/>
</div>
)}
</CheckboxRow>
{atLimit && data.selectedTier !== "pro" && (
<p className="subscribe__tier-hint">
Your {data.selectedTier} plan allows {maxTypes} alert type{maxTypes !== 1 ? "s" : ""} per address. Upgrade for more.
</p>
)}
</>
);
}
function StepNotifications() {
const channels = tierLimits.channels;
const canDiscord = channels.includes("discord");
const canSlack = channels.includes("slack");
return (
<>
<h2 className="mb-sm">Set up notifications</h2>
<p className="subscribe__subtitle">
Add at least one channel so you receive alerts. All fields are optional.
</p>
<Input
label="Email address for alerts"
type="email"
value={data.notificationEmail}
onChange={(v) => set("notificationEmail", v)}
disabled={loading}
placeholder="you@example.com"
className="form-field--last"
/>
<div className={`mb-md${!canDiscord ? " subscribe__channel-disabled" : ""}`}>
<label className="form-label">
Discord Webhook URL{" "}
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.discordWebhookUrl}
onChange={(v) => set("discordWebhookUrl", v)}
disabled={loading || !canDiscord}
placeholder="https://discord.com/api/webhooks/..."
/>
{!canDiscord && (
<p className="subscribe__tier-hint">Upgrade to Premium to enable Discord alerts</p>
)}
</div>
<div className={`mb-md${!canSlack ? " subscribe__channel-disabled" : ""}`}>
<label className="form-label">
Slack Webhook URL{" "}
<a
href="https://api.slack.com/messaging/webhooks"
target="_blank"
rel="noreferrer"
className="help-link"
>
(how to get one)
</a>
</label>
<Input
label=""
type="url"
value={data.slackWebhookUrl}
onChange={(v) => set("slackWebhookUrl", v)}
disabled={loading || !canSlack}
placeholder="https://hooks.slack.com/services/..."
/>
{!canSlack && (
<p className="subscribe__tier-hint">Upgrade to Pro to enable Slack alerts</p>
)}
</div>
</>
);
}
function StepDone() {
const alertCount = data.alertsCreated.length;
const hasNotif = data.notificationConfigured;
return (
<>
<h2 className="mb-md">You're all set!</h2>
<div className="subscribe__summary">
<p className="subscribe__summary-title">Summary</p>
<ul className="subscribe__summary-list">
<li>
Plan:{" "}
<span className="text-white">
{data.selectedTier === "pro" ? "Pro" : data.selectedTier === "premium" ? "Premium" : "Free Trial"}
</span>
</li>
<li>
Wallet address added:{" "}
<span className="text-mono text-white-sm">
{data.walletAddress}
</span>
{data.walletLabel && ` (${data.walletLabel})`}
</li>
<li>
Alert rules configured:{" "}
<span className="text-white">
{alertCount > 0 ? `${alertCount} rule${alertCount !== 1 ? "s" : ""}` : "None (skipped)"}
</span>
</li>
<li>
Notification channels:{" "}
<span className="text-white">
{hasNotif ? "Configured" : "Not set up (skipped)"}
</span>
</li>
</ul>
</div>
{hasNotif && (
<div className="mb-lg">
<Button
onClick={handleTestChannels}
disabled={testLoading}
variant="ghost"
>
{testLoading ? "Testing..." : "Test All Channels"}
</Button>
{testResults && (
<div className="mt-md">
{testResults.error ? (
<p className="text-error">{testResults.error}</p>
) : (
<ul className="list-unstyled">
{Object.entries(testResults).map(([channel, result]) => (
<li
key={channel}
className={`test-result ${result.success ? "test-result--success" : "test-result--failure"}`}
>
{result.success ? "\u2713" : "\u2717"} {channel}:{" "}
{result.message || (result.success ? "OK" : "Failed")}
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
<Button onClick={() => navigate("/addresses")} className="btn--lg text-bold">
Go to Dashboard
</Button>
</>
);
}
// ── Shared helpers ────────────────────────────────────────────────────────
function CheckboxRow({ checked, onChange, label, children, disabled }) {
return (
<div className={`checkbox-row${disabled ? " checkbox-row--disabled" : ""}`}>
<label className="checkbox-row__label">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="checkbox-row__input"
disabled={disabled}
/>
{label}
</label>
{children}
</div>
);
}
// ── Footer navigation ─────────────────────────────────────────────────────
function Footer() {
if (step === 6) return null;
const canSkip = step === 4 || step === 5;
const canBack = step > 1 && step <= 5;
async function handleNext() {
setSkipWarning("");
if (step === 1) handleStep1();
else if (step === 2) await handleStep2();
else if (step === 3) await handleStep3();
else if (step === 4) await handleStep4();
else if (step === 5) await handleStep5();
}
function handleSkip() {
setError("");
setSkipWarning("");
setStep((s) => s + 1);
}
function handleBack() {
setError("");
setSkipWarning("");
setStep((s) => s - 1);
}
const nextLabel = step === 1
? "Create Account"
: step === 2
? !data.selectedTier
const nextLabel =
step === 1
? "Create Account"
: !data.selectedTier
? "Continue"
: data.selectedTier === "free"
? "Start Free Trial"
: "Subscribe & Continue"
: step === 5
? "Finish"
: "Next →";
: "Subscribe & Continue";
return (
<div className="subscribe__footer">
<div>
{canBack && (
<Button
onClick={handleBack}
disabled={loading}
variant="ghost"
>
{step === 2 && (
<Button onClick={handleBack} disabled={loading} variant="ghost">
Back
</Button>
)}
</div>
<div className="flex gap-md">
{canSkip && (
<Button
onClick={handleSkip}
disabled={loading}
variant="ghost"
>
Skip for now
</Button>
)}
<Button
onClick={handleNext}
disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
</Button>
</div>
<Button
onClick={handleNext}
disabled={loading || (step === 2 && !data.selectedTier)}
className="text-bold"
>
{loading ? "Please wait..." : nextLabel}
</Button>
</div>
);
}
@@ -758,26 +308,18 @@ export default function Subscribe() {
const stepContent = {
1: StepCreateAccount(),
2: StepChoosePlan(),
3: StepAddWallet(),
4: StepAlertRules(),
5: StepNotifications(),
6: StepDone(),
};
return (
<div className="subscribe">
<div className={`subscribe__container${step === 2 ? " subscribe__container--wide" : ""}`}>
<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>
)}
{skipWarning && (
<div className="alert alert--warning">{skipWarning}</div>
)}
{error && <div className="alert alert--error">{error}</div>}
<div className="subscribe__card">
{stepContent[step]}
@@ -786,8 +328,7 @@ export default function Subscribe() {
{step === 1 && (
<p className="subscribe__login-link">
Already have an account?{" "}
<a href="/login">Log in here</a>
Already have an account? <a href="/login">Log in here</a>
</p>
)}
</div>