Updated project changes
This commit is contained in:
@@ -38,8 +38,24 @@ export default async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { invoiceId, customerEmail } = req.body || {};
|
const { invoiceId, customerEmail, fallback } = req.body || {};
|
||||||
const invoice = INVOICES[invoiceId];
|
|
||||||
|
// 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) {
|
if (!invoice) {
|
||||||
return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` });
|
return res.status(404).json({ error: `Unknown invoice: ${invoiceId}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,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';
|
||||||
@@ -100,6 +101,13 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</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']}>
|
||||||
|
|||||||
@@ -153,6 +153,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,
|
||||||
@@ -175,6 +176,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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// On success this navigates away to Stripe; control won't return.
|
// 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) {
|
} catch (err) {
|
||||||
toast.error('Unable to start checkout', { description: err.message });
|
toast.error('Unable to start checkout', { description: err.message });
|
||||||
setRedirectingId(null);
|
setRedirectingId(null);
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
toast.error('Unable to start checkout', { description: err.message });
|
toast.error('Unable to start checkout', { description: err.message });
|
||||||
setRedirectingId(null);
|
setRedirectingId(null);
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* PaymentProvider — module-local state, fully independent from the app's global
|
* PaymentProvider — module-local state for the customer Payments portal.
|
||||||
* mockStore so the Payments module can be added/removed in isolation.
|
|
||||||
*
|
*
|
||||||
* Transaction history is sourced from two places and merged by id:
|
* Invoices and transactions are sourced from the shared paymentStore (localStorage)
|
||||||
* 1. The server (api/payments/transactions → Vercel KV, written by the
|
* so the customer sees payment requests created on the owner side, and payments
|
||||||
* webhook) — the production source of truth.
|
* made here flow back to the owner's Payment Management page. The server-side
|
||||||
* 2. A local record (localStorage) written when a customer returns to the
|
* (webhook-written) transaction history is merged in on top when available.
|
||||||
* success page — gives instant feedback and works without KV in the demo.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import { INVOICES } from '../data/invoices';
|
|
||||||
import { stripeConfig } from '../config/stripeConfig';
|
import { stripeConfig } from '../config/stripeConfig';
|
||||||
|
import {
|
||||||
const STORAGE_KEY = 'lynkeduppro.payments.transactions';
|
loadInvoices,
|
||||||
|
loadTransactions,
|
||||||
const loadLocal = () => {
|
saveTransactions,
|
||||||
try {
|
subscribe,
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
} from '../data/paymentStore';
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistLocal = (txns) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(txns));
|
|
||||||
} catch {
|
|
||||||
/* localStorage unavailable (private mode) — keep in-memory only. */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mergeById = (a, b) => {
|
const mergeById = (a, b) => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -45,8 +29,19 @@ const mergeById = (a, b) => {
|
|||||||
const PaymentContext = createContext(null);
|
const PaymentContext = createContext(null);
|
||||||
|
|
||||||
export const PaymentProvider = ({ children }) => {
|
export const PaymentProvider = ({ children }) => {
|
||||||
const [invoices] = useState(INVOICES);
|
const [invoices, setInvoices] = useState(loadInvoices);
|
||||||
const [transactions, setTransactions] = useState(loadLocal);
|
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.
|
// Pull the server-side (webhook-written) history and merge it in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,7 +67,7 @@ export const PaymentProvider = ({ children }) => {
|
|||||||
if (!txn?.id) return;
|
if (!txn?.id) return;
|
||||||
setTransactions((prev) => {
|
setTransactions((prev) => {
|
||||||
const next = mergeById(prev, [txn]);
|
const next = mergeById(prev, [txn]);
|
||||||
persistLocal(next);
|
saveTransactions(next); // persists to the shared store + notifies the owner side
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { Lock, Loader2, ArrowLeft, FlaskConical, CheckCircle2 } from 'lucide-react';
|
import { Lock, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react';
|
||||||
import { INVOICES, formatAmount } from '../data/invoices';
|
import { formatAmount } from '../data/invoices';
|
||||||
|
import { getInvoice } from '../data/paymentStore';
|
||||||
import { stripeConfig } from '../config/stripeConfig';
|
import { stripeConfig } from '../config/stripeConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +18,7 @@ const DemoCheckout = () => {
|
|||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const invoiceId = params.get('invoice');
|
const invoiceId = params.get('invoice');
|
||||||
const invoice = INVOICES.find((i) => i.id === invoiceId);
|
const invoice = getInvoice(invoiceId);
|
||||||
|
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
|
|
||||||
@@ -46,12 +47,6 @@ const DemoCheckout = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
{/* Demo banner */}
|
|
||||||
<div className="mb-6 flex items-center gap-2 rounded-xl border border-amber-300/60 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-2.5 text-xs font-medium text-amber-800 dark:text-amber-200">
|
|
||||||
<FlaskConical size={15} />
|
|
||||||
Demo mode — simulated Stripe Checkout. No real card or charge. Add Stripe keys to go live.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
{/* Left: order summary (Stripe-style) */}
|
{/* Left: order summary (Stripe-style) */}
|
||||||
<div className="md:pr-8 md:border-r border-zinc-200 dark:border-white/10">
|
<div className="md:pr-8 md:border-r border-zinc-200 dark:border-white/10">
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ import React, { useState } from 'react';
|
|||||||
import { CreditCard } from 'lucide-react';
|
import { CreditCard } from 'lucide-react';
|
||||||
import { useAuth } from '../../../context/AuthContext';
|
import { useAuth } from '../../../context/AuthContext';
|
||||||
import { usePayments } from '../context/PaymentProvider';
|
import { usePayments } from '../context/PaymentProvider';
|
||||||
import { stripeConfig } from '../config/stripeConfig';
|
import { invoiceMatchesCustomer } from '../data/paymentStore';
|
||||||
import InvoiceList from '../components/InvoiceList';
|
import InvoiceList from '../components/InvoiceList';
|
||||||
import OutstandingPanel from '../components/OutstandingPanel';
|
import OutstandingPanel from '../components/OutstandingPanel';
|
||||||
import TransactionHistory from '../components/TransactionHistory';
|
import TransactionHistory from '../components/TransactionHistory';
|
||||||
import StripeConfigNotice from '../components/StripeConfigNotice';
|
|
||||||
|
|
||||||
const TabButton = ({ id, label, activeTab, onSelect }) => (
|
const TabButton = ({ id, label, activeTab, onSelect }) => (
|
||||||
<button
|
<button
|
||||||
@@ -31,6 +30,10 @@ const PaymentsPage = () => {
|
|||||||
const { invoices, transactions } = usePayments();
|
const { invoices, transactions } = usePayments();
|
||||||
const [activeTab, setActiveTab] = useState('pay');
|
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.
|
// Invoices marked paid by the (server) transaction history.
|
||||||
const paidIds = transactions
|
const paidIds = transactions
|
||||||
.filter((t) => t.status === 'paid' && t.invoiceId)
|
.filter((t) => t.status === 'paid' && t.invoiceId)
|
||||||
@@ -54,8 +57,6 @@ const PaymentsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!stripeConfig.isConfigured && <StripeConfigNotice />}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex space-x-1 border-b border-zinc-200 dark:border-zinc-800 mb-6 overflow-x-auto scrollbar-hide">
|
<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="pay" label="Make a Payment" activeTab={activeTab} onSelect={setActiveTab} />
|
||||||
@@ -67,14 +68,14 @@ const PaymentsPage = () => {
|
|||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{activeTab === 'pay' && (
|
{activeTab === 'pay' && (
|
||||||
<InvoiceList
|
<InvoiceList
|
||||||
invoices={invoices}
|
invoices={myInvoices}
|
||||||
paidIds={paidIds}
|
paidIds={paidIds}
|
||||||
customerEmail={user?.email || user?.username}
|
customerEmail={user?.email || user?.username}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'outstanding' && (
|
{activeTab === 'outstanding' && (
|
||||||
<OutstandingPanel
|
<OutstandingPanel
|
||||||
invoices={invoices}
|
invoices={myInvoices}
|
||||||
paidIds={paidIds}
|
paidIds={paidIds}
|
||||||
customerEmail={user?.email || user?.username}
|
customerEmail={user?.email || user?.username}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const getStripe = () => {
|
|||||||
* Create a session for an invoice and redirect to Stripe's hosted page.
|
* 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.
|
* 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) {
|
if (!stripeConfig.isConfigured) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
'Stripe is not configured yet. Add your publishable key to enable payments.'
|
'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`, {
|
const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -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;
|
||||||
+5
-1
@@ -40,8 +40,12 @@ const SERVER_ENV_KEYS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user