add subscription tiers
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import AddressForm from "../../components/AddressForm";
|
||||
import UpgradeBanner from "../../components/UpgradeBanner";
|
||||
import { getAddresses, createAddress, deleteAddress, updateAddress } from "../../api/addresses";
|
||||
import "./Addresses.css";
|
||||
|
||||
export default function Addresses() {
|
||||
const { tierLimits, refreshAccount } = useAuth();
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
|
||||
const maxAddresses = tierLimits.max_addresses;
|
||||
const isUnlimited = maxAddresses === -1;
|
||||
const atLimit = !isUnlimited && addresses.length >= maxAddresses;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAddresses() {
|
||||
try {
|
||||
@@ -32,6 +39,7 @@ export default function Addresses() {
|
||||
const newAddress = await createAddress(data);
|
||||
setAddresses((prev) => [...prev, newAddress]);
|
||||
setError(null);
|
||||
refreshAccount();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to create address:", err);
|
||||
@@ -47,6 +55,7 @@ export default function Addresses() {
|
||||
await deleteAddress(id);
|
||||
setAddresses((prev) => prev.filter((a) => a.id !== id));
|
||||
setError(null);
|
||||
refreshAccount();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error("Failed to delete address:", err);
|
||||
@@ -80,9 +89,17 @@ export default function Addresses() {
|
||||
<div className="page">
|
||||
<h1>Add Addresses to Track</h1>
|
||||
|
||||
<div className="mb-xl">
|
||||
<AddressForm onSubmit={handleAddressSubmit} />
|
||||
</div>
|
||||
{atLimit && (
|
||||
<UpgradeBanner
|
||||
message={`Your plan allows ${maxAddresses} address${maxAddresses !== 1 ? "es" : ""}. Upgrade to track more.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!atLimit && (
|
||||
<div className="mb-xl">
|
||||
<AddressForm onSubmit={handleAddressSubmit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2>Existing Tracked Addresses</h2>
|
||||
|
||||
@@ -125,6 +125,20 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Locked / disabled notification channel */
|
||||
.alerts__channel-locked {
|
||||
opacity: 0.55;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alerts__channel-locked .upgrade-banner {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.alerts__channel-locked input {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Alerts Page Responsive ──────────────────────────────── */
|
||||
|
||||
/* Laptop: tighten the gap */
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import AlertForm from "../../components/AlertForm";
|
||||
import Button from "../../components/Button";
|
||||
import Input from "../../components/Input";
|
||||
import UpgradeBanner from "../../components/UpgradeBanner";
|
||||
import { getAddresses } from "../../api/addresses";
|
||||
import {
|
||||
getAlerts,
|
||||
@@ -20,6 +22,8 @@ import {
|
||||
import "./Alerts.css";
|
||||
|
||||
export default function Alerts() {
|
||||
const { tierLimits, userTier } = useAuth();
|
||||
|
||||
const [addresses, setAddresses] = useState([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState(null);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
@@ -43,6 +47,21 @@ export default function Alerts() {
|
||||
const [openAccordions, setOpenAccordions] = useState({});
|
||||
const [thresholdEdits, setThresholdEdits] = useState({});
|
||||
|
||||
const allowedChannels = tierLimits.allowed_channels || ["email"];
|
||||
const canTelegram = allowedChannels.includes("telegram");
|
||||
const canDiscord = allowedChannels.includes("discord");
|
||||
const canSlack = allowedChannels.includes("slack");
|
||||
|
||||
const maxAlertTypes = tierLimits.max_alert_types;
|
||||
const isUnlimitedAlerts = maxAlertTypes === -1;
|
||||
|
||||
function distinctAlertTypeCount() {
|
||||
const types = new Set(alerts.map((a) => a.type));
|
||||
return types.size;
|
||||
}
|
||||
|
||||
const atAlertLimit = !isUnlimitedAlerts && distinctAlertTypeCount() >= maxAlertTypes;
|
||||
|
||||
function toggleAccordion(alertId) {
|
||||
setOpenAccordions((prev) => ({ ...prev, [alertId]: !prev[alertId] }));
|
||||
}
|
||||
@@ -209,11 +228,11 @@ export default function Alerts() {
|
||||
|
||||
const config = {
|
||||
notification_enabled: notificationEnabled,
|
||||
discord_webhook_url: discordWebhookUrl || null,
|
||||
telegram_bot_token: telegramBotToken || null,
|
||||
telegram_chat_id: telegramChatId || null,
|
||||
discord_webhook_url: canDiscord ? (discordWebhookUrl || null) : null,
|
||||
telegram_bot_token: canTelegram ? (telegramBotToken || null) : null,
|
||||
telegram_chat_id: canTelegram ? (telegramChatId || null) : null,
|
||||
email: email || null,
|
||||
slack_webhook_url: slackWebhookUrl || null,
|
||||
slack_webhook_url: canSlack ? (slackWebhookUrl || null) : null,
|
||||
};
|
||||
|
||||
await updateNotificationConfig(config);
|
||||
@@ -357,10 +376,18 @@ export default function Alerts() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-xl">
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
{atAlertLimit && userTier !== "pro" && (
|
||||
<UpgradeBanner
|
||||
message={`Your ${userTier} plan allows ${maxAlertTypes} alert type${maxAlertTypes !== 1 ? "s" : ""} per address. Upgrade for more.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!atAlertLimit && (
|
||||
<div className="mb-xl">
|
||||
<h3>Create New Alert</h3>
|
||||
<AlertForm onSubmit={handleAlertSubmit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3>Active Alert Rules</h3>
|
||||
@@ -540,19 +567,24 @@ export default function Alerts() {
|
||||
{notificationEnabled && (
|
||||
<>
|
||||
{/* Telegram */}
|
||||
<div className="section">
|
||||
<div className={`section${!canTelegram ? " alerts__channel-locked" : ""}`}>
|
||||
<h3 className="mt-0 mb-md">Telegram</h3>
|
||||
{!canTelegram && (
|
||||
<UpgradeBanner message="Upgrade to Premium to enable Telegram alerts" />
|
||||
)}
|
||||
<Input
|
||||
label="Bot Token"
|
||||
value={telegramBotToken}
|
||||
onChange={setTelegramBotToken}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
|
||||
disabled={!canTelegram}
|
||||
/>
|
||||
<Input
|
||||
label="Chat ID"
|
||||
value={telegramChatId}
|
||||
onChange={setTelegramChatId}
|
||||
placeholder="-1001234567890"
|
||||
disabled={!canTelegram}
|
||||
/>
|
||||
<a
|
||||
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||
@@ -597,13 +629,17 @@ export default function Alerts() {
|
||||
</div>
|
||||
|
||||
{/* Discord */}
|
||||
<div className="section">
|
||||
<div className={`section${!canDiscord ? " alerts__channel-locked" : ""}`}>
|
||||
<h3 className="mt-0 mb-md">Discord</h3>
|
||||
{!canDiscord && (
|
||||
<UpgradeBanner message="Upgrade to Premium to enable Discord alerts" />
|
||||
)}
|
||||
<Input
|
||||
label="Discord Webhook URL"
|
||||
value={discordWebhookUrl}
|
||||
onChange={setDiscordWebhookUrl}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
disabled={!canDiscord}
|
||||
/>
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"
|
||||
@@ -616,13 +652,17 @@ export default function Alerts() {
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="section">
|
||||
<div className={`section${!canSlack ? " alerts__channel-locked" : ""}`}>
|
||||
<h3 className="mt-0 mb-md">Slack</h3>
|
||||
{!canSlack && (
|
||||
<UpgradeBanner message="Upgrade to Pro to enable Slack alerts" />
|
||||
)}
|
||||
<Input
|
||||
label="Slack Webhook URL"
|
||||
value={slackWebhookUrl}
|
||||
onChange={setSlackWebhookUrl}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
disabled={!canSlack}
|
||||
/>
|
||||
<a
|
||||
href="https://api.slack.com/messaging/webhooks"
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.subscribe__container--wide {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.subscribe__title {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
@@ -237,6 +241,29 @@
|
||||
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) {
|
||||
|
||||
@@ -7,12 +7,20 @@ import {
|
||||
updateNotificationConfig,
|
||||
testNotificationChannels,
|
||||
} from "../../api/notificationConfig";
|
||||
import { createCheckoutSession, getSubscriptionStatus, verifyCheckoutSession } from "../../api/stripe";
|
||||
import { createCheckoutSession, getSubscriptionStatus, 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 = [
|
||||
"Choose Plan",
|
||||
"Create Account",
|
||||
"Add Wallet",
|
||||
"Alert Rules",
|
||||
@@ -33,6 +41,7 @@ export default function Subscribe() {
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
|
||||
const [data, setData] = useState({
|
||||
selectedTier: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
@@ -56,6 +65,8 @@ export default function Subscribe() {
|
||||
setData((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
const tierLimits = TIER_LIMITS[data.selectedTier] || TIER_LIMITS.free;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
getAddresses()
|
||||
@@ -67,7 +78,6 @@ export default function Subscribe() {
|
||||
.catch(() => { });
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
// Handle Stripe redirect back from checkout
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
const payment = searchParams.get("payment");
|
||||
@@ -77,23 +87,32 @@ export default function Subscribe() {
|
||||
setLoading(true);
|
||||
verifyCheckoutSession(sessionId)
|
||||
.then(() => {
|
||||
setStep(2);
|
||||
setStep(3);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError("Payment verification failed: " + err.message);
|
||||
setStep(1);
|
||||
setStep(2);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else if (payment === "cancelled") {
|
||||
setSearchParams({}, { replace: true });
|
||||
setStep(1);
|
||||
setStep(2);
|
||||
setError("Payment was cancelled. Please try again.");
|
||||
}
|
||||
}, [currentUser, searchParams, setSearchParams]);
|
||||
|
||||
// ── Step handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleStep1() {
|
||||
function handleStep1() {
|
||||
setError("");
|
||||
if (!data.selectedTier) {
|
||||
setError("Please select a plan to continue");
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function handleStep2() {
|
||||
setError("");
|
||||
if (!data.email || !data.password || !data.confirmPassword) {
|
||||
setError("Please fill in all fields");
|
||||
@@ -112,12 +131,19 @@ export default function Subscribe() {
|
||||
if (!currentUser) {
|
||||
await signup(data.email, data.password);
|
||||
}
|
||||
const status = await getSubscriptionStatus();
|
||||
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
||||
setStep(2);
|
||||
|
||||
if (data.selectedTier === "free") {
|
||||
await activateFreeTier();
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
const { url } = await createCheckoutSession();
|
||||
|
||||
const status = await getSubscriptionStatus();
|
||||
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
const { url } = await createCheckoutSession(data.selectedTier);
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
if (err.code === "auth/email-already-in-use") {
|
||||
@@ -133,7 +159,7 @@ export default function Subscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep2() {
|
||||
async function handleStep3() {
|
||||
setError("");
|
||||
if (!data.walletAddress) {
|
||||
setError("Please enter a wallet address");
|
||||
@@ -150,7 +176,7 @@ export default function Subscribe() {
|
||||
label: data.walletLabel || undefined,
|
||||
});
|
||||
set("createdAddressId", created.id);
|
||||
setStep(3);
|
||||
setStep(4);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -158,7 +184,7 @@ export default function Subscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep3() {
|
||||
async function handleStep4() {
|
||||
setError("");
|
||||
const rules = [];
|
||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
||||
@@ -179,7 +205,7 @@ export default function Subscribe() {
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
setStep(4);
|
||||
setStep(5);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +217,7 @@ export default function Subscribe() {
|
||||
created.push(result);
|
||||
}
|
||||
set("alertsCreated", created);
|
||||
setStep(4);
|
||||
setStep(5);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -199,12 +225,12 @@ export default function Subscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStep4() {
|
||||
async function handleStep5() {
|
||||
setError("");
|
||||
const hasAny =
|
||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
||||
if (!hasAny) {
|
||||
setStep(5);
|
||||
setStep(6);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -216,7 +242,7 @@ export default function Subscribe() {
|
||||
email: data.notificationEmail || undefined,
|
||||
});
|
||||
set("notificationConfigured", true);
|
||||
setStep(5);
|
||||
setStep(6);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -237,6 +263,21 @@ export default function Subscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
@@ -284,7 +325,22 @@ export default function Subscribe() {
|
||||
|
||||
// ── Step content ──────────────────────────────────────────────────────────
|
||||
|
||||
function Step1() {
|
||||
function StepChoosePlan() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Choose your 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCreateAccount() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-lg">Create your account</h2>
|
||||
@@ -317,7 +373,7 @@ export default function Subscribe() {
|
||||
);
|
||||
}
|
||||
|
||||
function Step2() {
|
||||
function StepAddWallet() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="mb-sm">Add a wallet address</h2>
|
||||
@@ -343,28 +399,35 @@ export default function Subscribe() {
|
||||
);
|
||||
}
|
||||
|
||||
function Step3() {
|
||||
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. You can change these later.
|
||||
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">
|
||||
@@ -384,6 +447,7 @@ export default function Subscribe() {
|
||||
checked={data.alertBalanceBelow}
|
||||
onChange={(v) => set("alertBalanceBelow", v)}
|
||||
label="Balance below"
|
||||
disabled={!data.alertBalanceBelow && atLimit}
|
||||
>
|
||||
{data.alertBalanceBelow && (
|
||||
<div className="checkbox-row__nested">
|
||||
@@ -399,11 +463,21 @@ export default function Subscribe() {
|
||||
</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 Step4() {
|
||||
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>
|
||||
@@ -411,7 +485,17 @@ export default function Subscribe() {
|
||||
Add at least one channel so you receive alerts. All fields are optional.
|
||||
</p>
|
||||
|
||||
<div className="mb-md">
|
||||
<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
|
||||
@@ -428,12 +512,15 @@ export default function Subscribe() {
|
||||
type="url"
|
||||
value={data.discordWebhookUrl}
|
||||
onChange={(v) => set("discordWebhookUrl", v)}
|
||||
disabled={loading}
|
||||
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">
|
||||
<div className={`mb-md${!canSlack ? " subscribe__channel-disabled" : ""}`}>
|
||||
<label className="form-label">
|
||||
Slack Webhook URL{" "}
|
||||
<a
|
||||
@@ -450,25 +537,18 @@ export default function Subscribe() {
|
||||
type="url"
|
||||
value={data.slackWebhookUrl}
|
||||
onChange={(v) => set("slackWebhookUrl", v)}
|
||||
disabled={loading}
|
||||
disabled={loading || !canSlack}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
{!canSlack && (
|
||||
<p className="subscribe__tier-hint">Upgrade to Pro to enable Slack alerts</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Step5() {
|
||||
function StepDone() {
|
||||
const alertCount = data.alertsCreated.length;
|
||||
const hasNotif = data.notificationConfigured;
|
||||
|
||||
@@ -479,6 +559,12 @@ export default function Subscribe() {
|
||||
<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">
|
||||
@@ -542,15 +628,16 @@ export default function Subscribe() {
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function CheckboxRow({ checked, onChange, label, children }) {
|
||||
function CheckboxRow({ checked, onChange, label, children, disabled }) {
|
||||
return (
|
||||
<div className="checkbox-row">
|
||||
<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>
|
||||
@@ -562,17 +649,18 @@ export default function Subscribe() {
|
||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||
|
||||
function Footer() {
|
||||
if (step === 5) return null;
|
||||
if (step === 6) return null;
|
||||
|
||||
const canSkip = step === 3 || step === 4;
|
||||
const canBack = step > 2;
|
||||
const canSkip = step === 4 || step === 5;
|
||||
const canBack = step > 1 && step <= 5;
|
||||
|
||||
async function handleNext() {
|
||||
setSkipWarning("");
|
||||
if (step === 1) await handleStep1();
|
||||
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() {
|
||||
@@ -588,10 +676,14 @@ export default function Subscribe() {
|
||||
}
|
||||
|
||||
const nextLabel = step === 1
|
||||
? "Create Account & Subscribe"
|
||||
: step === 4
|
||||
? "Finish"
|
||||
: "Next →";
|
||||
? "Continue"
|
||||
: step === 2
|
||||
? data.selectedTier === "free"
|
||||
? "Create Account"
|
||||
: "Create Account & Subscribe"
|
||||
: step === 5
|
||||
? "Finish"
|
||||
: "Next →";
|
||||
|
||||
return (
|
||||
<div className="subscribe__footer">
|
||||
@@ -619,7 +711,7 @@ export default function Subscribe() {
|
||||
)}
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
disabled={loading || (step === 1 && !data.selectedTier)}
|
||||
className="text-bold"
|
||||
>
|
||||
{loading ? "Please wait..." : nextLabel}
|
||||
@@ -632,16 +724,17 @@ export default function Subscribe() {
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const stepContent = {
|
||||
1: Step1(),
|
||||
2: Step2(),
|
||||
3: Step3(),
|
||||
4: Step4(),
|
||||
5: Step5(),
|
||||
1: StepChoosePlan(),
|
||||
2: StepCreateAccount(),
|
||||
3: StepAddWallet(),
|
||||
4: StepAlertRules(),
|
||||
5: StepNotifications(),
|
||||
6: StepDone(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="subscribe">
|
||||
<div className="subscribe__container">
|
||||
<div className={`subscribe__container${step === 1 ? " subscribe__container--wide" : ""}`}>
|
||||
<h1 className="subscribe__title">Koin Ping</h1>
|
||||
|
||||
{ProgressBar()}
|
||||
@@ -659,7 +752,7 @@ export default function Subscribe() {
|
||||
{Footer()}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
{step === 2 && (
|
||||
<p className="subscribe__login-link">
|
||||
Already have an account?{" "}
|
||||
<a href="/login">Log in here</a>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { updatePassword } from "firebase/auth";
|
||||
import { auth } from "../../firebase/config";
|
||||
import { getAccount, createPortalSession } from "../../api/account";
|
||||
import "./Account.css";
|
||||
|
||||
const TIER_LABELS = {
|
||||
free: "Free Trial",
|
||||
premium: "Premium",
|
||||
pro: "Pro",
|
||||
};
|
||||
|
||||
export default function Account() {
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -78,6 +86,7 @@ export default function Account() {
|
||||
return <div className="page text-error">Error: {error}</div>;
|
||||
}
|
||||
|
||||
const tier = account.subscription_tier || "free";
|
||||
const isCanceling = account.cancel_at_period_end;
|
||||
const statusLabel = isCanceling
|
||||
? "Canceling"
|
||||
@@ -86,6 +95,9 @@ export default function Account() {
|
||||
: account.subscription_status.charAt(0).toUpperCase() +
|
||||
account.subscription_status.slice(1);
|
||||
|
||||
const canUpgrade = tier === "free" || tier === "premium";
|
||||
const hasPaidSub = tier !== "free";
|
||||
|
||||
return (
|
||||
<div className="page account-page">
|
||||
<h1 className="mb-lg">Account</h1>
|
||||
@@ -114,7 +126,7 @@ export default function Account() {
|
||||
<h2 className="account__section-title">Subscription</h2>
|
||||
<div className="account__row">
|
||||
<span className="account__label">Plan</span>
|
||||
<span className="account__value">{account.subscription_plan}</span>
|
||||
<span className="account__value">{TIER_LABELS[tier] || account.subscription_plan}</span>
|
||||
</div>
|
||||
<div className="account__row">
|
||||
<span className="account__label">Status</span>
|
||||
@@ -149,15 +161,27 @@ export default function Account() {
|
||||
)}
|
||||
|
||||
<div className="account__portal-section">
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={portalLoading}
|
||||
className="btn btn--ghost"
|
||||
>
|
||||
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
||||
</button>
|
||||
{canUpgrade && (
|
||||
<button
|
||||
onClick={() => navigate("/subscribe")}
|
||||
className="btn btn--primary"
|
||||
>
|
||||
Upgrade Plan
|
||||
</button>
|
||||
)}
|
||||
{hasPaidSub && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={portalLoading}
|
||||
className="btn btn--ghost"
|
||||
>
|
||||
{portalLoading ? "Redirecting..." : "Manage Subscription"}
|
||||
</button>
|
||||
)}
|
||||
<p className="text-dimmed text-sm account__portal-hint">
|
||||
Cancel subscription, update payment method, or view invoices via Stripe.
|
||||
{hasPaidSub
|
||||
? "Cancel subscription, update payment method, or view invoices via Stripe."
|
||||
: "Upgrade to unlock more addresses, alert types, and notification channels."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user