feat: integrate Stripe payment module with API + UI components

This commit is contained in:
2026-06-09 21:10:57 +05:30
parent 944a745892
commit 5eb06b2809
25 changed files with 1434 additions and 1 deletions
+7
View File
@@ -3,3 +3,10 @@ VITE_GROQ_API_KEY=Your-API-Key
# Weather Widget (OpenWeatherMap)
VITE_OPENWEATHER_API_KEY=Your-API-Key
# --- Payments Module (Stripe Hosted Checkout) ---
# Frontend (safe to expose). Until set, the Payments page shows a "configure" notice.
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY
# Backend only (Vercel env / .env for `vercel dev`) — NEVER expose these to the client.
STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_SECRET_KEY
STRIPE_WEBHOOK_SECRET=whsec_REPLACE_WITH_YOUR_WEBHOOK_SECRET
+85
View File
@@ -0,0 +1,85 @@
/**
* Stripe — Create Checkout Session
* --------------------------------
* Creates a Stripe-hosted Checkout Session and returns its URL so the frontend
* can redirect the customer to Stripe's secure payment page. We never see or
* store card details — that is the whole point of using hosted Checkout.
*
* Flow: Pay Now → POST here → Stripe Session → redirect to session.url →
* Stripe hosted page → success_url / cancel_url back to the app.
*
* Env (set in Vercel project settings, and .env locally for `vercel dev`):
* STRIPE_SECRET_KEY sk_live_… / sk_test_… (server only — never exposed)
*
* If STRIPE_SECRET_KEY is missing we return 503 with a clear message instead of
* crashing, so the module degrades gracefully until credentials are provided.
*/
// Server-side source of truth for invoice amounts. Prices are resolved HERE,
// never trusted from the client, so a tampered request can't change the amount.
// In production this would be a DB/CRM lookup; mirrors src/modules/payments/data/invoices.js.
const INVOICES = {
'INV-2041': { title: 'Roof Repair — Leak Detection & Shingle Replacement', amount: 184500, currency: 'usd' },
'INV-2039': { title: 'Full Roof Inspection & Storm Damage Report', amount: 32500, currency: 'usd' },
'INV-2012': { title: 'Gutter Cleaning & Maintenance Plan (Q1)', amount: 14900, currency: 'usd' },
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
if (!secretKey) {
return res.status(503).json({
error: 'Stripe is not configured. Set STRIPE_SECRET_KEY to enable payments.',
code: 'stripe_not_configured',
});
}
try {
const { invoiceId, customerEmail } = req.body || {};
const invoice = INVOICES[invoiceId];
if (!invoice) {
return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` });
}
// Lazy import keeps the module out of the cold-start path when unconfigured.
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
// Where Stripe sends the customer back. Derive the app origin from the
// request so this works across local/preview/production without config.
const origin =
req.headers.origin ||
(req.headers.host ? `https://${req.headers.host}` : '');
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
quantity: 1,
price_data: {
currency: invoice.currency,
unit_amount: invoice.amount, // integer, in cents
product_data: {
name: invoice.title,
metadata: { invoiceId },
},
},
},
],
...(customerEmail ? { customer_email: customerEmail } : {}),
// Echoed back on the session + every webhook event for reconciliation.
metadata: { invoiceId },
success_url: `${origin}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/payments/cancel?invoice=${encodeURIComponent(invoiceId)}`,
});
return res.status(200).json({ id: session.id, url: session.url });
} catch (err) {
console.error('[create-checkout-session] error:', err.message);
return res.status(500).json({ error: err.message || 'Failed to create checkout session' });
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Stripe — Retrieve Checkout Session status
* -----------------------------------------
* The success return page calls this with the `session_id` Stripe appended to
* the success_url. We retrieve the session server-side (using the secret key)
* and return a trimmed, safe status object the UI can render and record.
*
* Returning here does NOT replace the webhook — the webhook is the authoritative
* record of truth (it fires even if the customer closes the tab). This endpoint
* just gives the returning customer immediate, verified confirmation.
*
* Env: STRIPE_SECRET_KEY
*/
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
if (!secretKey) {
return res.status(503).json({
error: 'Stripe is not configured.',
code: 'stripe_not_configured',
});
}
const sessionId = req.query?.session_id;
if (!sessionId) {
return res.status(400).json({ error: 'session_id is required' });
}
try {
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
const session = await stripe.checkout.sessions.retrieve(sessionId);
return res.status(200).json({
id: session.id,
// status: open | complete | expired
status: session.status,
// payment_status: paid | unpaid | no_payment_required
paymentStatus: session.payment_status,
amountTotal: session.amount_total,
currency: session.currency,
customerEmail: session.customer_details?.email || null,
invoiceId: session.metadata?.invoiceId || null,
created: session.created ? session.created * 1000 : null,
});
} catch (err) {
console.error('[session-status] error:', err.message);
return res.status(500).json({ error: err.message || 'Failed to retrieve session' });
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Stripe — Transaction history
* ----------------------------
* Returns the payment records written by the webhook (the server-side source of
* truth). Reads from Vercel KV when connected; returns an empty list otherwise
* so the frontend degrades gracefully and falls back to its local record.
*
* GET /api/payments/transactions -> { transactions: [...] }
*/
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { kv } = await import('@vercel/kv');
const raw = await kv.lrange('payments:transactions', 0, 199);
const transactions = (raw || []).map((item) =>
typeof item === 'string' ? JSON.parse(item) : item
);
return res.status(200).json({ transactions });
} catch {
// KV not configured — the frontend will use its local record instead.
return res.status(200).json({ transactions: [] });
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Stripe — Webhook receiver
* -------------------------
* The authoritative record of truth for payments. Stripe POSTs events here;
* we verify the signature, then react to payment lifecycle events. This fires
* server-to-server even if the customer closes their browser, so the
* transaction record must be written here (not only on the success page).
*
* Configure in the Stripe Dashboard → Developers → Webhooks:
* Endpoint URL: https://<your-app>.vercel.app/api/payments/webhook
* Events: checkout.session.completed,
* checkout.session.async_payment_succeeded,
* checkout.session.async_payment_failed,
* checkout.session.expired
*
* Env:
* STRIPE_SECRET_KEY sk_… (to construct the Stripe client)
* STRIPE_WEBHOOK_SECRET whsec_… (to verify the signature)
*
* Local testing: `stripe listen --forward-to localhost:5173/api/payments/webhook`
*
* Persistence uses Vercel KV when connected (same optional pattern as
* api/hail-webhook.js); without KV it logs the event and ack's 200.
*/
// Stripe requires the *raw* request body to verify the signature, so the
// default JSON body parser must be disabled for this route.
export const config = { api: { bodyParser: false } };
async function readRawBody(req) {
const chunks = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
/** Persist a payment record (best-effort; no-op without Vercel KV). */
async function recordPayment(record) {
try {
const { kv } = await import('@vercel/kv');
// Keep a capped, newest-first list of transactions.
await kv.lpush('payments:transactions', JSON.stringify(record));
await kv.ltrim('payments:transactions', 0, 199);
if (record.invoiceId && record.status === 'paid') {
await kv.set(`payments:invoice:${record.invoiceId}`, 'paid');
}
console.log('[payments/webhook] recorded in KV:', record.id);
} catch {
console.log('[payments/webhook] KV unavailable — event received but not persisted:', record);
}
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim();
const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim();
if (!secretKey || !webhookSecret) {
return res.status(503).json({ error: 'Stripe webhook is not configured.' });
}
let event;
try {
const Stripe = (await import('stripe')).default;
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
const rawBody = await readRawBody(req);
const signature = req.headers['stripe-signature'];
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
// Signature verification failed — reject (could be a forged request).
console.error('[payments/webhook] signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
const s = event.data.object;
await recordPayment({
id: s.id,
invoiceId: s.metadata?.invoiceId || null,
amount: s.amount_total,
currency: s.currency,
customerEmail: s.customer_details?.email || null,
status: s.payment_status === 'paid' ? 'paid' : s.payment_status,
eventType: event.type,
createdAt: new Date().toISOString(),
});
break;
}
case 'checkout.session.async_payment_failed': {
const s = event.data.object;
await recordPayment({
id: s.id,
invoiceId: s.metadata?.invoiceId || null,
amount: s.amount_total,
currency: s.currency,
status: 'failed',
eventType: event.type,
createdAt: new Date().toISOString(),
});
break;
}
case 'checkout.session.expired': {
const s = event.data.object;
console.log('[payments/webhook] checkout session expired/cancelled:', s.id);
break;
}
default:
// Unhandled event types are acknowledged so Stripe stops retrying.
console.log('[payments/webhook] unhandled event:', event.type);
}
} catch (err) {
console.error('[payments/webhook] handler error:', err.message);
// Still 200 below if we got a valid event — returning 500 makes Stripe
// retry, which is only desirable for transient persistence failures.
}
return res.status(200).json({ received: true });
}
+7
View File
@@ -26,4 +26,11 @@ export default defineConfig([
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
{
// Serverless functions and build config run on Node, not the browser.
files: ['api/**/*.js', '**/*.config.js'],
languageOptions: {
globals: { ...globals.node },
},
},
])
+2
View File
@@ -16,6 +16,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@stripe/stripe-js": "^9.7.0",
"@vercel/analytics": "^1.6.1",
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
@@ -36,6 +37,7 @@
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"stripe": "^22.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"three": "^0.182.0"
+24
View File
@@ -23,6 +23,9 @@ importers:
'@react-three/fiber':
specifier: ^9.5.0
version: 9.5.0(@types/react@19.2.10)(immer@11.1.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)
'@stripe/stripe-js':
specifier: ^9.7.0
version: 9.7.0
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(react@19.2.4)
@@ -83,6 +86,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
stripe:
specifier: ^22.2.0
version: 22.2.0
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -686,6 +692,10 @@ packages:
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/stripe-js@9.7.0':
resolution: {integrity: sha512-r1ElolvWXM4aYnZZVHvKW3EDL8JcwEuIgTuWxlB5lvC+YsvjkQ0gX35x9d8dTDubX395fViLVqkaolVs1PmIQQ==}
engines: {node: '>=12.16'}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -803,6 +813,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@use-gesture/core@10.3.1':
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
@@ -2055,6 +2066,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
stripe@22.2.0:
resolution: {integrity: sha512-WFGpMOom9QZqso1kcnSwJsCdC1QHDlMoCOxBZRf3JraMzhkfw7dgSdD2a1CFZrqC+mzAfqeEtYILrZhWKIDruA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -2801,6 +2821,8 @@ snapshots:
'@standard-schema/utils@0.3.0': {}
'@stripe/stripe-js@9.7.0': {}
'@tweenjs/tween.js@23.1.3': {}
'@types/babel__core@7.20.5':
@@ -4286,6 +4308,8 @@ snapshots:
strip-json-comments@3.1.1: {}
stripe@22.2.0: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
core-js: set this to true or false
esbuild: set this to true or false
+26
View File
@@ -9,6 +9,7 @@ import Maps from './pages/Maps';
import AdminSchedule from './pages/AdminSchedule';
import CustomerProfile from './pages/CustomerProfile';
import LeaderboardPage from './pages/LeaderboardPage';
import { PaymentProvider, PaymentsPage, PaymentSuccess, PaymentCancelled, DemoCheckout } from './modules/payments';
import ErrorBoundary from './components/ErrorBoundary';
import SmoothScroll from './components/SmoothScroll';
import { Toaster } from 'sonner';
@@ -90,6 +91,15 @@ function App() {
</ProtectedRoute>
} />
{/* Payments Module (independent) — Stripe hosted checkout */}
<Route path="/portal/payments" element={
<ProtectedRoute allowedRoles={['CUSTOMER']}>
<PaymentProvider>
<PaymentsPage />
</PaymentProvider>
</ProtectedRoute>
} />
{/* Self-service profile for internal roles */}
<Route path="/profile" element={
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
@@ -432,6 +442,22 @@ function App() {
} />
</Route>
{/* Stripe hosted-checkout return pages (public, full-screen).
Public on purpose: the round-trip to Stripe drops the in-memory
auth session, so these must render without it. */}
<Route path="/payments/success" element={
<PaymentProvider>
<PaymentSuccess />
</PaymentProvider>
} />
<Route path="/payments/cancel" element={
<PaymentProvider>
<PaymentCancelled />
</PaymentProvider>
} />
{/* Simulated hosted checkout — only used in demo mode (no Stripe keys). */}
<Route path="/payments/demo-checkout" element={<DemoCheckout />} />
{/* Catch all */}
<Route path="*" element={<NotFound />} />
</Routes>
+2 -1
View File
@@ -6,7 +6,7 @@ import {
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
HardHat, ShieldCheck
HardHat, ShieldCheck, CreditCard
} from 'lucide-react';
import PageTransition from './PageTransition';
@@ -210,6 +210,7 @@ const Layout = () => {
default: // Customer or Fallback
return [
{ to: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/portal/payments", icon: CreditCard, label: "Payments" },
...commonItems,
];
}
+88
View File
@@ -0,0 +1,88 @@
# Payments Module — Stripe Hosted Checkout
A **completely independent** payments module using Stripe's official **hosted
Checkout** flow. No custom card forms, no card data ever touches this app —
the customer enters payment details only on Stripe's secure page.
> This is **real Stripe integration architecture**, not a simulation. It ships
> with credential **placeholders**; the moment real keys are added it goes live.
> Until then, the UI shows a clear "configure Stripe" notice.
## Flow
```
Customer → Pay Now
→ POST /api/payments/create-checkout-session (amount resolved server-side)
→ redirect to Stripe-hosted checkout page (session.url)
→ customer pays on stripe.com
→ Stripe redirects back:
success_url → /payments/success?session_id=… (verifies + records)
cancel_url → /payments/cancel
→ Stripe also POSTs /api/payments/webhook (authoritative record)
```
Statuses handled: **success**, **processing/pending** (async methods),
**failed**, and **cancelled**.
## Structure
```
api/payments/ # Vercel serverless functions (backend)
├── create-checkout-session.js # creates the Checkout Session, returns session.url
├── session-status.js # verifies a session for the return page
├── webhook.js # signature-verified Stripe event receiver
└── transactions.js # serves webhook-written history (Vercel KV)
src/modules/payments/ # frontend (self-contained)
├── config/stripeConfig.js # publishable key + isConfigured flag
├── services/checkoutService.js # startCheckout() + getSessionStatus()
├── context/PaymentProvider.jsx # history (server KV local), invoices
├── data/invoices.js # sample invoices (display only)
├── components/ # InvoiceList, TransactionHistory, ConfigNotice
├── pages/ # PaymentsPage, PaymentSuccess, PaymentCancelled
└── index.js
```
## Routes (registered in `src/App.jsx`)
| Path | Access | Purpose |
|---|---|---|
| `/portal/payments` | CUSTOMER | Invoice list + history |
| `/payments/success` | public | Stripe success return (verifies session) |
| `/payments/cancel` | public | Stripe cancel return |
The return pages are **public** on purpose: the full-page round-trip to Stripe
loses the app's in-memory auth session, so the return pages must render without
it. (With persisted/JWT auth in production this is seamless.)
## Configuration
Add to your environment (see repo `.env.example`):
```
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… # frontend (safe to expose)
STRIPE_SECRET_KEY=sk_test_… # backend only — never expose
STRIPE_WEBHOOK_SECRET=whsec_… # backend only — webhook signature
```
In Vercel: add `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` as Project env vars,
and `VITE_STRIPE_PUBLISHABLE_KEY` as a build-time var.
### Stripe Dashboard → Webhooks
- Endpoint: `https://<your-app>/api/payments/webhook`
- Events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`,
`checkout.session.async_payment_failed`, `checkout.session.expired`
### Persistence
The webhook writes records to **Vercel KV** when connected (same optional pattern
as `api/hail-webhook.js`); without KV it logs and the frontend falls back to its
local record of the customer's own completed payments.
## Local testing
```
npm i # installs stripe + @stripe/stripe-js
# put test keys in .env, then:
vercel dev # runs the /api functions for real
# webhook:
stripe listen --forward-to localhost:3000/api/payments/webhook
```
`npm run dev` also bridges `create-checkout-session` and `session-status` via the
existing Vite dev-API plugin (needs test keys in `.env`).
## Dependencies added
- `@stripe/stripe-js` (frontend redirect fallback)
- `stripe` (backend Node SDK)
@@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Clock, Lock, Loader2, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { formatAmount } from '../data/invoices';
import { stripeConfig } from '../config/stripeConfig';
import { startCheckout } from '../services/checkoutService';
/**
* Lists outstanding invoices with a "Pay Now" button that redirects the
* customer to Stripe's hosted Checkout page. No card details are collected here.
*/
const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => {
const navigate = useNavigate();
const [redirectingId, setRedirectingId] = useState(null);
const isPaid = (inv) => inv.status === 'paid' || paidIds.includes(inv.id);
const due = invoices.filter((i) => !isPaid(i));
const paid = invoices.filter(isPaid);
const handlePay = async (invoice) => {
if (redirectingId) return;
setRedirectingId(invoice.id);
// No Stripe keys → use the simulated hosted-checkout page so the demo
// works without credentials. With keys, redirect to real Stripe.
if (!stripeConfig.isConfigured) {
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
return;
}
try {
// On success this navigates away to Stripe; control won't return.
await startCheckout({ invoiceId: invoice.id, customerEmail });
} catch (err) {
toast.error('Unable to start checkout', { description: err.message });
setRedirectingId(null);
}
};
return (
<div className="space-y-8">
<section>
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
<Clock className="mr-2 text-blue-500" size={20} /> Outstanding Invoices
</h3>
{due.length > 0 ? (
<div className="grid gap-4">
{due.map((inv) => {
const busy = redirectingId === inv.id;
return (
<SpotlightCard key={inv.id} className="p-5 md:p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4 min-w-0">
<div className="w-11 h-11 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20 shrink-0">
<FileText size={20} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-bold truncate">{inv.title}</h4>
<span className="text-[10px] font-mono text-zinc-400 shrink-0">
{inv.id}
</span>
</div>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
{inv.description}
</p>
<p className="text-xs text-zinc-400 mt-1">
Due {new Date(inv.dueDate).toLocaleDateString()} · {inv.property}
</p>
</div>
</div>
<div className="flex items-center justify-between md:flex-col md:items-end gap-3 md:gap-2 shrink-0">
<span className="text-xl font-black text-zinc-900 dark:text-white">
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
</span>
<button
onClick={() => handlePay(inv)}
disabled={busy}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? (
<><Loader2 className="animate-spin" size={15} /> Redirecting</>
) : (
<><Lock size={14} /> Pay Now</>
)}
</button>
</div>
</div>
</SpotlightCard>
);
})}
</div>
) : (
<div className="p-8 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
You're all caught up no outstanding invoices. 🎉
</div>
)}
</section>
{paid.length > 0 && (
<section>
<h3 className="text-lg font-bold mb-4 flex items-center text-zinc-800 dark:text-zinc-200">
<CheckCircle2 className="mr-2 text-emerald-500" size={20} /> Paid
</h3>
<div className="grid gap-3">
{paid.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-zinc-900/40 border border-zinc-200 dark:border-white/5 opacity-80"
>
<div className="flex items-center gap-3 min-w-0">
<CheckCircle2 size={18} className="text-emerald-500 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{inv.title}</p>
<p className="text-[11px] font-mono text-zinc-400">{inv.id}</p>
</div>
</div>
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400 shrink-0">
{formatAmount(inv.amount, inv.currency, stripeConfig.locale)}
</span>
</div>
))}
</div>
</section>
)}
<p className="flex items-center justify-center gap-1.5 text-[11px] text-zinc-400 pt-2">
<Lock size={12} /> Payments are processed securely by Stripe. We never see or store your card details.
</p>
</div>
);
};
export default InvoiceList;
@@ -0,0 +1,33 @@
import React from 'react';
import { FlaskConical } from 'lucide-react';
/**
* Shown when no real Stripe publishable key is configured. Payments run through
* the simulated checkout (no real charge); adding keys switches to live Stripe.
*/
const StripeConfigNotice = () => (
<div className="rounded-2xl border border-amber-300/60 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-4 md:p-5">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-amber-400/20 text-amber-600 dark:text-amber-300 flex items-center justify-center shrink-0">
<FlaskConical size={18} />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-amber-800 dark:text-amber-200">
Demo mode simulated Stripe Checkout
</p>
<p className="text-xs text-amber-700/80 dark:text-amber-200/70 mt-1 leading-relaxed">
No Stripe keys are configured, so "Pay Now" runs a simulated checkout
(no real charge). Add the keys below to switch to live Stripe-hosted
Checkout no code changes needed:
</p>
<ul className="mt-2 space-y-1 text-[11px] font-mono text-amber-800 dark:text-amber-200/90">
<li>VITE_STRIPE_PUBLISHABLE_KEY <span className="opacity-60">(frontend)</span></li>
<li>STRIPE_SECRET_KEY <span className="opacity-60">(backend)</span></li>
<li>STRIPE_WEBHOOK_SECRET <span className="opacity-60">(backend / webhook)</span></li>
</ul>
</div>
</div>
</div>
);
export default StripeConfigNotice;
@@ -0,0 +1,73 @@
import React from 'react';
import { Receipt, CreditCard, XCircle } from 'lucide-react';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { stripeConfig } from '../config/stripeConfig';
import { formatAmount } from '../data/invoices';
const STATUS_STYLES = {
paid: {
label: 'Paid',
chip: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300',
icon: CreditCard,
iconWrap: 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20',
},
failed: {
label: 'Failed',
chip: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300',
icon: XCircle,
iconWrap: 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 border-red-100 dark:border-red-500/20',
},
};
/**
* Lists payment records (merged server + local history). Read-only.
*/
const TransactionHistory = ({ transactions }) => {
if (!transactions.length) {
return (
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
<Receipt size={28} className="mx-auto mb-3 opacity-50" />
No transactions yet. Completed payments will appear here.
</div>
);
}
return (
<div className="grid gap-3">
{transactions.map((t) => {
const style = STATUS_STYLES[t.status] || STATUS_STYLES.paid;
const Icon = style.icon;
return (
<SpotlightCard key={t.id} className="p-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4 min-w-0">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center border shrink-0 ${style.iconWrap}`}>
<Icon size={18} />
</div>
<div className="min-w-0">
<h4 className="font-bold truncate">{t.invoiceId || 'Payment'}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{t.customerEmail ? `${t.customerEmail} · ` : ''}
{t.createdAt ? new Date(t.createdAt).toLocaleString() : ''}
</p>
<p className="text-[10px] font-mono text-zinc-400 mt-0.5 truncate">{t.id}</p>
</div>
</div>
<div className="text-right shrink-0">
<span className="block font-black text-zinc-900 dark:text-white">
{formatAmount(t.amount, t.currency || stripeConfig.currency, stripeConfig.locale)}
</span>
<span className={`inline-block mt-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${style.chip}`}>
{style.label}
</span>
</div>
</div>
</SpotlightCard>
);
})}
</div>
);
};
export default TransactionHistory;
@@ -0,0 +1,39 @@
/**
* Stripe configuration (frontend) for the Payments module.
*
* Only the PUBLISHABLE key lives on the frontend — it is safe to expose. The
* Secret Key and Webhook Secret are backend-only (see /api/payments/*).
*
* Set in `.env` (see .env.example):
* VITE_STRIPE_PUBLISHABLE_KEY=pk_test_… or pk_live_…
*
* Until a real key is provided, `isConfigured` is false and the UI shows a
* clear "configure Stripe" notice rather than attempting a broken redirect.
*/
const PLACEHOLDER = 'pk_test_REPLACE_WITH_YOUR_PUBLISHABLE_KEY';
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || PLACEHOLDER;
export const stripeConfig = {
publishableKey,
// Base path for the payment API endpoints (Vercel serverless functions).
apiBase: '/api/payments',
// True only when a real publishable key is present.
isConfigured:
typeof publishableKey === 'string' &&
publishableKey.startsWith('pk_') &&
publishableKey !== PLACEHOLDER,
currency: 'usd',
locale: 'en-US',
merchant: {
name: 'LynkedUp Pro Roofing',
supportEmail: 'billing@lynkeduppro.com',
},
};
export default stripeConfig;
@@ -0,0 +1,89 @@
/**
* PaymentProvider — module-local state, fully independent from the app's global
* mockStore so the Payments module can be added/removed in isolation.
*
* Transaction history is sourced from two places and merged by id:
* 1. The server (api/payments/transactions → Vercel KV, written by the
* webhook) — the production source of truth.
* 2. A local record (localStorage) written when a customer returns to the
* success page — gives instant feedback and works without KV in the demo.
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { INVOICES } from '../data/invoices';
import { stripeConfig } from '../config/stripeConfig';
const STORAGE_KEY = 'lynkeduppro.payments.transactions';
const loadLocal = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
const persistLocal = (txns) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(txns));
} catch {
/* localStorage unavailable (private mode) — keep in-memory only. */
}
};
const mergeById = (a, b) => {
const map = new Map();
[...a, ...b].forEach((t) => {
if (t && t.id) map.set(t.id, { ...map.get(t.id), ...t });
});
return Array.from(map.values()).sort(
(x, y) => new Date(y.createdAt || 0) - new Date(x.createdAt || 0)
);
};
const PaymentContext = createContext(null);
export const PaymentProvider = ({ children }) => {
const [invoices] = useState(INVOICES);
const [transactions, setTransactions] = useState(loadLocal);
// Pull the server-side (webhook-written) history and merge it in.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`${stripeConfig.apiBase}/transactions`);
if (!res.ok) return;
const { transactions: server = [] } = await res.json();
if (cancelled || !server.length) return;
setTransactions((prev) => mergeById(prev, server));
} catch {
/* offline / endpoint unavailable — local history is fine. */
}
})();
return () => {
cancelled = true;
};
}, []);
/** Record a transaction confirmed on the success return page. */
const recordTransaction = useCallback((txn) => {
if (!txn?.id) return;
setTransactions((prev) => {
const next = mergeById(prev, [txn]);
persistLocal(next);
return next;
});
}, []);
const value = { invoices, transactions, recordTransaction };
return <PaymentContext.Provider value={value}>{children}</PaymentContext.Provider>;
};
export const usePayments = () => {
const ctx = useContext(PaymentContext);
if (!ctx) throw new Error('usePayments must be used within a <PaymentProvider>.');
return ctx;
};
+45
View File
@@ -0,0 +1,45 @@
/**
* Sample invoices for the customer to pay (display only). Amounts are stored in
* cents to match Stripe's API. The SERVER independently re-resolves the amount
* from its own copy (see api/payments/create-checkout-session.js) so the client
* can never alter what is charged — these values are for rendering the list.
*
* In production this list would come from the CRM/billing backend.
*/
export const INVOICES = [
{
id: 'INV-2041',
title: 'Roof Repair — Leak Detection & Shingle Replacement',
description: 'Emergency leak repair on north-facing slope, 12 shingles replaced.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-06-15',
amount: 184500,
currency: 'usd',
},
{
id: 'INV-2039',
title: 'Full Roof Inspection & Storm Damage Report',
description: 'Comprehensive drone inspection with insurance-ready documentation.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-05-25',
amount: 32500,
currency: 'usd',
},
{
id: 'INV-2012',
title: 'Gutter Cleaning & Maintenance Plan (Q1)',
description: 'Quarterly gutter clearing and downspout flush.',
property: '2612 Dunwick Dr, Plano, TX 75023',
dueDate: '2026-03-18',
amount: 14900,
currency: 'usd',
},
];
/** Format an integer cent amount into a localized currency string. */
export const formatAmount = (cents, currency = 'usd', locale = 'en-US') =>
new Intl.NumberFormat(locale, {
style: 'currency',
currency: (currency || 'usd').toUpperCase(),
}).format((cents || 0) / 100);
+13
View File
@@ -0,0 +1,13 @@
/**
* Payments Module — public surface.
*
* Self-contained Stripe Checkout integration. Import from here so the rest of
* the app never reaches into the module internals.
*/
export { default as PaymentsPage } from './pages/PaymentsPage';
export { default as PaymentSuccess } from './pages/PaymentSuccess';
export { default as PaymentCancelled } from './pages/PaymentCancelled';
export { default as DemoCheckout } from './pages/DemoCheckout';
export { PaymentProvider, usePayments } from './context/PaymentProvider';
export { stripeConfig } from './config/stripeConfig';
+138
View File
@@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Lock, Loader2, ArrowLeft, FlaskConical, CheckCircle2 } from 'lucide-react';
import { INVOICES, formatAmount } from '../data/invoices';
import { stripeConfig } from '../config/stripeConfig';
/**
* Demo-only stand-in for Stripe's hosted Checkout page. Used ONLY when no
* Stripe keys are configured, so the flow (Pay Now → checkout → success →
* history) works for a demo without credentials. It collects no real card data
* and makes no charge. When real keys are added, this page is never reached —
* `startCheckout` redirects to the genuine Stripe-hosted page instead.
*
* Mirrors Stripe Checkout's two-column hosted layout for visual familiarity.
*/
const DemoCheckout = () => {
const [params] = useSearchParams();
const navigate = useNavigate();
const invoiceId = params.get('invoice');
const invoice = INVOICES.find((i) => i.id === invoiceId);
const [paying, setPaying] = useState(false);
if (!invoice) {
return (
<Shell>
<p className="text-center text-zinc-500">Invoice not found.</p>
<BackButton onClick={() => navigate('/portal/payments')} />
</Shell>
);
}
const amountLabel = formatAmount(invoice.amount, invoice.currency, stripeConfig.locale);
const handlePay = () => {
if (paying) return;
setPaying(true);
// Simulate Stripe's processing latency, then return to the success page.
setTimeout(() => {
navigate(
`/payments/success?demo=1&invoice=${encodeURIComponent(invoice.id)}` +
`&amount=${invoice.amount}&currency=${invoice.currency}`
);
}, 1600);
};
return (
<Shell>
{/* Demo banner */}
<div className="mb-6 flex items-center gap-2 rounded-xl border border-amber-300/60 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-2.5 text-xs font-medium text-amber-800 dark:text-amber-200">
<FlaskConical size={15} />
Demo mode simulated Stripe Checkout. No real card or charge. Add Stripe keys to go live.
</div>
<div className="grid md:grid-cols-2 gap-8">
{/* Left: order summary (Stripe-style) */}
<div className="md:pr-8 md:border-r border-zinc-200 dark:border-white/10">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{stripeConfig.merchant.name}</p>
<p className="text-3xl font-black mt-1">{amountLabel}</p>
<div className="mt-6 flex items-start justify-between gap-4 py-3 border-t border-zinc-100 dark:border-white/5">
<div className="min-w-0">
<p className="font-semibold truncate">{invoice.title}</p>
<p className="text-xs text-zinc-400 mt-0.5">{invoice.id}</p>
</div>
<span className="font-semibold shrink-0">{amountLabel}</span>
</div>
<div className="flex items-center justify-between py-3 border-t border-zinc-100 dark:border-white/5 text-sm">
<span className="text-zinc-500">Total due</span>
<span className="font-bold">{amountLabel}</span>
</div>
</div>
{/* Right: payment (read-only demo — no real card entry) */}
<div>
<p className="text-sm font-bold uppercase tracking-widest text-zinc-400 mb-3">
Pay with card
</p>
<div className="rounded-xl border border-zinc-200 dark:border-white/10 divide-y divide-zinc-100 dark:divide-white/5 bg-zinc-50 dark:bg-zinc-900/40 text-sm">
<Row label="Card number" value="4242 4242 4242 4242" />
<div className="grid grid-cols-2 divide-x divide-zinc-100 dark:divide-white/5">
<Row label="Expiry" value="12 / 34" />
<Row label="CVC" value="123" />
</div>
</div>
<p className="flex items-center gap-1.5 text-[11px] text-emerald-600 dark:text-emerald-400 mt-2">
<CheckCircle2 size={12} /> Test card pre-filled for the demo just press Pay.
</p>
<button
onClick={handlePay}
disabled={paying}
className="mt-5 w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{paying ? (
<><Loader2 className="animate-spin" size={18} /> Processing</>
) : (
<><Lock size={16} /> Pay {amountLabel}</>
)}
</button>
<p className="flex items-center justify-center gap-1.5 text-[11px] text-zinc-400 mt-3">
<Lock size={12} /> Powered by Stripe (simulated)
</p>
{!paying && <BackButton onClick={() => navigate('/payments/cancel?invoice=' + invoice.id)} />}
</div>
</div>
</Shell>
);
};
const Shell = ({ children }) => (
<div className="min-h-screen bg-zinc-100 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
<div className="w-full max-w-3xl bg-white dark:bg-zinc-950 rounded-3xl shadow-xl border border-zinc-200 dark:border-white/10 p-6 md:p-10">
{children}
</div>
</div>
);
const Row = ({ label, value }) => (
<div className="flex items-center justify-between px-4 py-3">
<span className="text-zinc-400 text-xs uppercase tracking-wide">{label}</span>
<span className="font-mono">{value}</span>
</div>
);
const BackButton = ({ onClick }) => (
<button
onClick={onClick}
className="mt-4 w-full flex items-center justify-center gap-1.5 text-sm font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowLeft size={15} /> Cancel and go back
</button>
);
export default DemoCheckout;
@@ -0,0 +1,39 @@
import React from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Ban, ArrowLeft } from 'lucide-react';
import { SpotlightCard } from '../../../components/SpotlightCard';
/**
* Stripe redirects here (cancel_url) when the customer backs out of the hosted
* Checkout page without paying. Public route — no charge was made.
*/
const PaymentCancelled = () => {
const [params] = useSearchParams();
const navigate = useNavigate();
const invoice = params.get('invoice');
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
<div className="w-full max-w-lg">
<SpotlightCard className="p-8 text-center">
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center mb-4">
<Ban size={34} className="text-zinc-400" />
</div>
<h2 className="text-2xl font-black">Payment Cancelled</h2>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
No charge was made{invoice ? ` for ${invoice}` : ''}. You can complete the
payment whenever you're ready.
</p>
<button
onClick={() => navigate('/portal/payments')}
className="mt-7 w-full flex items-center justify-center gap-1.5 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<ArrowLeft size={16} /> Back to Payments
</button>
</SpotlightCard>
</div>
</div>
);
};
export default PaymentCancelled;
@@ -0,0 +1,201 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { CheckCircle2, XCircle, Loader2, Clock, ArrowLeft, Receipt } from 'lucide-react';
import { SpotlightCard } from '../../../components/SpotlightCard';
import { stripeConfig } from '../config/stripeConfig';
import { formatAmount } from '../data/invoices';
import { getSessionStatus } from '../services/checkoutService';
import { usePayments } from '../context/PaymentProvider';
/**
* Stripe redirects here (success_url) after the customer leaves the hosted
* Checkout page. We verify the session server-side, then show + record the
* result. This route is PUBLIC so it renders even though the in-memory auth
* session was lost during the full-page round-trip to Stripe.
*
* Handled states: verifying → paid | pending (async) | failed | error.
*/
const PaymentSuccess = () => {
const [params] = useSearchParams();
const navigate = useNavigate();
const { recordTransaction } = usePayments();
const sessionId = params.get('session_id');
const isDemo = params.get('demo') === '1';
// Initialize from the URL so the effect never needs a synchronous setState
// (which would trigger cascading renders).
const [state, setState] = useState(() =>
isDemo ? 'paid' : sessionId ? 'verifying' : 'error'
); // verifying | paid | pending | failed | error
const [details, setDetails] = useState(() =>
isDemo
? {
id: `cs_demo_${Date.now()}`,
invoiceId: params.get('invoice'),
amountTotal: Number(params.get('amount')) || 0,
currency: params.get('currency') || stripeConfig.currency,
customerEmail: null,
}
: null
);
const [errorMsg, setErrorMsg] = useState(() =>
sessionId || isDemo ? '' : 'Missing session reference.'
);
const recordedRef = useRef(false);
useEffect(() => {
// Demo mode: no server to verify against — record the simulated payment.
if (isDemo) {
if (!recordedRef.current && details) {
recordedRef.current = true;
recordTransaction({
id: details.id,
invoiceId: details.invoiceId,
amount: details.amountTotal,
currency: details.currency,
customerEmail: null,
status: 'paid',
createdAt: new Date().toISOString(),
demo: true,
});
}
return;
}
if (!sessionId) return;
let cancelled = false;
(async () => {
try {
const data = await getSessionStatus(sessionId);
if (cancelled) return;
setDetails(data);
const paid = data.paymentStatus === 'paid' || data.status === 'complete';
if (paid) {
setState('paid');
if (!recordedRef.current) {
recordedRef.current = true;
recordTransaction({
id: data.id,
invoiceId: data.invoiceId,
amount: data.amountTotal,
currency: data.currency,
customerEmail: data.customerEmail,
status: 'paid',
createdAt: new Date().toISOString(),
});
}
} else if (data.status === 'open') {
// Payment still processing (e.g. async method).
setState('pending');
} else {
setState('failed');
}
} catch (err) {
if (cancelled) return;
setState('error');
setErrorMsg(err.message);
}
})();
return () => {
cancelled = true;
};
}, [sessionId, isDemo, details, recordTransaction]);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white flex items-center justify-center p-6">
<div className="w-full max-w-lg">
<SpotlightCard className="p-8 text-center">
{state === 'verifying' && (
<Body
icon={<Loader2 size={36} className="animate-spin text-blue-500" />}
title="Confirming your payment…"
subtitle="Verifying with Stripe, one moment."
/>
)}
{state === 'pending' && (
<Body
icon={<Clock size={36} className="text-amber-500" />}
title="Payment processing"
subtitle="Your payment is being processed. We'll update your history once it settles."
/>
)}
{state === 'paid' && details && (
<>
<Body
icon={<CheckCircle2 size={36} className="text-emerald-500" />}
title="Payment Successful"
subtitle={`${formatAmount(details.amountTotal, details.currency, stripeConfig.locale)} paid to ${stripeConfig.merchant.name}.`}
/>
<Receipt2 details={details} />
</>
)}
{state === 'failed' && (
<Body
icon={<XCircle size={36} className="text-red-500" />}
title="Payment not completed"
subtitle="This payment didn't go through. You can try again from your invoices."
/>
)}
{state === 'error' && (
<Body
icon={<XCircle size={36} className="text-red-500" />}
title="Couldn't verify payment"
subtitle={errorMsg || 'Please check your transaction history in a moment.'}
/>
)}
<button
onClick={() => navigate('/portal/payments')}
className="mt-7 w-full flex items-center justify-center gap-1.5 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<ArrowLeft size={16} /> Back to Payments
</button>
</SpotlightCard>
</div>
</div>
);
};
const Body = ({ icon, title, subtitle }) => (
<>
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center mb-4">
{icon}
</div>
<h2 className="text-2xl font-black">{title}</h2>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">{subtitle}</p>
</>
);
const Receipt2 = ({ details }) => {
const rows = [
{ label: 'Invoice', value: details.invoiceId || '—' },
{ label: 'Email', value: details.customerEmail || '—' },
{ label: 'Session', value: details.id, mono: true },
];
return (
<div className="mt-6 text-left rounded-2xl border border-zinc-200 dark:border-white/10 divide-y divide-zinc-100 dark:divide-white/5 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-50 dark:bg-zinc-900/40 text-xs font-bold uppercase tracking-widest text-zinc-400">
<Receipt size={14} /> Receipt
</div>
{rows.map((r) => (
<div key={r.label} className="flex items-start justify-between gap-4 px-4 py-3">
<span className="text-xs font-bold uppercase tracking-wide text-zinc-400 shrink-0">
{r.label}
</span>
<span className={`text-sm text-zinc-800 dark:text-zinc-200 text-right break-all ${r.mono ? 'font-mono text-xs' : 'font-medium'}`}>
{r.value}
</span>
</div>
))}
</div>
);
};
export default PaymentSuccess;
@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { CreditCard } from 'lucide-react';
import { useAuth } from '../../../context/AuthContext';
import { usePayments } from '../context/PaymentProvider';
import { stripeConfig } from '../config/stripeConfig';
import InvoiceList from '../components/InvoiceList';
import TransactionHistory from '../components/TransactionHistory';
import StripeConfigNotice from '../components/StripeConfigNotice';
const TabButton = ({ id, label, activeTab, onSelect }) => (
<button
onClick={() => onSelect(id)}
className={`px-4 md:px-6 py-3 text-xs md:text-sm font-bold uppercase tracking-wider border-b-2 transition-all whitespace-nowrap ${
activeTab === id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
{label}
</button>
);
/**
* Payments module entry point (customer portal). Lists invoices to pay via
* Stripe-hosted Checkout and shows transaction history. Shares no state with
* the rest of the app beyond reading the logged-in user's email.
*/
const PaymentsPage = () => {
const { user } = useAuth();
const { invoices, transactions } = usePayments();
const [activeTab, setActiveTab] = useState('pay');
// Invoices marked paid by the (server) transaction history.
const paidIds = transactions
.filter((t) => t.status === 'paid' && t.invoiceId)
.map((t) => t.invoiceId);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-8 pt-24 transition-colors duration-300">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center space-x-4 mb-8">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white shadow-lg">
<CreditCard size={28} />
</div>
<div>
<h1 className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60">
Payments
</h1>
<p className="text-zinc-500 dark:text-zinc-400">
Pay invoices securely via Stripe and review past transactions.
</p>
</div>
</div>
{!stripeConfig.isConfigured && <StripeConfigNotice />}
{/* Tabs */}
<div className="flex space-x-1 border-b border-zinc-200 dark:border-zinc-800 mb-6 overflow-x-auto scrollbar-hide">
<TabButton id="pay" label="Make a Payment" activeTab={activeTab} onSelect={setActiveTab} />
<TabButton id="history" label="Transaction History" activeTab={activeTab} onSelect={setActiveTab} />
</div>
{/* Content */}
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
{activeTab === 'pay' ? (
<InvoiceList
invoices={invoices}
paidIds={paidIds}
customerEmail={user?.email || user?.username}
/>
) : (
<TransactionHistory transactions={transactions} />
)}
</div>
</div>
</div>
);
};
export default PaymentsPage;
@@ -0,0 +1,74 @@
/**
* Checkout service — the only place the frontend talks to Stripe / the payment
* API. The UI calls these functions; it never handles card data.
*
* `startCheckout` follows Stripe's recommended hosted-checkout flow:
* 1. Ask our backend to create a Checkout Session (amount resolved server-side).
* 2. Redirect the browser to the Stripe-hosted `session.url`.
* 3. Stripe handles payment, then redirects back to success_url / cancel_url.
*
* A Stripe.js `redirectToCheckout({ sessionId })` fallback (which uses the
* publishable key) is kept for older API behavior, but `session.url` is the
* current default and avoids loading Stripe.js for the redirect.
*/
import { loadStripe } from '@stripe/stripe-js';
import { stripeConfig } from '../config/stripeConfig';
let stripePromise;
const getStripe = () => {
if (!stripePromise) stripePromise = loadStripe(stripeConfig.publishableKey);
return stripePromise;
};
/**
* Create a session for an invoice and redirect to Stripe's hosted page.
* Throws (with a friendly message) if the backend isn't configured/reachable.
*/
export async function startCheckout({ invoiceId, customerEmail } = {}) {
if (!stripeConfig.isConfigured) {
const err = new Error(
'Stripe is not configured yet. Add your publishable key to enable payments.'
);
err.code = 'stripe_not_configured';
throw err;
}
const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoiceId, customerEmail }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Could not start checkout. Please try again.');
}
// Preferred: redirect straight to the Stripe-hosted Checkout URL.
if (data.url) {
window.location.assign(data.url);
return;
}
// Fallback: Stripe.js redirect using the session id + publishable key.
const stripe = await getStripe();
if (!stripe) throw new Error('Unable to initialize Stripe.');
const { error } = await stripe.redirectToCheckout({ sessionId: data.id });
if (error) throw new Error(error.message);
}
/**
* Fetch the verified status of a completed Checkout Session (used by the
* success return page). Returns the trimmed status object from the backend.
*/
export async function getSessionStatus(sessionId) {
const res = await fetch(
`${stripeConfig.apiBase}/session-status?session_id=${encodeURIComponent(sessionId)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Could not verify payment status.');
}
return data;
}
+20
View File
@@ -22,6 +22,11 @@ const DEV_API_HANDLERS = {
'hail-proxy': () => import('./api/hail-proxy.js'),
'storm-events': () => import('./api/storm-events.js'),
'storm-polygons': () => import('./api/storm-polygons.js'),
// Payments module (needs real Stripe test keys in .env to function locally).
// The webhook is intentionally excluded — it needs the raw body + Stripe CLI.
'payments/create-checkout-session': () => import('./api/payments/create-checkout-session.js'),
'payments/session-status': () => import('./api/payments/session-status.js'),
'payments/transactions': () => import('./api/payments/transactions.js'),
}
// Server-side env vars the handlers read (mirrors what you'd set in Vercel).
@@ -30,6 +35,8 @@ const SERVER_ENV_KEYS = [
'HAIL_RECON_ACCESS_KEY',
'HAIL_RECON_ACCESS_SECRET',
'HAIL_RECON_WEBHOOK_SECRET',
'STRIPE_SECRET_KEY',
'STRIPE_WEBHOOK_SECRET',
]
function devApi(env) {
@@ -62,6 +69,19 @@ function devApi(env) {
res.end(JSON.stringify(obj))
return res
}
res.send = (body) => { res.end(typeof body === 'string' ? body : JSON.stringify(body)); return res }
// Parse a JSON body for POST/PUT/PATCH (Vercel does this automatically).
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
req.body = await new Promise((resolve) => {
let data = ''
req.on('data', (c) => { data += c })
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}) } catch { resolve({}) }
})
req.on('error', () => resolve({}))
})
}
try {
const mod = await load()