Merge branch 'feature/payment-gateway' into diy-estimate-presentation-module
# Conflicts: # src/components/Layout.jsx
This commit is contained in:
@@ -3,3 +3,10 @@ VITE_GROQ_API_KEY=Your-API-Key
|
|||||||
|
|
||||||
# Weather Widget (OpenWeatherMap)
|
# Weather Widget (OpenWeatherMap)
|
||||||
VITE_OPENWEATHER_API_KEY=Your-API-Key
|
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
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 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, fallback } = req.body || {};
|
||||||
|
|
||||||
|
// The static map is authoritative for seed invoices (amount can't be
|
||||||
|
// tampered). DEMO ONLY: invoices the owner created live in the browser's
|
||||||
|
// localStorage, so the server doesn't know them — accept the client's
|
||||||
|
// amount/title as a fallback for those. In production every invoice would
|
||||||
|
// be resolved from the billing DB here and this fallback removed.
|
||||||
|
let invoice = INVOICES[invoiceId];
|
||||||
|
if (!invoice && fallback) {
|
||||||
|
const amount = Number(fallback.amount);
|
||||||
|
if (Number.isInteger(amount) && amount > 0) {
|
||||||
|
invoice = {
|
||||||
|
title: String(fallback.title || 'Payment').slice(0, 200),
|
||||||
|
amount,
|
||||||
|
currency: (fallback.currency || 'usd').toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -26,4 +26,11 @@ export default defineConfig([
|
|||||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
'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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Generated
+6918
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"stripe": "^22.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"three": "^0.182.0"
|
"three": "^0.182.0"
|
||||||
|
|||||||
Generated
-4567
File diff suppressed because it is too large
Load Diff
+34
@@ -10,6 +10,7 @@ import AdminSchedule from './pages/AdminSchedule';
|
|||||||
import CustomerProfile from './pages/CustomerProfile';
|
import CustomerProfile from './pages/CustomerProfile';
|
||||||
import CustomerDashboard from './pages/CustomerDashboard';
|
import CustomerDashboard from './pages/CustomerDashboard';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
|
import { PaymentProvider, PaymentsPage, PaymentSuccess, PaymentCancelled, DemoCheckout } from './modules/payments';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import SmoothScroll from './components/SmoothScroll';
|
import SmoothScroll from './components/SmoothScroll';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
@@ -32,6 +33,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard';
|
|||||||
import VendorOrders from './pages/vendor/VendorOrders';
|
import VendorOrders from './pages/vendor/VendorOrders';
|
||||||
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
||||||
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||||
|
import PaymentManagement from './pages/owner/PaymentManagement';
|
||||||
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
||||||
import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage';
|
import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage';
|
||||||
import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage';
|
import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage';
|
||||||
@@ -122,6 +124,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* Payments Module (independent) — Stripe hosted checkout */}
|
||||||
|
<Route path="/portal/payments" element={
|
||||||
|
<ProtectedRoute allowedRoles={['CUSTOMER']}>
|
||||||
|
<PaymentProvider>
|
||||||
|
<PaymentsPage />
|
||||||
|
</PaymentProvider>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Payment Management (owner/admin side of the Payments module) */}
|
||||||
|
<Route path="/owner/payments" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||||
|
<PaymentManagement />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Self-service profile for internal roles */}
|
{/* Self-service profile for internal roles */}
|
||||||
<Route path="/profile" element={
|
<Route path="/profile" element={
|
||||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
|
||||||
@@ -464,6 +482,22 @@ function App() {
|
|||||||
} />
|
} />
|
||||||
</Route>
|
</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 */}
|
{/* Catch all */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
|
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid, Settings, CloudLightning,
|
||||||
HardHat, ShieldCheck, Sparkles, Megaphone, Rocket
|
HardHat, ShieldCheck, Sparkles, Megaphone, Rocket, CreditCard
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import PageTransition from './PageTransition';
|
import PageTransition from './PageTransition';
|
||||||
@@ -155,6 +155,7 @@ const Layout = () => {
|
|||||||
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
|
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
|
||||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||||
{ to: "/owner/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
{ to: "/owner/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
||||||
|
{ to: "/owner/payments", icon: CreditCard, label: "Payment Management" },
|
||||||
{ to: "/owner/people", icon: Users, label: "People" },
|
{ to: "/owner/people", icon: Users, label: "People" },
|
||||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
@@ -181,6 +182,7 @@ const Layout = () => {
|
|||||||
},
|
},
|
||||||
{ to: "/admin/estimates", icon: Calculator, label: "Estimates" },
|
{ to: "/admin/estimates", icon: Calculator, label: "Estimates" },
|
||||||
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
||||||
|
{ to: "/owner/payments", icon: CreditCard, label: "Payment Management" },
|
||||||
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
|
||||||
@@ -221,6 +223,7 @@ const Layout = () => {
|
|||||||
return [
|
return [
|
||||||
{ to: "/portal/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/portal/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/portal/profile", icon: User, label: "My Profile" },
|
{ to: "/portal/profile", icon: User, label: "My Profile" },
|
||||||
|
{ to: "/portal/payments", icon: CreditCard, label: "Payments" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -652,9 +652,9 @@ function generateProperties() {
|
|||||||
|
|
||||||
const MOCK_USERS = [
|
const MOCK_USERS = [
|
||||||
// Customers
|
// Customers
|
||||||
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' },
|
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600', phone: '214-555-2600' },
|
||||||
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer' },
|
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer', phone: '214-555-2601' },
|
||||||
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' },
|
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client', phone: '214-555-2602' },
|
||||||
|
|
||||||
// Field Agents (5 Agents)
|
// Field Agents (5 Agents)
|
||||||
{ id: 'e1', type: 'employee', empId: 'LUP-1040', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Cody Tatum', xp: 12450, doorsKnocked: 342, leadsGained: 45, appointmentsSet: 18, streakDays: 5, achievements: ['Hot Spot Hunter', 'Storm Chaser'] },
|
{ id: 'e1', type: 'employee', empId: 'LUP-1040', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Cody Tatum', xp: 12450, doorsKnocked: 342, leadsGained: 45, appointmentsSet: 18, streakDays: 5, achievements: ['Hot Spot Hunter', 'Storm Chaser'] },
|
||||||
|
|||||||
@@ -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,142 @@
|
|||||||
|
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,
|
||||||
|
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
|
||||||
|
});
|
||||||
|
} 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,156 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { AlertCircle, CalendarClock, FileText, Lock, Loader2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { SpotlightCard } from '../../../components/SpotlightCard';
|
||||||
|
import { formatAmount } from '../data/invoices';
|
||||||
|
import { stripeConfig } from '../config/stripeConfig';
|
||||||
|
import { startCheckout } from '../services/checkoutService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outstanding tab — summarizes everything the customer still owes: a headline
|
||||||
|
* total-due figure plus the list of unpaid invoices, with overdue ones flagged.
|
||||||
|
* Reads the same invoice/paid data as the "Make a Payment" tab so the two views
|
||||||
|
* never disagree.
|
||||||
|
*/
|
||||||
|
const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [redirectingId, setRedirectingId] = useState(null);
|
||||||
|
|
||||||
|
const due = useMemo(
|
||||||
|
() =>
|
||||||
|
invoices
|
||||||
|
.filter((i) => !(i.status === 'paid' || paidIds.includes(i.id)))
|
||||||
|
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)),
|
||||||
|
[invoices, paidIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Total owed, assuming all unpaid invoices share a currency (they do here).
|
||||||
|
const totalCents = due.reduce((sum, i) => sum + (i.amount || 0), 0);
|
||||||
|
const currency = due[0]?.currency || 'usd';
|
||||||
|
|
||||||
|
// Midnight today, so an invoice due today is not counted as overdue.
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const isOverdue = (inv) => new Date(inv.dueDate) < today;
|
||||||
|
const overdueCount = due.filter(isOverdue).length;
|
||||||
|
|
||||||
|
const handlePay = async (invoice) => {
|
||||||
|
if (redirectingId) return;
|
||||||
|
setRedirectingId(invoice.id);
|
||||||
|
|
||||||
|
if (!stripeConfig.isConfigured) {
|
||||||
|
navigate(`/payments/demo-checkout?invoice=${encodeURIComponent(invoice.id)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startCheckout({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
customerEmail,
|
||||||
|
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Unable to start checkout', { description: err.message });
|
||||||
|
setRedirectingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (due.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
||||||
|
You have no outstanding balance — everything is paid up. 🎉
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Total due summary */}
|
||||||
|
<SpotlightCard className="p-6 md:p-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||||
|
Total Outstanding
|
||||||
|
</p>
|
||||||
|
<p className="text-4xl md:text-5xl font-black text-zinc-900 dark:text-white mt-1">
|
||||||
|
{formatAmount(totalCents, currency, stripeConfig.locale)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-2">
|
||||||
|
Across {due.length} invoice{due.length === 1 ? '' : 's'}
|
||||||
|
{overdueCount > 0 && (
|
||||||
|
<span className="text-red-500 dark:text-red-400 font-semibold">
|
||||||
|
{' '}· {overdueCount} overdue
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center text-amber-500 border border-amber-100 dark:border-amber-500/20 shrink-0">
|
||||||
|
<CalendarClock size={30} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Outstanding invoice list */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{due.map((inv) => {
|
||||||
|
const busy = redirectingId === inv.id;
|
||||||
|
const overdue = isOverdue(inv);
|
||||||
|
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 mt-1 flex items-center gap-1 ${
|
||||||
|
overdue
|
||||||
|
? 'text-red-500 dark:text-red-400 font-semibold'
|
||||||
|
: 'text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{overdue && <AlertCircle size={12} />}
|
||||||
|
{overdue ? 'Overdue · ' : 'Due '}
|
||||||
|
{new Date(inv.dueDate).toLocaleDateString()}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OutstandingPanel;
|
||||||
@@ -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,84 @@
|
|||||||
|
/**
|
||||||
|
* PaymentProvider — module-local state for the customer Payments portal.
|
||||||
|
*
|
||||||
|
* Invoices and transactions are sourced from the shared paymentStore (localStorage)
|
||||||
|
* so the customer sees payment requests created on the owner side, and payments
|
||||||
|
* made here flow back to the owner's Payment Management page. The server-side
|
||||||
|
* (webhook-written) transaction history is merged in on top when available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { stripeConfig } from '../config/stripeConfig';
|
||||||
|
import {
|
||||||
|
loadInvoices,
|
||||||
|
loadTransactions,
|
||||||
|
saveTransactions,
|
||||||
|
subscribe,
|
||||||
|
} from '../data/paymentStore';
|
||||||
|
|
||||||
|
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, setInvoices] = useState(loadInvoices);
|
||||||
|
const [transactions, setTransactions] = useState(loadTransactions);
|
||||||
|
|
||||||
|
// Re-read from the shared store whenever the owner adds an invoice, marks
|
||||||
|
// one paid, or a payment is recorded (same-tab and cross-tab).
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribe(() => {
|
||||||
|
setInvoices(loadInvoices());
|
||||||
|
setTransactions((prev) => mergeById(prev, loadTransactions()));
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
saveTransactions(next); // persists to the shared store + notifies the owner side
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2055',
|
||||||
|
title: 'Siding Replacement — West Wall',
|
||||||
|
description: 'Storm-damaged vinyl siding replacement, 3 panels.',
|
||||||
|
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||||
|
dueDate: '2026-07-10',
|
||||||
|
amount: 95000,
|
||||||
|
currency: 'usd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2061',
|
||||||
|
title: 'Chimney Flashing Repair & Sealant',
|
||||||
|
description: 'Re-flashed chimney base and applied weatherproof sealant.',
|
||||||
|
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||||
|
dueDate: '2026-05-30',
|
||||||
|
amount: 42500,
|
||||||
|
currency: 'usd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2063',
|
||||||
|
title: 'Skylight Installation — Living Room',
|
||||||
|
description: 'Supply and install one energy-efficient skylight unit.',
|
||||||
|
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||||
|
dueDate: '2026-06-28',
|
||||||
|
amount: 128000,
|
||||||
|
currency: 'usd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2067',
|
||||||
|
title: 'Annual Roof Maintenance Plan',
|
||||||
|
description: 'Twice-yearly inspection and minor repair coverage.',
|
||||||
|
property: '2612 Dunwick Dr, Plano, TX 75023',
|
||||||
|
dueDate: '2026-08-01',
|
||||||
|
amount: 56000,
|
||||||
|
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);
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Shared payment store — the single source of truth that lets the OWNER/ADMIN
|
||||||
|
* side and the CUSTOMER side talk to each other in this demo (no backend).
|
||||||
|
*
|
||||||
|
* Both invoices and transactions live in localStorage so a payment request the
|
||||||
|
* owner creates appears in the customer's Outstanding list, and a payment the
|
||||||
|
* customer makes (or the owner marks manually) shows back on the owner's
|
||||||
|
* Payment Management page. In production these reads/writes would hit the CRM
|
||||||
|
* billing API instead — the component surface would stay the same.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { INVOICES } from './invoices';
|
||||||
|
|
||||||
|
const INVOICES_KEY = 'lynkeduppro.payments.invoices';
|
||||||
|
const TXNS_KEY = 'lynkeduppro.payments.transactions';
|
||||||
|
const UPDATE_EVENT = 'payments:updated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default customer mobile number for the demo. The owner/admin "New Payment
|
||||||
|
* Request" form prefills this so a request lands with the current demo
|
||||||
|
* customer. In production this comes from a customer lookup instead.
|
||||||
|
*/
|
||||||
|
export const DEMO_CUSTOMER_PHONE = '214-555-2600';
|
||||||
|
|
||||||
|
/** Compare two phone numbers by digits only (ignores spaces, dashes, etc.). */
|
||||||
|
export const normalizePhone = (phone) => String(phone || '').replace(/\D/g, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this invoice belong to the given customer phone? Invoices without a
|
||||||
|
* `customerPhone` are treated as unassigned and visible to any customer (keeps
|
||||||
|
* the original seed invoices working).
|
||||||
|
*/
|
||||||
|
export const invoiceMatchesCustomer = (invoice, phone) =>
|
||||||
|
!invoice.customerPhone ||
|
||||||
|
normalizePhone(invoice.customerPhone) === normalizePhone(phone);
|
||||||
|
|
||||||
|
const read = (key, fallback) => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? JSON.parse(raw) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = (key, value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable (private mode) — best effort only. */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Notify same-tab listeners (the native `storage` event only fires cross-tab). */
|
||||||
|
const emit = () => {
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new Event(UPDATE_EVENT));
|
||||||
|
} catch {
|
||||||
|
/* non-browser / SSR — nothing to notify. */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribe to any invoice/transaction change. Returns an unsubscribe fn. */
|
||||||
|
export const subscribe = (callback) => {
|
||||||
|
window.addEventListener(UPDATE_EVENT, callback);
|
||||||
|
window.addEventListener('storage', callback);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(UPDATE_EVENT, callback);
|
||||||
|
window.removeEventListener('storage', callback);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------- Invoices -------------------------------- */
|
||||||
|
|
||||||
|
/** Load invoices, seeding from the static sample list on first run. */
|
||||||
|
export const loadInvoices = () => {
|
||||||
|
const stored = read(INVOICES_KEY, null);
|
||||||
|
if (Array.isArray(stored)) return stored;
|
||||||
|
write(INVOICES_KEY, INVOICES);
|
||||||
|
return INVOICES;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveInvoices = (list) => {
|
||||||
|
write(INVOICES_KEY, list);
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInvoice = (id) => loadInvoices().find((i) => i.id === id) || null;
|
||||||
|
|
||||||
|
/** Append a new invoice (payment request) and return it. */
|
||||||
|
export const addInvoice = (invoice) => {
|
||||||
|
const next = [...loadInvoices(), invoice];
|
||||||
|
saveInvoices(next);
|
||||||
|
return invoice;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Next sequential invoice id, e.g. "INV-2068". */
|
||||||
|
export const nextInvoiceId = () => {
|
||||||
|
const max = loadInvoices().reduce((m, inv) => {
|
||||||
|
const n = parseInt(String(inv.id).replace(/\D/g, ''), 10);
|
||||||
|
return Number.isFinite(n) && n > m ? n : m;
|
||||||
|
}, 2000);
|
||||||
|
return `INV-${max + 1}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------- Transactions ------------------------------ */
|
||||||
|
|
||||||
|
export const loadTransactions = () => read(TXNS_KEY, []);
|
||||||
|
|
||||||
|
export const saveTransactions = (list) => {
|
||||||
|
write(TXNS_KEY, list);
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Has this invoice been paid (by the customer or marked by the owner)? */
|
||||||
|
export const isInvoicePaid = (invoiceId) =>
|
||||||
|
loadTransactions().some((t) => t.invoiceId === invoiceId && t.status === 'paid');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice paid by writing a `paid` transaction — used both by the
|
||||||
|
* customer success flow and by the owner's manual "Mark as Paid" action.
|
||||||
|
* No-ops if a paid transaction for this invoice already exists.
|
||||||
|
*/
|
||||||
|
export const markInvoicePaid = (invoiceId, { method = 'manual' } = {}) => {
|
||||||
|
const inv = getInvoice(invoiceId);
|
||||||
|
if (!inv || isInvoicePaid(invoiceId)) return null;
|
||||||
|
|
||||||
|
const txn = {
|
||||||
|
id: `${method}-${invoiceId}-${Date.now()}`,
|
||||||
|
invoiceId,
|
||||||
|
amount: inv.amount,
|
||||||
|
currency: inv.currency,
|
||||||
|
customerEmail: null,
|
||||||
|
status: 'paid',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
method,
|
||||||
|
};
|
||||||
|
saveTransactions([txn, ...loadTransactions()]);
|
||||||
|
return txn;
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Lock, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { formatAmount } from '../data/invoices';
|
||||||
|
import { getInvoice } from '../data/paymentStore';
|
||||||
|
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 = getInvoice(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}¤cy=${invoice.currency}`
|
||||||
|
);
|
||||||
|
}, 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<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,92 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { CreditCard } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../../context/AuthContext';
|
||||||
|
import { usePayments } from '../context/PaymentProvider';
|
||||||
|
import { invoiceMatchesCustomer } from '../data/paymentStore';
|
||||||
|
import InvoiceList from '../components/InvoiceList';
|
||||||
|
import OutstandingPanel from '../components/OutstandingPanel';
|
||||||
|
import TransactionHistory from '../components/TransactionHistory';
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Only show invoices addressed to this customer's mobile number (plus any
|
||||||
|
// unassigned seed invoices). Keeps one customer from seeing another's bills.
|
||||||
|
const myInvoices = invoices.filter((inv) => invoiceMatchesCustomer(inv, user?.phone));
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
{/* 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="outstanding" label="Outstanding" 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={myInvoices}
|
||||||
|
paidIds={paidIds}
|
||||||
|
customerEmail={user?.email || user?.username}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'outstanding' && (
|
||||||
|
<OutstandingPanel
|
||||||
|
invoices={myInvoices}
|
||||||
|
paidIds={paidIds}
|
||||||
|
customerEmail={user?.email || user?.username}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<TransactionHistory transactions={transactions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentsPage;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 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, fallback } = {}) {
|
||||||
|
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' },
|
||||||
|
// `fallback` carries the invoice's amount/title for demo invoices the
|
||||||
|
// owner created in localStorage (the server's static map won't have them).
|
||||||
|
body: JSON.stringify({ invoiceId, customerEmail, fallback }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
CreditCard, Plus, X, FileText, CheckCircle2, Clock, AlertCircle,
|
||||||
|
DollarSign, TrendingUp, Receipt, Phone,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import { formatAmount } from '../../modules/payments/data/invoices';
|
||||||
|
import {
|
||||||
|
loadInvoices, loadTransactions, addInvoice, markInvoicePaid,
|
||||||
|
nextInvoiceId, subscribe, DEMO_CUSTOMER_PHONE, normalizePhone,
|
||||||
|
} from '../../modules/payments/data/paymentStore';
|
||||||
|
|
||||||
|
const DEFAULT_PROPERTY = '2612 Dunwick Dr, Plano, TX 75023';
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
amount: '',
|
||||||
|
dueDate: '',
|
||||||
|
property: DEFAULT_PROPERTY,
|
||||||
|
customerPhone: DEMO_CUSTOMER_PHONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner/Admin Payment Management — create payment requests that land in the
|
||||||
|
* customer's Outstanding list, then track which have been paid. A request is
|
||||||
|
* "Paid" once a paid transaction references it (the customer pays via the
|
||||||
|
* portal, or the owner marks it paid manually here for offline/cash payments).
|
||||||
|
*/
|
||||||
|
const PaymentManagement = () => {
|
||||||
|
const [invoices, setInvoices] = useState(loadInvoices);
|
||||||
|
const [transactions, setTransactions] = useState(loadTransactions);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
|
||||||
|
// Stay in sync with payments made on the customer side.
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribe(() => {
|
||||||
|
setInvoices(loadInvoices());
|
||||||
|
setTransactions(loadTransactions());
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const paidIds = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
transactions
|
||||||
|
.filter((t) => t.status === 'paid' && t.invoiceId)
|
||||||
|
.map((t) => t.invoiceId)
|
||||||
|
),
|
||||||
|
[transactions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPaid = useCallback(
|
||||||
|
(inv) => inv.status === 'paid' || paidIds.has(inv.id),
|
||||||
|
[paidIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
[...invoices].sort((a, b) => {
|
||||||
|
// Outstanding first, then by due date.
|
||||||
|
const ap = isPaid(a) ? 1 : 0;
|
||||||
|
const bp = isPaid(b) ? 1 : 0;
|
||||||
|
if (ap !== bp) return ap - bp;
|
||||||
|
return new Date(a.dueDate) - new Date(b.dueDate);
|
||||||
|
}),
|
||||||
|
[invoices, isPaid]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let outstanding = 0;
|
||||||
|
let collected = 0;
|
||||||
|
invoices.forEach((inv) => {
|
||||||
|
if (isPaid(inv)) collected += inv.amount || 0;
|
||||||
|
else outstanding += inv.amount || 0;
|
||||||
|
});
|
||||||
|
return { outstanding, collected };
|
||||||
|
}, [invoices, isPaid]);
|
||||||
|
|
||||||
|
const handleField = (e) =>
|
||||||
|
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||||
|
|
||||||
|
const handleCreate = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const amountNum = parseFloat(form.amount);
|
||||||
|
if (!form.title.trim()) return toast.error('Title is required.');
|
||||||
|
if (!Number.isFinite(amountNum) || amountNum <= 0)
|
||||||
|
return toast.error('Enter a valid amount greater than 0.');
|
||||||
|
if (!form.dueDate) return toast.error('Due date is required.');
|
||||||
|
if (normalizePhone(form.customerPhone).length < 7)
|
||||||
|
return toast.error('Enter a valid customer mobile number.');
|
||||||
|
|
||||||
|
const invoice = {
|
||||||
|
id: nextInvoiceId(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
property: form.property.trim() || DEFAULT_PROPERTY,
|
||||||
|
customerPhone: form.customerPhone.trim(),
|
||||||
|
dueDate: form.dueDate,
|
||||||
|
amount: Math.round(amountNum * 100), // dollars → cents
|
||||||
|
currency: 'usd',
|
||||||
|
};
|
||||||
|
addInvoice(invoice);
|
||||||
|
toast.success(`Payment request ${invoice.id} sent`, {
|
||||||
|
description: `${formatAmount(invoice.amount, 'usd')} sent to ${invoice.customerPhone}.`,
|
||||||
|
});
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkPaid = (inv) => {
|
||||||
|
const txn = markInvoicePaid(inv.id, { method: 'manual' });
|
||||||
|
if (txn) toast.success(`${inv.id} marked as paid`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-indigo-500/5 dark:bg-indigo-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center border-b border-zinc-200 dark:border-white/5 pb-6 gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white shadow-lg shrink-0">
|
||||||
|
<CreditCard size={26} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
||||||
|
Payment Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1 font-light">
|
||||||
|
Request payments and track what customers owe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm((s) => !s)}
|
||||||
|
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl text-sm transition-colors shadow-lg shadow-blue-500/20 flex items-center gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
{showForm ? <><X size={16} /> Close</> : <><Plus size={16} /> New Payment Request</>}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* New request form */}
|
||||||
|
{showForm && (
|
||||||
|
<SpotlightCard className="p-6 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
|
<Receipt size={18} className="text-blue-500" /> New Payment Request
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleCreate} className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Field label="Title" className="md:col-span-2">
|
||||||
|
<input
|
||||||
|
name="title" value={form.title} onChange={handleField}
|
||||||
|
placeholder="e.g. Roof Repair — Shingle Replacement"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description" className="md:col-span-2">
|
||||||
|
<input
|
||||||
|
name="description" value={form.description} onChange={handleField}
|
||||||
|
placeholder="Short description of the work / charge"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Amount (USD)">
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
name="amount" value={form.amount} onChange={handleField}
|
||||||
|
type="number" min="0" step="0.01" placeholder="950.00"
|
||||||
|
className={`${inputCls} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Due date">
|
||||||
|
<input
|
||||||
|
name="dueDate" value={form.dueDate} onChange={handleField}
|
||||||
|
type="date" className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Customer Mobile Number" className="md:col-span-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Phone size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
name="customerPhone" value={form.customerPhone} onChange={handleField}
|
||||||
|
type="tel" placeholder="214-555-2600"
|
||||||
|
className={`${inputCls} pl-9`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-zinc-400 mt-1 block">
|
||||||
|
Request goes to the customer with this mobile number.
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Property" className="md:col-span-2">
|
||||||
|
<input
|
||||||
|
name="property" value={form.property} onChange={handleField}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="md:col-span-2 flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button" onClick={() => { setForm(EMPTY_FORM); setShowForm(false); }}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-bold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={15} /> Send Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI cards */}
|
||||||
|
<section className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Kpi icon={<Clock size={20} />} color="text-amber-500" label="Total Outstanding"
|
||||||
|
value={formatAmount(totals.outstanding, 'usd')} />
|
||||||
|
<Kpi icon={<TrendingUp size={20} />} color="text-emerald-500" label="Total Collected"
|
||||||
|
value={formatAmount(totals.collected, 'usd')} />
|
||||||
|
<Kpi icon={<FileText size={20} />} color="text-blue-500" label="Payment Requests"
|
||||||
|
value={invoices.length} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Requests list */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-bold mb-4">All Payment Requests</h2>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{rows.map((inv) => {
|
||||||
|
const paid = isPaid(inv);
|
||||||
|
const overdue = !paid && new Date(inv.dueDate) < today;
|
||||||
|
return (
|
||||||
|
<SpotlightCard key={inv.id} className="p-4 md:p-5">
|
||||||
|
<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 flex items-center justify-center shrink-0 border ${
|
||||||
|
paid
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-100 dark:border-blue-500/20'
|
||||||
|
}`}>
|
||||||
|
<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-xs text-zinc-400 mt-1 truncate flex items-center gap-2">
|
||||||
|
{inv.customerPhone && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Phone size={11} /> {inv.customerPhone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{inv.property}</span>
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 flex items-center gap-1 ${
|
||||||
|
overdue ? 'text-red-500 dark:text-red-400 font-semibold' : 'text-zinc-400'
|
||||||
|
}`}>
|
||||||
|
{overdue && <AlertCircle size={12} />}
|
||||||
|
{overdue ? 'Overdue · ' : 'Due '}{new Date(inv.dueDate).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between md:justify-end gap-4 shrink-0">
|
||||||
|
<span className="text-lg font-black">
|
||||||
|
{formatAmount(inv.amount, inv.currency)}
|
||||||
|
</span>
|
||||||
|
{paid ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold border border-emerald-100 dark:border-emerald-500/20">
|
||||||
|
<CheckCircle2 size={14} /> Paid
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkPaid(inv)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-white/10 text-zinc-700 dark:text-zinc-200 text-xs font-bold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} /> Mark as Paid
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
||||||
|
No payment requests yet — create one to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'w-full px-3 py-2 rounded-lg bg-white dark:bg-zinc-900/60 border border-zinc-300 dark:border-white/10 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 transition';
|
||||||
|
|
||||||
|
const Field = ({ label, className = '', children }) => (
|
||||||
|
<label className={`block ${className}`}>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 mb-1.5 block">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Kpi = ({ icon, color, label, value }) => (
|
||||||
|
<SpotlightCard className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 ${color}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{label}</p>
|
||||||
|
<p className="text-2xl font-black mt-0.5">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PaymentManagement;
|
||||||
+25
-1
@@ -22,6 +22,11 @@ const DEV_API_HANDLERS = {
|
|||||||
'hail-proxy': () => import('./api/hail-proxy.js'),
|
'hail-proxy': () => import('./api/hail-proxy.js'),
|
||||||
'storm-events': () => import('./api/storm-events.js'),
|
'storm-events': () => import('./api/storm-events.js'),
|
||||||
'storm-polygons': () => import('./api/storm-polygons.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).
|
// Server-side env vars the handlers read (mirrors what you'd set in Vercel).
|
||||||
@@ -30,11 +35,17 @@ const SERVER_ENV_KEYS = [
|
|||||||
'HAIL_RECON_ACCESS_KEY',
|
'HAIL_RECON_ACCESS_KEY',
|
||||||
'HAIL_RECON_ACCESS_SECRET',
|
'HAIL_RECON_ACCESS_SECRET',
|
||||||
'HAIL_RECON_WEBHOOK_SECRET',
|
'HAIL_RECON_WEBHOOK_SECRET',
|
||||||
|
'STRIPE_SECRET_KEY',
|
||||||
|
'STRIPE_WEBHOOK_SECRET',
|
||||||
]
|
]
|
||||||
|
|
||||||
function devApi(env) {
|
function devApi(env) {
|
||||||
|
// .env is authoritative in dev: always apply it so edits are picked up on a
|
||||||
|
// server restart. (A `!process.env[k]` guard here would keep a stale value
|
||||||
|
// from a previous load — e.g. the placeholder secret key — and silently
|
||||||
|
// ignore the real key you just added.)
|
||||||
for (const k of SERVER_ENV_KEYS) {
|
for (const k of SERVER_ENV_KEYS) {
|
||||||
if (env[k] && !process.env[k]) process.env[k] = env[k]
|
if (env[k]) process.env[k] = env[k]
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: 'dev-api',
|
name: 'dev-api',
|
||||||
@@ -62,6 +73,19 @@ function devApi(env) {
|
|||||||
res.end(JSON.stringify(obj))
|
res.end(JSON.stringify(obj))
|
||||||
return res
|
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 {
|
try {
|
||||||
const mod = await load()
|
const mod = await load()
|
||||||
|
|||||||
Reference in New Issue
Block a user