diff --git a/.gitignore b/.gitignore index 9d7f61e..1c20926 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ yarn-error.log* # editor backups *.save +.env*.local diff --git a/package-lock.json b/package-lock.json index 8540ceb..c8cdd2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "par3-admin1", "version": "0.1.0", "dependencies": { + "@stripe/react-stripe-js": "^5.3.0", + "@stripe/stripe-js": "^8.4.0", "axios": "^1.13.1", "bcryptjs": "^3.0.2", "cookie": "^1.0.2", @@ -562,6 +564,29 @@ "node": ">=14" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.3.0.tgz", + "integrity": "sha512-q9Wl6JKg9JUU9zRcQyx4rkKauiM4Il+/ohJemQkLa4SsFEvcXVzX9X89cRLiaAEiNOaJ/2EQML+aWNSHc0EXBw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.4.0.tgz", + "integrity": "sha512-3LYVbK3Yg6NTGq3xB8jHSZF8VhoVjb3qJSAorzh2yuva/g4FMRmdQq68euk4hqaq2nRJHkr6x+Id473roUky1w==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -3128,6 +3153,17 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3221,6 +3257,12 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 818777c..c91284c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "start": "next start" }, "dependencies": { + "@stripe/react-stripe-js": "^5.3.0", + "@stripe/stripe-js": "^8.4.0", "axios": "^1.13.1", "bcryptjs": "^3.0.2", "cookie": "^1.0.2", diff --git a/pages/api/payments/index.ts b/pages/api/payments/index.ts index 4a2629f..6593a3b 100644 --- a/pages/api/payments/index.ts +++ b/pages/api/payments/index.ts @@ -1,41 +1,46 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import Stripe from 'stripe'; +export const config = { api: { bodyParser: true } } -const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ''; -if (!STRIPE_SECRET_KEY) { - // Build/runtime safety — surfaces clear error in logs - console.error('Missing STRIPE_SECRET_KEY env var'); -} - -const stripe = new Stripe(STRIPE_SECRET_KEY); // use dashboard default API version +import type { NextApiRequest, NextApiResponse } from 'next' +import Stripe from 'stripe' -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === 'OPTIONS') return res.status(200).end(); - - try { - if (req.method !== 'POST') { - res.setHeader('Allow', 'POST, OPTIONS'); - return res.status(405).json({ error: 'Method Not Allowed' }); - } +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string); - // expected body: { amountCents: number, currency?: string, metadata?: Record } - const { amountCents, currency = 'usd', metadata = {} } = - typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {}); +function allowCors(res: NextApiResponse) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') +} - if (!amountCents || typeof amountCents !== 'number' || amountCents < 50) { - return res.status(400).json({ error: 'amountCents >= 50 required' }); +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + allowCors(res) + if (req.method === 'OPTIONS') return res.status(200).end() + + // Debug log for incoming body and headers + console.log('DEBUG payments API:', { + method: req.method, + headers: req.headers, + body: req.body + }) + + if (req.method === 'POST') { + try { + const { amount = 800, currency = 'usd' } = (req.body ?? {}) as { amount?: number; currency?: string } + if (!process.env.STRIPE_SECRET_KEY) return res.status(500).json({ error: 'Missing STRIPE_SECRET_KEY' }) + if (!Number.isFinite(amount) || amount < 100) return res.status(400).json({ error: 'amount must be >= 100 (cents)' }) + + const intent = await stripe.paymentIntents.create({ + amount, + currency, + // This makes backend confirmations with test cards work without return_url + automatic_payment_methods: { enabled: true, allow_redirects: 'never' }, + }) + + return res.status(200).json({ clientSecret: intent.client_secret, id: intent.id }) + } catch (e: any) { + return res.status(500).json({ error: e.message || 'stripe_create_failed' }) } - - const pi = await stripe.paymentIntents.create({ - amount: Math.floor(amountCents), // integer cents - currency, - automatic_payment_methods: { enabled: true }, - metadata, - }); - - return res.status(200).json({ clientSecret: pi.client_secret }); - } catch (err: any) { - console.error('payments.api error:', err?.message || err); - return res.status(500).json({ error: err?.message || 'Stripe error' }); } + + res.setHeader('Allow', 'POST, OPTIONS') + return res.status(405).end() } diff --git a/pages/api/players/index.ts.BAK.1762246063 b/pages/api/players/index.ts.BAK.1762246063 new file mode 100644 index 0000000..862d9b9 --- /dev/null +++ b/pages/api/players/index.ts.BAK.1762246063 @@ -0,0 +1,251 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { MongoClient, ObjectId } from "mongodb"; + +const MONGODB_URI = process.env.MONGODB_URI || ""; +const MONGODB_DB = process.env.MONGODB_DB || "par3"; +let client: MongoClient | null = null; + +async function getDb() { + if (!MONGODB_URI) return null; + if (!client) { + client = new MongoClient(MONGODB_URI); + await client.connect(); + } + return client.db(MONGODB_DB); +} + +<<<<<<< HEAD +// in-memory fallback for local +======= +// Dev-only in-memory fallback +>>>>>>> b55baa6 (api(players): upsert-by-email, coursesPlayed, points; GET returns {players:[]}) +type PlayerDoc = { + _id?: string | ObjectId; + playerEmail: string; + playerName?: string; + playerPhone?: string; + coursesPlayed?: string[]; + points?: number; + qualifiedForMillion?: boolean; + claims?: Array<{ + claimType: string; + courseId?: string; + hole?: string; + status?: "submitted" | "approved" | "denied"; + createdAt?: Date | string; + }>; +}; +const mem: { players: PlayerDoc[] } = + (global as any).__MEM__ || ((global as any).__MEM__ = { players: [] }); + +function cors(res: NextApiResponse) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type,x-admin-key"); +} + +function normalize(body: any) { + const b = typeof body === "string" ? JSON.parse(body || "{}") : (body || {}); + const first = b.firstName || ""; + const last = b.lastName || ""; +<<<<<<< HEAD + const playerEmail = b.playerEmail ?? b.email ?? ""; + const playerName = b.playerName ?? b.name ?? [first, last].filter(Boolean).join(" ").trim(); + const playerPhone = b.playerPhone ?? b.phone ?? ""; + const course = b.courseId ?? b.course ?? b.lastCourse ?? ""; + const points = typeof b.points === "number" ? b.points : undefined; + const qualifiedForMillion = b.qualifiedForMillion === true; + return { playerEmail, playerName, playerPhone, course, points, qualifiedForMillion }; +} +======= + const playerName = b.playerName ?? b.name ?? [first, last].filter(Boolean).join(" ").trim(); + const playerEmail = b.playerEmail ?? b.email ?? ""; + const playerPhone = b.playerPhone ?? b.phone ?? ""; + const course = b.courseId ?? b.course ?? b.lastCourse ?? ""; + const points = typeof b.points === "number" ? b.points : undefined; + const qualifiedForMillion = !!b.qualifiedForMillion; + return { playerEmail, playerName, playerPhone, course, points, qualifiedForMillion }; +} + +>>>>>>> b55baa6 (api(players): upsert-by-email, coursesPlayed, points; GET returns {players:[]}) +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + cors(res); + if (req.method === "OPTIONS") return res.status(200).end(); + + try { + const db = await getDb(); + + if (req.method === "GET") { +<<<<<<< HEAD + if (db) { + const list = await db.collection("players") + .find({}) + .sort({ _id: -1 }) + .limit(200) + .toArray(); + return res.status(200).json({ players: list }); + } else { + return res.status(200).json({ players: mem.players }); + } + } + if (req.method === "POST") { + const data = normalize(req.body); + if (!data.playerEmail) { + return res.status(400).json({ error: "playerEmail (or email) is required" }); + } + + if (db) { + const set: any = { + playerName: data.playerName ?? "", + playerPhone: data.playerPhone ?? "", + }; + const update: any = { + $setOnInsert: { points: 0, qualifiedForMillion: false, claims: [] }, + $set: set, + }; + if (data.course) { + update.$addToSet = { ...(update.$addToSet || {}), coursesPlayed: data.course }; + } + if (typeof data.points === "number") update.$set.points = data.points; + if (typeof data.qualifiedForMillion === "boolean") { + update.$set.qualifiedForMillion = data.qualifiedForMillion; + } + + await db.collection("players").updateOne( + { playerEmail: data.playerEmail }, + update, + { upsert: true } +); +const updated = await db.collection("players").findOne({ playerEmail: data.playerEmail }); +return res.status(201).json({ player: updated || null }); + + } else { + const i = mem.players.findIndex(p => p.playerEmail === data.playerEmail); + const base: PlayerDoc = i >= 0 ? mem.players[i] : { + _id: String(Date.now()), + playerEmail: data.playerEmail, + points: 0, + qualifiedForMillion: false, + claims: [] + }; + base.playerName = data.playerName ?? base.playerName; + base.playerPhone = data.playerPhone ?? base.playerPhone; + if (data.course) { + base.coursesPlayed = Array.from(new Set([...(base.coursesPlayed || []), data.course])); + } + if (typeof data.points === "number") base.points = data.points; + if (typeof data.qualifiedForMillion === "boolean") base.qualifiedForMillion = data.qualifiedForMillion; + + if (i >= 0) mem.players[i] = base; else mem.players.push(base); + return res.status(201).json({ player: base }); + } + } + + if (req.method === "DELETE") { + const {_id, email, playerEmail} = (typeof req.body === "string" ? JSON.parse(req.body||"{}") : req.body) || {}; + if (!(_id || email || playerEmail)) { + return res.status(400).json({ error: "_id or playerEmail required" }); + } + if (db) { +======= + if (db) { + const list = await db.collection("players") + .find({}) + .sort({ _id: -1 }) + .limit(200) + .toArray(); + return res.status(200).json({ players: list }); + } else { + return res.status(200).json({ players: mem.players }); + } + } + + if (req.method === "POST") { + const data = normalize(req.body); + if (!data.playerEmail) { + return res.status(400).json({ error: "playerEmail (or email) is required" }); + } + + if (db) { + // Build update + const set: any = { + playerName: data.playerName ?? "", + playerPhone: data.playerPhone ?? "", + }; + const update: any = { + $setOnInsert: { points: 0, qualifiedForMillion: false, claims: [] }, + $set: set, + }; + if (data.course) { + update.$addToSet = { ...(update.$addToSet || {}), coursesPlayed: data.course }; + } + if (typeof data.points === "number") { + update.$set.points = data.points; + } + if (typeof data.qualifiedForMillion === "boolean") { + update.$set.qualifiedForMillion = !!data.qualifiedForMillion; + } + + const result = await db.collection("players").findOneAndUpdate( + { playerEmail: data.playerEmail }, + update, + { upsert: true, returnDocument: "after" } + ); + return res.status(201).json({ player: result.value }); + } else { + // In-memory upsert + const i = mem.players.findIndex(p => p.playerEmail === data.playerEmail); + const base: PlayerDoc = i >= 0 ? mem.players[i] : { + _id: String(Date.now()), + playerEmail: data.playerEmail, + points: 0, + qualifiedForMillion: false, + claims: [] + }; + base.playerName = data.playerName ?? base.playerName; + base.playerPhone = data.playerPhone ?? base.playerPhone; + if (data.course) { + base.coursesPlayed = Array.from(new Set([...(base.coursesPlayed || []), data.course])); + } + if (typeof data.points === "number") base.points = data.points; + if (typeof data.qualifiedForMillion === "boolean") base.qualifiedForMillion = !!data.qualifiedForMillion; + + if (i >= 0) mem.players[i] = base; else mem.players.push(base); + return res.status(201).json({ player: base }); + } + } + + if (req.method === "DELETE") { + const {_id, email, playerEmail} = (typeof req.body === "string" ? JSON.parse(req.body||"{}") : req.body) || {}; + if (!(_id || email || playerEmail)) { + return res.status(400).json({ error: "_id or playerEmail required" }); + } + if (db) { +>>>>>>> b55baa6 (api(players): upsert-by-email, coursesPlayed, points; GET returns {players:[]}) + if (_id) { + await db.collection("players").deleteOne({ _id: new ObjectId(String(_id)) }); + } else { + await db.collection("players").deleteOne({ playerEmail: String(email || playerEmail) }); + } + return res.status(200).json({ ok: true }); + } else { + const key = String(email || playerEmail || ""); +<<<<<<< HEAD + if (_id) mem.players = mem.players.filter(p => String(p._id) !== String(_id)); + else if (key) mem.players = mem.players.filter(p => p.playerEmail !== key); +======= + if (_id) { + mem.players = mem.players.filter(p => String(p._id) !== String(_id)); + } else if (key) { + mem.players = mem.players.filter(p => p.playerEmail !== key); + } +>>>>>>> b55baa6 (api(players): upsert-by-email, coursesPlayed, points; GET returns {players:[]}) + return res.status(200).json({ ok: true }); + } + } + + return res.status(405).json({ error: "Method not allowed" }); + } catch (e:any) { + return res.status(500).json({ error: e?.message || "Failed" }); + } +} diff --git a/pages/api/stripe/webhook.ts b/pages/api/stripe/webhook.ts new file mode 100644 index 0000000..c8db97d --- /dev/null +++ b/pages/api/stripe/webhook.ts @@ -0,0 +1,53 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import Stripe from 'stripe' + +export const config = { api: { bodyParser: false } } // Raw body for Stripe + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string); + +function readRawBody(req: any): Promise { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = [] + req.on('data', (c: Uint8Array) => chunks.push(c)) + req.on('end', () => resolve(Buffer.concat(chunks))) + req.on('error', reject) + }) +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { res.setHeader('Allow','POST'); return res.status(405).end() } + + const sig = req.headers['stripe-signature'] + if (!sig || typeof sig !== 'string') return res.status(400).send('Missing signature') + + const secret = process.env.STRIPE_WEBHOOK_SECRET + if (!secret) return res.status(500).send('Missing STRIPE_WEBHOOK_SECRET') + + let event: Stripe.Event + try { + const raw = await readRawBody(req) + event = stripe.webhooks.constructEvent(raw, sig, secret) + } catch (e: any) { + return res.status(400).send(`Webhook Error: ${e.message}`) + } + + try { + switch (event.type) { + case 'payment_intent.succeeded': { + const pi = event.data.object as Stripe.PaymentIntent + console.log('[WEBHOOK] succeeded', pi.id, pi.amount, pi.currency) + // TODO: mark claim/order paid in DB + break + } + case 'payment_intent.payment_failed': { + const pi = event.data.object as Stripe.PaymentIntent + console.log('[WEBHOOK] failed', pi.id, pi.last_payment_error?.message) + // TODO: record failure + break + } + } + return res.status(200).json({ received: true }) + } catch (e: any) { + return res.status(500).json({ error: e.message || 'handler_error' }) + } +} diff --git a/pages/crm.tsx b/pages/crm.tsx index 28213b1..6fd6b79 100644 --- a/pages/crm.tsx +++ b/pages/crm.tsx @@ -55,12 +55,23 @@ export default function CRM() { totalBookings: 0, status: 'active', }); - const [courseNotes, setCourseNotes] = useState(course?.notes || ""); - const [courseManager, setCourseManager] = useState(course?.manager || ""); + const [courseNotes] = useState(course?.notes || ""); + const [courseManager] = useState(course?.manager || ""); const [editingCourse, setEditingCourse] = useState(false); + void courseNotes; void courseManager; void editingCourse; + // Load all courses from localStorage as leads + const [courses, setCourses] = useState([]); + React.useEffect(() => { + if (typeof window !== 'undefined') { + const stored: Course[] = JSON.parse(localStorage.getItem('courses') || '[]'); + setCourses(stored); + } + }, []); + const filteredCustomers = customers.filter((customer: Customer) => { const matchesSearch = customer.name.toLowerCase().includes(searchTerm.toLowerCase()) || +void filteredCustomers; customer.email.toLowerCase().includes(searchTerm.toLowerCase()); const matchesStatus = selectedStatus === 'all' || customer.status === selectedStatus; return matchesSearch && matchesStatus; @@ -268,114 +279,39 @@ export default function CRM() { - {/* Customer Table */} + {/* Customer Table replaced with Course Table */}
-
- - - - - - - - - - - - - - {/* Show course info as a row if available */} - {course && ( - - - - - - - - - - )} - {/* Show editable notes/manager field for course */} - {editingCourse && course && ( - -
- Customer - - Contact - - Join Date - - Last Activity - - Bookings - - Status - - Actions -
-
{course.name}
-
-
{course.email || "N/A"}
-
{course.phone || "N/A"}
-
{course.holeNumber || "N/A"}{course.yardage || "N/A"}- - Course - - -
-
Course Manager:
- setCourseManager(e.target.value)} - placeholder="Manager name" - /> -
Course Notes:
-