From f45955191fab5c821bf453589d9d56a8e9ce0bfb Mon Sep 17 00:00:00 2001 From: Goutam Date: Fri, 12 Jun 2026 20:49:34 +0530 Subject: [PATCH] Updated project changes --- api/payments/create-checkout-session.js | 20 +- src/App.jsx | 8 + src/components/Layout.jsx | 2 + src/data/mockStore.jsx | 6 +- .../payments/components/InvoiceList.jsx | 6 +- .../payments/components/OutstandingPanel.jsx | 6 +- .../payments/context/PaymentProvider.jsx | 55 ++- src/modules/payments/data/paymentStore.js | 140 ++++++++ src/modules/payments/pages/DemoCheckout.jsx | 13 +- src/modules/payments/pages/PaymentsPage.jsx | 13 +- .../payments/services/checkoutService.js | 6 +- src/pages/owner/PaymentManagement.jsx | 340 ++++++++++++++++++ vite.config.js | 6 +- 13 files changed, 566 insertions(+), 55 deletions(-) create mode 100644 src/modules/payments/data/paymentStore.js create mode 100644 src/pages/owner/PaymentManagement.jsx diff --git a/api/payments/create-checkout-session.js b/api/payments/create-checkout-session.js index d94b3e7..dda686e 100644 --- a/api/payments/create-checkout-session.js +++ b/api/payments/create-checkout-session.js @@ -38,8 +38,24 @@ export default async function handler(req, res) { } try { - const { invoiceId, customerEmail } = req.body || {}; - const invoice = INVOICES[invoiceId]; + 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}` }); } diff --git a/src/App.jsx b/src/App.jsx index f13f236..00e117c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard'; import VendorOrders from './pages/vendor/VendorOrders'; import ContractorDashboard from './pages/contractor/ContractorDashboard'; import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard'; +import PaymentManagement from './pages/owner/PaymentManagement'; import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage'; import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage'; import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage'; @@ -100,6 +101,13 @@ function App() { } /> + {/* Payment Management (owner/admin side of the Payments module) */} + + + + } /> + {/* Self-service profile for internal roles */} diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index eb5190c..588d5b5 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -153,6 +153,7 @@ const Layout = () => { { to: "/admin/schedule", icon: Calendar, label: "Team Schedule" }, { to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" }, { 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/settings", icon: Settings, label: "Org Settings" }, ...commonItems, @@ -175,6 +176,7 @@ const Layout = () => { }, { to: "/admin/estimates", icon: Calculator, label: "Estimates" }, { 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" }, ...commonItems, ]; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 57c5c69..9d4e895 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -652,9 +652,9 @@ function generateProperties() { const MOCK_USERS = [ // Customers - { id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' }, - { id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer' }, - { id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' }, + { 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', phone: '214-555-2601' }, + { id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client', phone: '214-555-2602' }, // 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'] }, diff --git a/src/modules/payments/components/InvoiceList.jsx b/src/modules/payments/components/InvoiceList.jsx index e818c8b..af832fd 100644 --- a/src/modules/payments/components/InvoiceList.jsx +++ b/src/modules/payments/components/InvoiceList.jsx @@ -32,7 +32,11 @@ const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => { try { // On success this navigates away to Stripe; control won't return. - await startCheckout({ invoiceId: invoice.id, customerEmail }); + 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); diff --git a/src/modules/payments/components/OutstandingPanel.jsx b/src/modules/payments/components/OutstandingPanel.jsx index 6f18774..8c73c03 100644 --- a/src/modules/payments/components/OutstandingPanel.jsx +++ b/src/modules/payments/components/OutstandingPanel.jsx @@ -46,7 +46,11 @@ const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => { } try { - await startCheckout({ invoiceId: invoice.id, customerEmail }); + 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); diff --git a/src/modules/payments/context/PaymentProvider.jsx b/src/modules/payments/context/PaymentProvider.jsx index c6b4fa2..e24979f 100644 --- a/src/modules/payments/context/PaymentProvider.jsx +++ b/src/modules/payments/context/PaymentProvider.jsx @@ -1,36 +1,20 @@ /** - * PaymentProvider — module-local state, fully independent from the app's global - * mockStore so the Payments module can be added/removed in isolation. + * PaymentProvider — module-local state for the customer Payments portal. * - * Transaction history is sourced from two places and merged by id: - * 1. The server (api/payments/transactions → Vercel KV, written by the - * webhook) — the production source of truth. - * 2. A local record (localStorage) written when a customer returns to the - * success page — gives instant feedback and works without KV in the demo. + * 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 { INVOICES } from '../data/invoices'; import { stripeConfig } from '../config/stripeConfig'; - -const STORAGE_KEY = 'lynkeduppro.payments.transactions'; - -const loadLocal = () => { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? JSON.parse(raw) : []; - } catch { - return []; - } -}; - -const persistLocal = (txns) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(txns)); - } catch { - /* localStorage unavailable (private mode) — keep in-memory only. */ - } -}; +import { + loadInvoices, + loadTransactions, + saveTransactions, + subscribe, +} from '../data/paymentStore'; const mergeById = (a, b) => { const map = new Map(); @@ -45,8 +29,19 @@ const mergeById = (a, b) => { const PaymentContext = createContext(null); export const PaymentProvider = ({ children }) => { - const [invoices] = useState(INVOICES); - const [transactions, setTransactions] = useState(loadLocal); + 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(() => { @@ -72,7 +67,7 @@ export const PaymentProvider = ({ children }) => { if (!txn?.id) return; setTransactions((prev) => { const next = mergeById(prev, [txn]); - persistLocal(next); + saveTransactions(next); // persists to the shared store + notifies the owner side return next; }); }, []); diff --git a/src/modules/payments/data/paymentStore.js b/src/modules/payments/data/paymentStore.js new file mode 100644 index 0000000..db49d9c --- /dev/null +++ b/src/modules/payments/data/paymentStore.js @@ -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; +}; diff --git a/src/modules/payments/pages/DemoCheckout.jsx b/src/modules/payments/pages/DemoCheckout.jsx index 510009c..de1d72e 100644 --- a/src/modules/payments/pages/DemoCheckout.jsx +++ b/src/modules/payments/pages/DemoCheckout.jsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; -import { Lock, Loader2, ArrowLeft, FlaskConical, CheckCircle2 } from 'lucide-react'; -import { INVOICES, formatAmount } from '../data/invoices'; +import { Lock, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react'; +import { formatAmount } from '../data/invoices'; +import { getInvoice } from '../data/paymentStore'; import { stripeConfig } from '../config/stripeConfig'; /** @@ -17,7 +18,7 @@ const DemoCheckout = () => { const [params] = useSearchParams(); const navigate = useNavigate(); const invoiceId = params.get('invoice'); - const invoice = INVOICES.find((i) => i.id === invoiceId); + const invoice = getInvoice(invoiceId); const [paying, setPaying] = useState(false); @@ -46,12 +47,6 @@ const DemoCheckout = () => { return ( - {/* Demo banner */} -
- - Demo mode — simulated Stripe Checkout. No real card or charge. Add Stripe keys to go live. -
-
{/* Left: order summary (Stripe-style) */}
diff --git a/src/modules/payments/pages/PaymentsPage.jsx b/src/modules/payments/pages/PaymentsPage.jsx index 785c23a..46ab890 100644 --- a/src/modules/payments/pages/PaymentsPage.jsx +++ b/src/modules/payments/pages/PaymentsPage.jsx @@ -2,11 +2,10 @@ import React, { useState } from 'react'; import { CreditCard } from 'lucide-react'; import { useAuth } from '../../../context/AuthContext'; import { usePayments } from '../context/PaymentProvider'; -import { stripeConfig } from '../config/stripeConfig'; +import { invoiceMatchesCustomer } from '../data/paymentStore'; import InvoiceList from '../components/InvoiceList'; import OutstandingPanel from '../components/OutstandingPanel'; import TransactionHistory from '../components/TransactionHistory'; -import StripeConfigNotice from '../components/StripeConfigNotice'; const TabButton = ({ id, label, activeTab, onSelect }) => (
- {!stripeConfig.isConfigured && } - {/* Tabs */}
@@ -67,14 +68,14 @@ const PaymentsPage = () => {
{activeTab === 'pay' && ( )} {activeTab === 'outstanding' && ( diff --git a/src/modules/payments/services/checkoutService.js b/src/modules/payments/services/checkoutService.js index a17a723..c18c033 100644 --- a/src/modules/payments/services/checkoutService.js +++ b/src/modules/payments/services/checkoutService.js @@ -25,7 +25,7 @@ const getStripe = () => { * 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 } = {}) { +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.' @@ -37,7 +37,9 @@ export async function startCheckout({ invoiceId, customerEmail } = {}) { const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ invoiceId, customerEmail }), + // `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(() => ({})); diff --git a/src/pages/owner/PaymentManagement.jsx b/src/pages/owner/PaymentManagement.jsx new file mode 100644 index 0000000..5b17b5f --- /dev/null +++ b/src/pages/owner/PaymentManagement.jsx @@ -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 ( +
+
+
+
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

+ Payment Management +

+

+ Request payments and track what customers owe. +

+
+
+ +
+ + {/* New request form */} + {showForm && ( + +

+ New Payment Request +

+
+ + + + + + + +
+ + +
+
+ + + + +
+ + +
+ + Request goes to the customer with this mobile number. + +
+ + + +
+ + +
+
+
+ )} + + {/* KPI cards */} +
+ } color="text-amber-500" label="Total Outstanding" + value={formatAmount(totals.outstanding, 'usd')} /> + } color="text-emerald-500" label="Total Collected" + value={formatAmount(totals.collected, 'usd')} /> + } color="text-blue-500" label="Payment Requests" + value={invoices.length} /> +
+ + {/* Requests list */} +
+

All Payment Requests

+
+ {rows.map((inv) => { + const paid = isPaid(inv); + const overdue = !paid && new Date(inv.dueDate) < today; + return ( + +
+
+
+ +
+
+
+

{inv.title}

+ {inv.id} +
+

+ {inv.customerPhone && ( + + {inv.customerPhone} + + )} + {inv.property} +

+

+ {overdue && } + {overdue ? 'Overdue · ' : 'Due '}{new Date(inv.dueDate).toLocaleDateString()} +

+
+
+ +
+ + {formatAmount(inv.amount, inv.currency)} + + {paid ? ( + + Paid + + ) : ( + + )} +
+
+
+ ); + })} + {rows.length === 0 && ( +
+ No payment requests yet — create one to get started. +
+ )} +
+
+
+
+ ); +}; + +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 }) => ( + +); + +const Kpi = ({ icon, color, label, value }) => ( + +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+
+); + +export default PaymentManagement; diff --git a/vite.config.js b/vite.config.js index 8f55add..fb44572 100644 --- a/vite.config.js +++ b/vite.config.js @@ -40,8 +40,12 @@ const SERVER_ENV_KEYS = [ ] 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) { - if (env[k] && !process.env[k]) process.env[k] = env[k] + if (env[k]) process.env[k] = env[k] } return { name: 'dev-api',