addition Stripe payment integration work
Some checks are pending
check / check (push) Waiting to run
Some checks are pending
check / check (push) Waiting to run
This commit is contained in:
@@ -2,7 +2,6 @@ Start DB:
|
|||||||
|
|
||||||
brew services start postgresql@15
|
brew services start postgresql@15
|
||||||
|
|
||||||
|
|
||||||
From the backend-go directory, you have a few options:
|
From the backend-go directory, you have a few options:
|
||||||
|
|
||||||
Option 1: Single command (both API + poller)
|
Option 1: Single command (both API + poller)
|
||||||
@@ -10,9 +9,9 @@ cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gomake dev-all
|
|||||||
|
|
||||||
Option 2: Two separate terminals
|
Option 2: Two separate terminals
|
||||||
Terminal 1 (API server):
|
Terminal 1 (API server):
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/api
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-go go run ./cmd/api
|
||||||
Terminal 2 (Poller):
|
Terminal 2 (Poller):
|
||||||
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-gogo run ./cmd/poller
|
cd /Users/kjannette/workspace/koin_ping_0.2.0/backend-go go run ./cmd/poller
|
||||||
|
|
||||||
make run — Builds and runs the API server.
|
make run — Builds and runs the API server.
|
||||||
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
make dev — Runs the API server with auto-reload via air (falls back to go run if air isn't installed).
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func (h *StripeHandler) CreateCheckoutSession(w http.ResponseWriter, r *http.Req
|
|||||||
Quantity: stripe.Int64(1),
|
Quantity: stripe.Int64(1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SuccessURL: stripe.String(h.cfg.FrontendURL + "/onboarding?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
SuccessURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=success&session_id={CHECKOUT_SESSION_ID}"),
|
||||||
CancelURL: stripe.String(h.cfg.FrontendURL + "/onboarding?payment=cancelled"),
|
CancelURL: stripe.String(h.cfg.FrontendURL + "/subscribe?payment=cancelled"),
|
||||||
ClientReferenceID: stripe.String(userID),
|
ClientReferenceID: stripe.String(userID),
|
||||||
CustomerEmail: stripe.String(user.Email),
|
CustomerEmail: stripe.String(user.Email),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/signup" element={<Signup />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/subscribe" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/login" />} />
|
<Route path="*" element={<Navigate to="/login" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -30,7 +30,7 @@ export default function App() {
|
|||||||
<Route path="/addresses" element={<Addresses />} />
|
<Route path="/addresses" element={<Addresses />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<Alerts />} />
|
||||||
<Route path="/alertevents" element={<AlertHistory />} />
|
<Route path="/alertevents" element={<AlertHistory />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/subscribe" element={<Onboarding />} />
|
||||||
<Route path="*" element={<Navigate to="/addresses" />} />
|
<Route path="*" element={<Navigate to="/addresses" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.input__label {
|
.input__label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.4rem;
|
margin: 0.8rem 0rem 0.4rem 0rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,4 +18,4 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Onboarding Wizard
|
* Subscribe / Onboarding Wizard
|
||||||
*
|
*
|
||||||
* 6-step guided flow: Create Account -> Subscribe -> Add Wallet -> Alert Rules -> Notifications -> Done
|
* 5-step guided flow: Create Account -> Add Wallet -> Alert Rules -> Notifications -> Done
|
||||||
|
* After account creation, user is redirected to Stripe Checkout for payment.
|
||||||
|
* On successful payment, they return here at step 2 (Add Wallet).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
@@ -20,7 +22,6 @@ import "./Onboarding.css";
|
|||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
"Create Account",
|
"Create Account",
|
||||||
"Subscribe",
|
|
||||||
"Add Wallet",
|
"Add Wallet",
|
||||||
"Alert Rules",
|
"Alert Rules",
|
||||||
"Notifications",
|
"Notifications",
|
||||||
@@ -80,11 +81,11 @@ export default function Onboarding() {
|
|||||||
const payment = searchParams.get("payment");
|
const payment = searchParams.get("payment");
|
||||||
if (payment === "success") {
|
if (payment === "success") {
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
setStep(3);
|
setStep(2);
|
||||||
} else if (payment === "cancelled") {
|
} else if (payment === "cancelled") {
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
setStep(2);
|
setStep(1);
|
||||||
setError("Payment was cancelled. Please subscribe to continue.");
|
setError("Payment was cancelled. Please try again.");
|
||||||
}
|
}
|
||||||
}, [currentUser, searchParams, setSearchParams]);
|
}, [currentUser, searchParams, setSearchParams]);
|
||||||
|
|
||||||
@@ -104,47 +105,33 @@ export default function Onboarding() {
|
|||||||
setError("Password must be at least 6 characters");
|
setError("Password must be at least 6 characters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!currentUser) {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await signup(data.email, data.password);
|
|
||||||
} 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 create account: " + err.message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setStep(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStep2Subscribe() {
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
if (!currentUser) {
|
||||||
|
await signup(data.email, data.password);
|
||||||
|
}
|
||||||
const status = await getSubscriptionStatus();
|
const status = await getSubscriptionStatus();
|
||||||
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
if (status.subscription_status === "active" || status.subscription_status === "trialing") {
|
||||||
setStep(3);
|
setStep(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { url } = await createCheckoutSession();
|
const { url } = await createCheckoutSession();
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
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 create account: " + err.message);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep3() {
|
async function handleStep2() {
|
||||||
setError("");
|
setError("");
|
||||||
if (!data.walletAddress) {
|
if (!data.walletAddress) {
|
||||||
setError("Please enter a wallet address");
|
setError("Please enter a wallet address");
|
||||||
@@ -161,7 +148,7 @@ export default function Onboarding() {
|
|||||||
label: data.walletLabel || undefined,
|
label: data.walletLabel || undefined,
|
||||||
});
|
});
|
||||||
set("createdAddressId", created.id);
|
set("createdAddressId", created.id);
|
||||||
setStep(4);
|
setStep(3);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -169,7 +156,7 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep4() {
|
async function handleStep3() {
|
||||||
setError("");
|
setError("");
|
||||||
const rules = [];
|
const rules = [];
|
||||||
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
if (data.alertIncomingTx) rules.push({ type: "incoming_tx" });
|
||||||
@@ -190,7 +177,7 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rules.length === 0) {
|
if (rules.length === 0) {
|
||||||
setStep(5);
|
setStep(4);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +189,7 @@ export default function Onboarding() {
|
|||||||
created.push(result);
|
created.push(result);
|
||||||
}
|
}
|
||||||
set("alertsCreated", created);
|
set("alertsCreated", created);
|
||||||
setStep(5);
|
setStep(4);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -210,12 +197,12 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep5() {
|
async function handleStep4() {
|
||||||
setError("");
|
setError("");
|
||||||
const hasAny =
|
const hasAny =
|
||||||
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
data.discordWebhookUrl || data.slackWebhookUrl || data.notificationEmail;
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
setStep(6);
|
setStep(5);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -227,7 +214,7 @@ export default function Onboarding() {
|
|||||||
email: data.notificationEmail || undefined,
|
email: data.notificationEmail || undefined,
|
||||||
});
|
});
|
||||||
set("notificationConfigured", true);
|
set("notificationConfigured", true);
|
||||||
setStep(6);
|
setStep(5);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -331,33 +318,6 @@ export default function Onboarding() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Step2() {
|
function Step2() {
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-sm">Subscribe to Koin Ping</h2>
|
|
||||||
<p className="onboarding__subtitle">
|
|
||||||
A subscription is required to use Koin Ping.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="subscribe-card">
|
|
||||||
<div className="subscribe-card__price">
|
|
||||||
<span className="subscribe-card__amount">$1.99</span>
|
|
||||||
<span className="subscribe-card__period">/month</span>
|
|
||||||
</div>
|
|
||||||
<ul className="subscribe-card__features">
|
|
||||||
<li>Unlimited wallet monitoring</li>
|
|
||||||
<li>Real-time alert notifications</li>
|
|
||||||
<li>Discord, Slack, Telegram & email alerts</li>
|
|
||||||
<li>Email digest reports</li>
|
|
||||||
</ul>
|
|
||||||
<p className="subscribe-card__commitment">
|
|
||||||
6-month minimum commitment
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step3() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="mb-sm">Add a wallet address</h2>
|
<h2 className="mb-sm">Add a wallet address</h2>
|
||||||
@@ -383,7 +343,7 @@ export default function Onboarding() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step4() {
|
function Step3() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="mb-sm">Configure alert rules</h2>
|
<h2 className="mb-sm">Configure alert rules</h2>
|
||||||
@@ -443,7 +403,7 @@ export default function Onboarding() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step5() {
|
function Step4() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="mb-sm">Set up notifications</h2>
|
<h2 className="mb-sm">Set up notifications</h2>
|
||||||
@@ -508,7 +468,7 @@ export default function Onboarding() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step6() {
|
function Step5() {
|
||||||
const alertCount = data.alertsCreated.length;
|
const alertCount = data.alertsCreated.length;
|
||||||
const hasNotif = data.notificationConfigured;
|
const hasNotif = data.notificationConfigured;
|
||||||
|
|
||||||
@@ -602,18 +562,17 @@ export default function Onboarding() {
|
|||||||
// ── Footer navigation ─────────────────────────────────────────────────────
|
// ── Footer navigation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
if (step === 6) return null;
|
if (step === 5) return null;
|
||||||
|
|
||||||
const canSkip = step === 4 || step === 5;
|
const canSkip = step === 3 || step === 4;
|
||||||
const canBack = step > 1 && step !== 2;
|
const canBack = step > 2;
|
||||||
|
|
||||||
async function handleNext() {
|
async function handleNext() {
|
||||||
setSkipWarning("");
|
setSkipWarning("");
|
||||||
if (step === 1) await handleStep1();
|
if (step === 1) await handleStep1();
|
||||||
else if (step === 2) await handleStep2Subscribe();
|
else if (step === 2) await handleStep2();
|
||||||
else if (step === 3) await handleStep3();
|
else if (step === 3) await handleStep3();
|
||||||
else if (step === 4) await handleStep4();
|
else if (step === 4) await handleStep4();
|
||||||
else if (step === 5) await handleStep5();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSkip() {
|
function handleSkip() {
|
||||||
@@ -628,9 +587,9 @@ export default function Onboarding() {
|
|||||||
setStep((s) => s - 1);
|
setStep((s) => s - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextLabel = step === 2
|
const nextLabel = step === 1
|
||||||
? "Subscribe — $1.99/mo"
|
? "Create Account & Subscribe"
|
||||||
: step === 5
|
: step === 4
|
||||||
? "Finish"
|
? "Finish"
|
||||||
: "Next →";
|
: "Next →";
|
||||||
|
|
||||||
@@ -673,12 +632,11 @@ export default function Onboarding() {
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const stepContent = {
|
const stepContent = {
|
||||||
1: <Step1 />,
|
1: Step1(),
|
||||||
2: <Step2 />,
|
2: Step2(),
|
||||||
3: <Step3 />,
|
3: Step3(),
|
||||||
4: <Step4 />,
|
4: Step4(),
|
||||||
5: <Step5 />,
|
5: Step5(),
|
||||||
6: <Step6 />,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -686,7 +644,7 @@ export default function Onboarding() {
|
|||||||
<div className="onboarding__container">
|
<div className="onboarding__container">
|
||||||
<h1 className="onboarding__title">Koin Ping</h1>
|
<h1 className="onboarding__title">Koin Ping</h1>
|
||||||
|
|
||||||
<ProgressBar />
|
{ProgressBar()}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert alert--error">{error}</div>
|
<div className="alert alert--error">{error}</div>
|
||||||
@@ -698,7 +656,7 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
<div className="onboarding__card">
|
<div className="onboarding__card">
|
||||||
{stepContent[step]}
|
{stepContent[step]}
|
||||||
<Footer />
|
{Footer()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export default function Signup() {
|
export default function Signup() {
|
||||||
return <Navigate to="/onboarding" replace />;
|
return <Navigate to="/subscribe" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user