diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..aa21f89 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,9 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-img-element": "warn", + "react-hooks/exhaustive-deps": "warn", + "import/no-anonymous-default-export": "warn", + "react/no-unescaped-entities": "off" + } } diff --git a/.gitignore b/.gitignore index fd3dbb5..8e3ee7b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ yarn-debug.log* yarn-error.log* # local env files +.env +.env.local .env*.local # vercel diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/about/metadata.ts b/app/about/metadata.ts new file mode 100644 index 0000000..b6ee8b1 --- /dev/null +++ b/app/about/metadata.ts @@ -0,0 +1,6 @@ +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'About TradesMonk - Connecting You with Trusted Service Providers', + description: 'Learn about TradesMonk and how we connect you with reliable service providers in your area.', +}; diff --git a/app/about/page.tsx b/app/about/page.tsx index d449568..64fcf6d 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,3 +1,205 @@ -"use client"; // This marks the file as a Client Component +"use client"; -import { useEffect } from "react"; \ No newline at end of file +import { Metadata } from 'next'; +import Link from 'next/link'; +import { useSession } from 'next-auth/react'; + +export default function AboutPage() { + const { data: session } = useSession(); + + return ( +
+ {/* Navigation Bar - Matching Home Page */} +
+
+ + TradesMonk + +
+ + About + + {session ? ( +
+ {/* Admin Dashboard Link - only show for admin */} + {(session.user?.email === 'yashp.d39@gmail.com' || session.user?.email === 'neelvp@gmail.com') && ( + + Admin + + )} +
+ + Welcome, {session.user?.name || session.user?.email?.split('@')[0] || 'User'}! + +
+
+ ) : ( + + Login + + )} +
+
+
+ + {/* Hero Section */} +
+
+
+

+ About TradesMonk +

+

+ Empowering local service professionals and connecting communities with trusted, independent contractors. +

+
+
+
+ + {/* Our Story Section */} +
+
+
+

+ Supporting Local Heroes +

+
+

+ TradesMonk was born from a simple truth: local service professionals are the backbone of our communities, + yet they're constantly being pushed out by massive corporations that prioritize profit over personal service. + We're here to level the playing field. +

+

+ Did you know? Small, local service businesses employ over 27 million Americans and contribute + $1.3 trillion to the U.S. economy annually. Yet 70% of consumers struggle to find reliable local contractors, + often defaulting to expensive corporate chains that charge 40-60% more for the same services. +

+

+ Our mission is simple: connect homeowners directly with skilled, independent professionals in their community. + When you choose TradesMonk, you're not just getting quality service – you're supporting local families, + keeping money in your community, and helping small businesses thrive. +

+
+
+ + {/* How It Works Section */} +
+

+ How It Works +

+
+
+
+ 🔍 +
+

1. Search

+

+ Discover local, independent professionals who live and work in your community. +

+
+
+
+ 📅 +
+

2. Book

+

+ Connect directly with local pros – no corporate middleman, no inflated prices. +

+
+
+
+ +
+

3. Relax

+

+ Support your local economy while getting personalized, quality service from people who care. +

+
+
+
+ + {/* Why Choose Us Section */} +
+

+ Why Choose TradesMonk? +

+
+
+
+ 🛡️ +
+
+

Support Local Families

+

+ Every booking directly supports independent contractors and their families, not corporate shareholders. + Local businesses reinvest 68% of revenue back into the community. +

+
+
+
+
+ +
+
+

Better Prices

+

+ Skip the corporate markup. Local professionals typically charge 30-50% less than big chains + while providing more personalized service. +

+
+
+
+
+ 💰 +
+
+

Personal Accountability

+

+ Local professionals stake their reputation on every job. They live in your community + and depend on word-of-mouth referrals. +

+
+
+
+
+ 📍 +
+
+

Community Investment

+

+ Local businesses create 2x more local jobs per dollar of revenue than chains. + Your choice makes a real difference in your neighborhood. +

+
+
+
+
+
+
+ + {/* CTA Section */} +
+
+

Ready to get started?

+

+ Join the movement to support local businesses and get better service at better prices. +

+
+ + Find a Service + + + Sign Up + +
+
+
+
+ ); +} diff --git a/app/address_input/page.tsx b/app/address_input/page.tsx deleted file mode 100644 index cfb5bdb..0000000 --- a/app/address_input/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; // This marks the file as a Client Component - -import React, { useState } from 'react'; -import { useRouter } from 'next/navigation'; - -export default function AddressInput() { - const [address, setAddress] = useState(''); - const [error, setError] = useState(''); - const router = useRouter(); - - const handleAddressChange = (e: React.ChangeEvent) => { - setAddress(e.target.value); - }; - - const geocodeAddress = async (address: string) => { - const apiKey = 'AIzaSyA-TlVuQXWUgjmMxpLS4qmWjv164jkl75c'; // Replace with your actual API key - const response = await fetch( - `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}` - ); - const data = await response.json(); - - if (data.status === 'OK') { - const { lat, lng } = data.results[0].geometry.location; - return { lat, lng }; - } else { - setError('Address not found. Please try again.'); - return null; - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - - const coords = await geocodeAddress(address); - if (coords) { - // Save the new location in local storage - const savedLocations = JSON.parse(localStorage.getItem('truckLocations') || '[]'); - const newLocation = { lat: coords.lat, lng: coords.lng, title: address }; - localStorage.setItem('truckLocations', JSON.stringify([...savedLocations, newLocation])); - - // Navigate back to the Home page with the new coordinates - router.replace('/'); - } - }; - - return ( -
-

Add New Truck Location

-
-
- -
- {error &&

{error}

} - -
-
- ); -} diff --git a/app/admin-dashboard/page.tsx b/app/admin-dashboard/page.tsx new file mode 100644 index 0000000..16027d6 --- /dev/null +++ b/app/admin-dashboard/page.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; + +interface Booking { + _id: string; + userId: string; + serviceId: string; + serviceName: string; + selectedDate: string; + selectedTime: string; + duration: number; + amount: number; + customerName: string; + customerEmail: string; + customerPhone: string; + address: { + addressLine1: string; + city: string; + state: string; + zipCode: string; + }; + providerName: string; + providerEmail: string; + status: string; + createdAt: string; +} + +export default function AdminDashboard() { + const { data: session, status } = useSession(); + const router = useRouter(); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ + totalBookings: 0, + totalRevenue: 0, + todayBookings: 0, + thisWeekBookings: 0 + }); + + // Admin authentication + const isAdmin = session?.user?.email === 'yashp.d39@gmail.com'; + + useEffect(() => { + if (status === 'loading') return; + + if (!session) { + router.push('/'); + return; + } + + if (!isAdmin) { + alert('Access denied. Admin access only.'); + router.push('/'); + return; + } + + fetchAllBookings(); + }, [session, status, router, isAdmin]); + + const fetchAllBookings = async () => { + try { + const response = await fetch('/api/bookings'); + const data = await response.json(); + + if (!response.ok) { + console.error('Error fetching bookings:', data.error || 'Unknown error'); + alert(`Error fetching bookings: ${data.error || 'Please try again'}`); + return; + } + + if (data.success) { + // The API now returns data in data.data for the admin view + const bookingsData = data.data || []; + console.log('Fetched bookings:', bookingsData); + setBookings(bookingsData); + + // Calculate stats + const today = new Date().toDateString(); + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const todayCount = bookingsData.filter((b: Booking) => + b.createdAt && new Date(b.createdAt).toDateString() === today + ).length; + + const weekCount = bookingsData.filter((b: Booking) => + b.createdAt && new Date(b.createdAt) >= oneWeekAgo + ).length; + + const totalRevenue = bookingsData.reduce((sum: number, b: Booking) => { + return sum + (b.amount || 0); + }, 0); + + setStats({ + totalBookings: bookingsData.length, + totalRevenue, + todayBookings: todayCount, + thisWeekBookings: weekCount + }); + } + } catch (error) { + console.error('Error fetching bookings:', error); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD' + }).format(amount); + }; + + if (loading) { + return ( +
+
Loading CEO Dashboard...
+
+ ); + } + + if (!isAdmin) { + return null; + } + + return ( +
+ {/* Header */} +
+
+ + TradesMonk Admin Dashboard + +
+ Welcome, Admin + + Back to Home + +
+
+
+ +
+ {/* Stats Cards */} +
+
+

Total Bookings

+

{stats.totalBookings}

+
+
+

Total Revenue

+

{formatCurrency(stats.totalRevenue)}

+
+
+

Today's Bookings

+

{stats.todayBookings}

+
+
+

This Week

+

{stats.thisWeekBookings}

+
+
+ + {/* Bookings Table */} +
+
+

All Bookings

+
+ + {bookings.length === 0 ? ( +
+ No bookings found. +
+ ) : ( +
+ + + + + + + + + + + + + + {bookings.map((booking) => ( + + + + + + + + + + ))} + +
+ Customer + + Service + + Provider + + Date & Time + + Amount + + Status + + Booked On +
+
+
{booking.customerName}
+
{booking.customerEmail}
+
{booking.customerPhone}
+
+
+
{booking.serviceName}
+
{booking.duration}h duration
+
+
{booking.providerName}
+
{booking.providerEmail}
+
+
+ {new Date(booking.selectedDate).toLocaleDateString()} +
+
{booking.selectedTime}
+
+
+ {formatCurrency(booking.amount)} +
+
+ + {booking.status || 'Confirmed'} + + + {formatDate(booking.createdAt)} +
+
+ )} +
+
+
+ ); +} diff --git a/app/admin-dashboard/promotions/page.tsx b/app/admin-dashboard/promotions/page.tsx new file mode 100644 index 0000000..e4fb557 --- /dev/null +++ b/app/admin-dashboard/promotions/page.tsx @@ -0,0 +1,320 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; + +type Promo = { + code: string; + description?: string; + type: 'percent' | 'fixed'; + value: number; + maxDiscount?: number; + active: boolean; + startsAt?: string; + endsAt?: string; + usageLimit?: number; + usageCount?: number; +}; + +export default function PromotionsAdminPage() { + const { data: session, status } = useSession(); + const [promos, setPromos] = useState([]); + const [loading, setLoading] = useState(false); + const [form, setForm] = useState({ code: '', description: '', type: 'percent', value: 10, maxDiscount: 0, active: true }); + const [msg, setMsg] = useState(''); + + const isAdmin = session?.user?.email === 'yashp.d39@gmail.com'; + + const load = async () => { + setLoading(true); + setMsg(''); + try { + const res = await fetch('/api/promotions'); + const json = await res.json(); + if (json.success) setPromos(json.data || []); + else setMsg(json.error || 'Failed to load'); + } catch { + setMsg('Failed to load'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (status === 'authenticated') load(); + }, [status]); + + const upsert = async (e: React.FormEvent) => { + e.preventDefault(); + setMsg(''); + try { + const method = promos.find(p => p.code.toUpperCase() === (form.code || '').trim().toUpperCase()) ? 'PUT' : 'POST'; + const res = await fetch('/api/promotions', { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: form.code, + description: form.description, + type: form.type, + value: Number(form.value), + maxDiscount: form.maxDiscount ? Number(form.maxDiscount) : undefined, + active: form.active, + startsAt: form.startsAt || undefined, + endsAt: form.endsAt || undefined, + usageLimit: form.usageLimit !== undefined && form.usageLimit !== null && form.usageLimit !== ('' as any) ? Number(form.usageLimit) : undefined, + }) + }); + const json = await res.json(); + if (json.success) { + setMsg('Saved'); + setForm({ code: '', description: '', type: 'percent', value: 10, maxDiscount: 0, active: true }); + load(); + } else { + setMsg(json.error || 'Failed to save'); + } + } catch { + setMsg('Failed to save'); + } + }; + + const remove = async (code: string) => { + if (!confirm(`Delete promotion ${code}?`)) return; + setMsg(''); + try { + const res = await fetch(`/api/promotions?code=${encodeURIComponent(code)}`, { method: 'DELETE' }); + const json = await res.json(); + if (json.success) { setMsg('Deleted'); load(); } + else setMsg(json.error || 'Failed to delete'); + } catch { + setMsg('Failed to delete'); + } + }; + + if (status === 'loading') return null; + if (!isAdmin) return
Admin access required
; + + return ( +
+
+
+
+
+

Promotions Management

+

Create and manage discount codes

+
+ + Go to Referrals → + +
+ + {/* Create/Edit Form */} +
+

+ {promos.find(p => p.code.toUpperCase() === (form.code || '').trim().toUpperCase()) ? 'Edit Promotion' : 'Create New Promotion'} +

+
+
+ + setForm({ ...form, code: e.target.value })} + required + /> +
+
+ + setForm({ ...form, description: e.target.value })} + /> +
+
+ + +
+
+ + setForm({ ...form, value: Number(e.target.value) })} + /> +
+
+ + setForm({ ...form, maxDiscount: e.target.value === '' ? undefined : Number(e.target.value) })} + /> +
+
+ + setForm({ ...form, startsAt: e.target.value })} + /> +
+
+ + setForm({ ...form, endsAt: e.target.value })} + /> +
+
+ + setForm({ ...form, usageLimit: e.target.value === '' ? undefined : Number(e.target.value) })} + /> +
+
+
+ +
+
+
+ +
+
+
+ + {msg && ( +
+

{msg}

+
+ )} + + {/* Promotions Table */} +
+
+

Active Promotions

+
+
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : promos.length === 0 ? ( + + + + ) : ( + promos.map(p => ( + + + + + + + + + + )) + )} + +
CodeTypeValueMax DiscountStatusUsageActions
+
+
+ Loading promotions... +
+
+ No promotions created yet. Create your first promotion above. +
+ {p.code} + + + {p.type === 'percent' ? 'Percentage' : 'Fixed'} + + + {p.type === 'percent' ? `${p.value}%` : `$${p.value}`} + + {p.maxDiscount ? `$${p.maxDiscount}` : '-'} + + + {p.active ? 'Active' : 'Inactive'} + + + + {p.usageCount ?? 0}{typeof p.usageLimit === 'number' ? ` / ${p.usageLimit}` : ' uses'} + + + + +
+
+
+
+
+
+ ); +} diff --git a/app/admin-dashboard/referrals/page.tsx b/app/admin-dashboard/referrals/page.tsx new file mode 100644 index 0000000..f9b39a5 --- /dev/null +++ b/app/admin-dashboard/referrals/page.tsx @@ -0,0 +1,263 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from 'react'; +import { useSession } from 'next-auth/react'; + +type UserRow = { + email: string; + name?: string; + role?: string; + type?: string; + referralCode?: string; + referralEligible?: boolean; + referralCredits?: number; + hasBookedBefore?: boolean; + createdAt?: string; +}; + +export default function AdminReferralsPage() { + const { data: session, status } = useSession(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(''); + const [query, setQuery] = useState(''); + + const isAdmin = session?.user?.email === 'yashp.d39@gmail.com'; + + const load = async () => { + setLoading(true); + setMsg(''); + try { + const res = await fetch('/api/admin/users'); + const json = await res.json(); + if (json.success) setRows(json.data || []); + else setMsg(json.error || 'Failed to load users'); + } catch (e) { + setMsg('Failed to load users'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (status === 'authenticated') load(); + }, [status]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter(r => (r.email || '').toLowerCase().includes(q) || (r.name || '').toLowerCase().includes(q)); + }, [rows, query]); + + const toggleEligibility = async (email: string, next: boolean) => { + setMsg(''); + try { + const res = await fetch('/api/admin/users', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, referralEligible: next }) + }); + const json = await res.json(); + if (json.success) { + setRows(prev => prev.map(r => r.email === email ? { ...r, referralEligible: next } : r)); + } else { + setMsg(json.error || 'Failed to update'); + } + } catch { + setMsg('Failed to update'); + } + }; + + const sendReferralEmail = async (email: string) => { + setMsg(''); + try { + const res = await fetch('/api/referrals/send-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + const json = await res.json(); + if (json.success) setMsg(`Referral email sent to ${email}`); + else setMsg(json.error || 'Failed to send email'); + } catch { + setMsg('Failed to send email'); + } + }; + + if (status === 'loading') return null; + if (!isAdmin) return
Admin access required
; + + return ( +
+
+
+
+
+

Referral Program Management

+

Manage user eligibility and send referral codes

+
+ + Go to Promotions → + +
+ +
+
+ setQuery(e.target.value)} + /> +
+ +
+ + {msg && ( +
+

{msg}

+
+ )} + +
+
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map(u => { + const link = u.referralCode ? `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/?ref=${encodeURIComponent(u.referralCode)}` : ''; + return ( + + + + + + + + + + + ); + }) + )} + +
NameEmailRoleTypeEligibleReferral CodeCreditsActions
+
+
+ Loading users... +
+
+ {rows.length === 0 ? "No users found. Users will appear here after they log in." : "No users match your search."} +
+ {u.name || '-'} + + {u.email} + + + {u.role || '-'} + + + + {u.type || '-'} + + + + + {u.referralCode ? ( +
+
{u.referralCode}
+ {link && ( + + View link → + + )} +
+ ) : ( + - + )} +
+ + {u.referralCredits ?? 0} + + + +
+
+
+ +
+

API Endpoints:

+
+
+ /api/admin/users +

GET, PUT - Manage users

+
+
+ /api/referrals/send-email +

POST - Send referral emails

+
+
+ + /admin-dashboard/promotions + +

Promotions management

+
+
+
+
+
+
+ ); +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..df62660 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import User from '@/models/User'; + +// GET: list users (admin-only) +export async function GET() { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + await dbConnect(); + const users = await User.find({}, { email: 1, name: 1, role: 1, type: 1, referralCode: 1, referralEligible: 1, referralCredits: 1, hasBookedBefore: 1, createdAt: 1 }).sort({ createdAt: -1 }).lean(); + return NextResponse.json({ success: true, data: users }); + } catch (e) { + console.error('Admin users GET error', e); + return NextResponse.json({ success: false, error: 'Failed' }, { status: 500 }); + } +} + +// PUT: update user referral eligibility (admin-only) +export async function PUT(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + await dbConnect(); + const body = await req.json(); + const { email, referralEligible } = body || {}; + if (!email || typeof referralEligible !== 'boolean') return NextResponse.json({ success: false, error: 'email and referralEligible required' }, { status: 400 }); + + const doc = await User.findOneAndUpdate( + { email }, + { referralEligible, updatedAt: new Date() }, + { new: true } + ); + if (!doc) return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 }); + + return NextResponse.json({ success: true, data: { email: doc.email, referralEligible: doc.referralEligible } }); + } catch (e) { + console.error('Admin users PUT error', e); + return NextResponse.json({ success: false, error: 'Failed to update' }, { status: 500 }); + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..4f72e1a --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,8 @@ +// app/api/auth/[...nextauth]/route.ts +import NextAuth from "next-auth"; +import GoogleProvider from "next-auth/providers/google"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/app/api/bookings/booked/route.ts b/app/api/bookings/booked/route.ts new file mode 100644 index 0000000..ae77738 --- /dev/null +++ b/app/api/bookings/booked/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "@/lib/dbConnect"; +import Booking from "@/models/Booking"; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// GET handler to retrieve booked time slots for a specific service on a specific date +export async function GET(req: NextRequest) { + try { + await dbConnect(); + + const { searchParams } = new URL(req.url); + const serviceId = searchParams.get("serviceId"); + const date = searchParams.get("date"); + + if (!serviceId || !date) { + return NextResponse.json( + { success: false, error: "Service ID and date are required" }, + { status: 400 } + ); + } + + const bookings = await Booking.find({ + serviceId, + date, + status: { $ne: "cancelled" } + }).select('time'); + + const bookedSlots = bookings.map(booking => booking.time); + + return NextResponse.json( + { success: true, bookedSlots }, + { status: 200 } + ); + } catch (error: any) { + console.error("Error fetching booked slots:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch booked slots" }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/create/route.ts b/app/api/bookings/create/route.ts new file mode 100644 index 0000000..7db8630 --- /dev/null +++ b/app/api/bookings/create/route.ts @@ -0,0 +1,305 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import { google } from 'googleapis'; +import User from '@/models/User'; + +/** + * Send an email using the Gmail API + * This is the exact same implementation used in the email test page + */ +async function sendEmail(to: string, subject: string, htmlContent: string) { + try { + console.log(`Sending email to ${to} with subject "${subject}"`); + + // Configure Gmail API client for each email send to ensure fresh credentials + const oauth2Client = new google.auth.OAuth2( + process.env.EMAIL_CLIENT_ID, + process.env.EMAIL_CLIENT_SECRET, + 'https://developers.google.com/oauthplayground' + ); + + // Set credentials with refresh token + oauth2Client.setCredentials({ + refresh_token: process.env.GOOGLE_REFRESH_TOKEN + }); + + // Create Gmail API instance + const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + + // Create the email with proper headers and black text styling + const emailContent = [ + 'Content-Type: text/html; charset=utf-8', + 'MIME-Version: 1.0', + `To: ${to}`, + 'From: "TradesMonk" <' + process.env.EMAIL_USER + '>', + `Subject: ${subject}`, + '', + htmlContent + ].join('\n'); + + // Encode the email for the Gmail API + const encodedMessage = Buffer.from(emailContent) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + console.log('Sending email with Gmail API...'); + // Send the email + const result = await gmail.users.messages.send({ + userId: 'me', + requestBody: { + raw: encodedMessage + } + }); + + console.log('Email sent successfully, message ID:', result.data.id); + return result.data.id; + } catch (error: any) { + console.error('Error sending email:', error); + + // More detailed error logging for debugging + if (error.response) { + console.error('API response error:', { + status: error.response.status, + data: error.response.data + }); + } + + throw error; + } +} + +export async function POST(req: NextRequest) { + try { + // Get the session + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + // Parse the request body + const bookingData = await req.json(); + const { serviceId, serviceName, amount, email, description, address } = bookingData; + let { referralCode } = bookingData as { referralCode?: string }; + + if (!serviceId || !serviceName || !amount || !email || !address) { + return NextResponse.json({ success: false, error: 'Missing required fields' }, { status: 400 }); + } + + // Connect to the database + await dbConnect(); + + // Import Mongoose models + const { default: Booking } = await import('@/models/Booking'); + const { default: Service } = await import('@/models/Service'); + + // Load current user from DB + const currentUserEmail = session.user.email as string; + const currentUser = await User.findOne({ email: currentUserEmail }); + + // If no explicit referralCode provided, try to extract from notes like TM-XXXXXX + if (!referralCode && address?.serviceNotes) { + const match = (address.serviceNotes as string).toUpperCase().match(/TM-[A-Z0-9]{6}/); + if (match) referralCode = match[0]; + } + + // Determine applicable discount percent + let discountPercent = 0; + let referrerEmail: string | undefined; + + // If referralCode is provided and user hasn't booked before, apply referral discount + if (referralCode && currentUser && !currentUser.hasBookedBefore) { + const referrer = await User.findOne({ referralCode: referralCode.toUpperCase() }); + if (referrer && referrer.email !== currentUser.email) { + // Friend gets 10% off their first booking + discountPercent = Math.max(discountPercent, 10); + referrerEmail = referrer.email; + // Grant referrer 10% off their next booking (do not stack above 10) + referrer.nextDiscountPercent = Math.max(referrer.nextDiscountPercent || 0, 10); + await referrer.save(); + + // Mark current user as referred and first booking + currentUser.referredBy = referrer.email; + // We'll set hasBookedBefore=true after successful save + } + } + + // Also check if the current user has a pending discount + if (currentUser && (currentUser.nextDiscountPercent || 0) > 0) { + discountPercent = Math.max(discountPercent, Math.min(currentUser.nextDiscountPercent, 10)); + } + + // Extract date and time from the address object if available + const bookingDate = address?.date || null; + const bookingTime = address?.time || null; + + console.log('Creating booking with date:', bookingDate, 'and time:', bookingTime); + + // Calculate discount amounts + const originalAmount = Number(amount); + const discountAmount = discountPercent > 0 ? Number((originalAmount * (discountPercent / 100)).toFixed(2)) : 0; + const finalAmount = Number((originalAmount - discountAmount).toFixed(2)); + + // Create the booking record without specifying _id (let MongoDB create the ObjectId) + const booking = { + userId: session.user.email || session.user.name, + serviceId, + serviceName, + amount: finalAmount, + originalAmount, + finalAmount, + discountPercentApplied: discountPercent, + discountAmount, + customerEmail: email, + description, + // Include date and time in the address object if available + address: { + ...address, + date: bookingDate, + time: bookingTime + }, + // Also include date and time at the top level + date: bookingDate, + time: bookingTime, + status: 'pending', + paymentStatus: 'pending', + createdAt: new Date(), + referralCodeUsed: referralCode?.toUpperCase(), + referrerEmail, + }; + + console.log('Saving booking:', JSON.stringify(booking, null, 2)); + + // Save the booking using Mongoose model + const savedBooking = await new Booking(booking).save(); + + // Update current user state after a successful booking save + if (currentUser) { + if (!currentUser.hasBookedBefore) currentUser.hasBookedBefore = true; + if (currentUser.nextDiscountPercent && discountPercent >= currentUser.nextDiscountPercent) { + // Clear consumed next discount if it was applied (or matched) + currentUser.nextDiscountPercent = 0; + } + await currentUser.save(); + } + + // Fetch the service provider's email + console.log('Finding service with ID:', serviceId); + const service = await Service.findById(serviceId); + console.log('Service found:', service ? 'Yes' : 'No'); + + // Extract email from service or use userEmail field + const providerEmail = service?.email || service?.userEmail; + console.log('Provider email found:', providerEmail); + + // Format common booking details for emails + const formattedAddress = `${address.addressLine1}${address.addressLine2 ? ', ' + address.addressLine2 : ''}, ${address.city}, ${address.state} ${address.zipCode}`; + const formattedDate = address.date ? new Date(address.date).toLocaleDateString() : 'Not specified'; + const formattedTime = address.time || 'Not specified'; + const additionalNotes = address.serviceNotes || 'None provided'; + const pricingLine = discountPercent > 0 + ? `
  • Original Amount: $${originalAmount.toFixed(2)}
  • +
  • Discount: ${discountPercent}% (-$${discountAmount.toFixed(2)})
  • +
  • Total: $${finalAmount.toFixed(2)}
  • ` + : `
  • Amount: $${originalAmount.toFixed(2)}
  • `; + + // 1. Send email notification to the SERVICE PROVIDER if we have their email + if (providerEmail) { + console.log('Sending booking notification email to service provider:', providerEmail); + + try { + // Create service provider email content with black text per user preference + const providerEmailSubject = `New Booking: ${serviceName}`; + const providerEmailContent = ` +
    +

    New Service Booking

    +

    You have a new booking for ${serviceName}!

    +

    Booking Details:

    +
      +
    • Service: ${serviceName}
    • +
    • Date: ${formattedDate}
    • +
    • Time: ${formattedTime}
    • + ${pricingLine} +
    • Customer Email: ${email}
    • +
    • Service Address: ${formattedAddress}
    • +
    • Additional Instructions: ${additionalNotes}
    • +
    +

    Please contact the customer directly if you need any clarification or have questions about this booking.

    +

    You can view all your bookings in your Provider Dashboard.

    +

    Thank you for using TradesMonk!

    +
    + `; + + // Send provider email using Gmail API + const providerMessageId = await sendEmail(providerEmail, providerEmailSubject, providerEmailContent); + console.log('Provider notification email sent successfully, ID:', providerMessageId); + } catch (providerEmailError: any) { + console.error('======== PROVIDER EMAIL ERROR ========'); + console.error('Error sending provider email:', providerEmailError.message); + if (providerEmailError.response) { + console.error('API response error:', { + status: providerEmailError.response.status, + data: providerEmailError.response.data + }); + } + console.error('========================================='); + // Continue execution even if provider email fails + } + } + + // 2. Send confirmation email to the CUSTOMER + try { + console.log('Sending booking confirmation email to customer:', email); + + const customerEmailSubject = `Your Booking Confirmation: ${serviceName}`; + const customerEmailContent = ` +
    +

    Booking Confirmation

    +

    Thank you for booking ${serviceName} through TradesMonk!

    +

    Your Booking Details:

    +
      +
    • Service: ${serviceName}
    • +
    • Date: ${formattedDate}
    • +
    • Time: ${formattedTime}
    • + ${pricingLine} +
    • Service Address: ${formattedAddress}
    • +
    • Your Additional Instructions: ${additionalNotes}
    • +
    +

    The service provider has been notified and will contact you if they have any questions.

    +

    You can view all your bookings in your Profile Dashboard.

    +

    Thank you for using TradersTap!

    +
    + `; + + // Send customer email using Gmail API + const customerMessageId = await sendEmail(email, customerEmailSubject, customerEmailContent); + console.log('Customer confirmation email sent successfully, ID:', customerMessageId); + } catch (customerEmailError: any) { + console.error('======== CUSTOMER EMAIL ERROR ========'); + console.error('Error sending customer email:', customerEmailError.message); + if (customerEmailError.response) { + console.error('API response error:', { + status: customerEmailError.response.status, + data: customerEmailError.response.data + }); + } + console.error('========================================='); + // Continue execution even if customer email fails + } + + return NextResponse.json({ + success: true, + bookingId: savedBooking._id.toString(), + message: 'Booking created successfully and service provider notified' + }); + } catch (error: any) { + console.error('Booking creation error:', error); + return NextResponse.json({ + success: false, + error: error.message || 'Failed to create booking' + }, { status: 500 }); + } +} diff --git a/app/api/bookings/dev-create/route.ts b/app/api/bookings/dev-create/route.ts new file mode 100644 index 0000000..c9d9266 --- /dev/null +++ b/app/api/bookings/dev-create/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/lib/dbConnect'; +import Booking from '@/models/Booking'; +import { google } from 'googleapis'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +function toTitleCase(s: string) { + return s.replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.substr(1).toLowerCase()); +} + +function normalizeDate(input: string | null): string | null { + if (!input) return null; + // Accept YYYY-MM-DD or mm/dd/yyyy + const isoMatch = input.match(/^\d{4}-\d{2}-\d{2}$/); + if (isoMatch) return input; + const usMatch = input.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (usMatch) { + const [_, m, d, y] = usMatch; + const mm = m.padStart(2, '0'); + const dd = d.padStart(2, '0'); + return `${y}-${mm}-${dd}`; + } + // Fallback: Date.parse + const dt = new Date(input); + if (!isNaN(dt.getTime())) return dt.toISOString().split('T')[0]; + return null; +} + +function normalizeTime(input: string | null): string { + if (!input) return '10:00 AM'; + // Accept HH:MM AM/PM or 24h HH:MM + const ampm = input.trim().toUpperCase(); + if (/^\d{1,2}:\d{2}\s?(AM|PM)$/.test(ampm)) { + // Ensure space before AM/PM + return ampm.replace(/\s?(AM|PM)$/,(m)=>` ${m.trim()}`); + } + // 24h -> 12h + const m = input.match(/^(\d{1,2}):(\d{2})$/); + if (m) { + let h = parseInt(m[1], 10); + const min = m[2]; + const period = h >= 12 ? 'PM' : 'AM'; + if (h === 0) h = 12; else if (h > 12) h -= 12; + return `${h}:${min} ${period}`; + } + return '10:00 AM'; +} + +async function sendEmail(to: string, subject: string, htmlContent: string) { + // Delegate to the central email route which you confirmed works + const origin = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const res = await fetch(`${origin}/api/send-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to, subject, message: htmlContent }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data?.details || data?.error || 'Email API failed'); + } + return data.messageId || 'email-api-message-id'; +} + +// GET /api/bookings/dev-create?to=...&date=YYYY-MM-DD or mm/dd/yyyy&time=10:20 PM&serviceName=...&providerName=...&amount=200 +export async function GET(req: NextRequest) { + try { + if (process.env.NODE_ENV === 'production') { + return NextResponse.json({ success: false, error: 'Disabled in production' }, { status: 403 }); + } + + await dbConnect(); + + const url = new URL(req.url); + const to = url.searchParams.get('to'); + const rawDate = url.searchParams.get('date'); + const rawTime = url.searchParams.get('time'); + const serviceName = url.searchParams.get('serviceName') || 'Handyman - Dev Test Job'; + const providerName = url.searchParams.get('providerName') || 'TradesMonk Provider'; + const providerEmailFromQuery = url.searchParams.get('providerEmail'); + const amountParam = url.searchParams.get('amount'); + const amount = amountParam ? Number(amountParam) : 150; + + if (!to) return NextResponse.json({ success: false, error: 'Missing to=email@example.com' }, { status: 400 }); + + const date = normalizeDate(rawDate) || new Date().toISOString().split('T')[0]; + const time = normalizeTime(rawTime); + + const booking = new Booking({ + userId: 'dev-user', + serviceId: 'dev-service-id', + serviceName, + serviceType: 'dev-test', + providerName, + amount, + price: amount, + estimatedTime: '2 hours', + serviceDuration: 2, + userEmail: process.env.EMAIL_USER || 'noreply@tradesmonk.com', + customerEmail: to, + description: 'Dev-created dummy booking for email/review testing', + specialInstructions: 'Dev-only', + clientName: 'Dev Tester', + clientPhone: '(555) 555-5555', + clientEmail: to, + address: { + addressLine1: '123 Test St', + city: 'San Diego', + state: 'CA', + zipCode: '92101', + serviceNotes: 'Dev address', + }, + date, + time, + status: 'confirmed', + paymentStatus: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await booking.save(); + + const bookingDateHuman = new Date(date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + + // Direct review link + const reviewParams = new URLSearchParams({ + bookingId: String(booking._id), + customerName: 'Dev Tester', + serviceName, + providerName, + }); + const origin = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const reviewUrl = `${origin}/reviews/submit?${reviewParams.toString()}`; + + const customerEmailSubject = `TradesMonk Booking Confirmation - ${toTitleCase(serviceName)} (DEV)`; + const customerEmailContent = ` +
    +

    [DEV] Booking Created

    +

    Hi Dev Tester,

    +

    Your DEV dummy booking has been created to test emails and reviews:

    +
    +

    Service: ${toTitleCase(serviceName)}

    +

    Provider: ${toTitleCase(providerName)}

    +

    Date: ${bookingDateHuman}

    +

    Time: ${time}

    +

    Price: $${amount}

    +

    Address: 123 Test St, San Diego, CA 92101

    +
    +

    Leave a review now (DEV): ${reviewUrl}

    +
    + `; + + const providerEmail = providerEmailFromQuery || process.env.EMAIL_USER; + const providerEmailSubject = `New TradesMonk Booking - ${toTitleCase(serviceName)} (DEV)`; + const providerEmailContent = ` +
    +

    [DEV] New Booking

    +

    Hello ${toTitleCase(providerName)},

    +

    A DEV dummy booking was created to test notifications:

    +
    +

    Service: ${toTitleCase(serviceName)}

    +

    Customer: Dev Tester

    +

    Email: ${to}

    +

    Date: ${bookingDateHuman}

    +

    Time: ${time}

    +

    Price: $${amount}

    +

    Address: 123 Test St, San Diego, CA 92101

    +
    +
    + `; + + const emailResults: any = {}; + try { emailResults.customerMessageId = await sendEmail(to, customerEmailSubject, customerEmailContent); } + catch (e: any) { emailResults.customerError = e?.message || 'Failed to send customer email'; } + + try { if (providerEmail) emailResults.providerMessageId = await sendEmail(providerEmail, providerEmailSubject, providerEmailContent); } + catch (e: any) { emailResults.providerError = e?.message || 'Failed to send provider email'; } + + return NextResponse.json({ + success: true, + note: 'DEV dummy booking created and emails attempted', + booking: { + id: booking._id, + serviceName, + providerName, + providerEmail, + date, + time, + amount, + reviewUrl, + }, + emailResults, + }); + } catch (error: any) { + console.error('Dev-create booking error:', error); + return NextResponse.json({ success: false, error: error?.message || 'Failed to create dev dummy booking' }, { status: 500 }); + } +} diff --git a/app/api/bookings/route.ts b/app/api/bookings/route.ts new file mode 100644 index 0000000..8eb3393 --- /dev/null +++ b/app/api/bookings/route.ts @@ -0,0 +1,509 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import Booking from '@/models/Booking'; +import Service from '@/models/Service'; +import { google } from 'googleapis'; +import Promotion from '@/models/Promotion'; +import User from '@/models/User'; + +/** + * Send an email using the Gmail API + */ +async function sendEmail(to: string, subject: string, htmlContent: string) { + try { + console.log(`Sending email to ${to} with subject "${subject}"`); + + // Configure Gmail API client for each email send to ensure fresh credentials + const oauth2Client = new google.auth.OAuth2( + process.env.EMAIL_CLIENT_ID, + process.env.EMAIL_CLIENT_SECRET, + 'https://developers.google.com/oauthplayground' + ); + + // Set credentials with refresh token + oauth2Client.setCredentials({ + refresh_token: process.env.GOOGLE_REFRESH_TOKEN + }); + + // Create Gmail API instance + const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + + // Create the email with proper headers and black text styling + const emailContent = [ + 'Content-Type: text/html; charset=utf-8', + 'MIME-Version: 1.0', + `To: ${to}`, + 'From: "TradesMonk" <' + process.env.EMAIL_USER + '>', + `Subject: ${subject}`, + '', + htmlContent + ].join('\n'); + + // Encode the email for the Gmail API + const encodedMessage = Buffer.from(emailContent) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + console.log('Sending email with Gmail API...'); + // Send the email + const result = await gmail.users.messages.send({ + userId: 'me', + requestBody: { + raw: encodedMessage + } + }); + + console.log('Email sent successfully, message ID:', result.data.id); + return result.data.id; + } catch (error: any) { + console.error('Error sending email:', error); + + // More detailed error logging for debugging + if (error.response) { + console.error('API response error:', { + status: error.response.status, + data: error.response.data + }); + } + + throw error; + } +} + +// GET /api/bookings - Get bookings +// If serviceId and date are provided, returns available times for that service/date +// If no parameters, returns all bookings (admin only) +export async function GET(req: NextRequest) { + try { + await dbConnect(); + + const { searchParams } = new URL(req.url); + const serviceId = searchParams.get('serviceId'); + const date = searchParams.get('date'); + + // If serviceId and date are provided, return booked times for that service/date + if (serviceId && date) { + // Get existing bookings for this service and date + const existingBookings = await Booking.find({ + serviceId, + date, + status: { $ne: 'cancelled' } + }).select('time serviceDuration'); + + const bookedTimes = existingBookings.map(b => b.time); + const bookings = existingBookings.map(b => ({ time: b.time, serviceDuration: b.serviceDuration || 1 })); + + return NextResponse.json({ + success: true, + bookedTimes, + bookings + }); + } + + // If no parameters, check if user is admin and return all bookings + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json( + { success: false, error: 'Authentication required' }, + { status: 401 } + ); + } + + // Check if user is admin + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) { + return NextResponse.json( + { success: false, error: 'Admin access required' }, + { status: 403 } + ); + } + + // Get all bookings, sorted by date (newest first) + const allBookings = await Booking.find({}) + .sort({ date: -1, time: -1 }) + .lean(); + + return NextResponse.json({ + success: true, + data: allBookings + }); + } catch (error) { + console.error('Error fetching bookings:', error); + return NextResponse.json( + { success: false, error: 'Failed to fetch bookings' }, + { status: 500 } + ); + } +} + +// POST /api/bookings - Create a new booking +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json( + { success: false, error: 'Authentication required' }, + { status: 401 } + ); + } + + // Connect to database first + await dbConnect(); + + const body = await req.json(); + console.log('=== API BOOKING DEBUG ==='); + console.log('Received booking data:', body); + console.log('Session user:', session.user); + console.log('========================'); + + // Extract all required fields from the body + const { + userId, serviceId, serviceName, amount, price, userEmail, customerEmail, + date, time, serviceType, providerName, estimatedTime, serviceDuration, + description, clientName, clientPhone, clientEmail, specialInstructions, + address, status, paymentStatus, + promoCode: rawPromoCode, + referralCode: rawReferralCode + } = body; + + // Get the service to fetch provider's email + const service = await Service.findById(serviceId); + if (!service) { + return NextResponse.json( + { success: false, error: 'Service not found' }, + { status: 404 } + ); + } + + // Use provider's contact email if available, otherwise fall back to service owner's email + const providerEmail = service.contactEmail || service.userEmail; + + // Validate required fields + if (!userId || !serviceId || !serviceName || !amount || !userEmail || !customerEmail || + !date || !time || !address?.addressLine1 || !address?.city || !address?.state || !address?.zipCode) { + console.log('Missing required fields:'); + console.log('- userId:', userId); + console.log('- serviceId:', serviceId); + console.log('- serviceName:', serviceName); + console.log('- amount:', amount); + console.log('- userEmail:', userEmail); + console.log('- customerEmail:', customerEmail); + console.log('- address:', address); + return NextResponse.json( + { success: false, error: 'Missing required booking information' }, + { status: 400 } + ); + } + + // Enforce: clients can only book jobs at least one day in advance (no same-day bookings) + try { + const today = new Date(); + // Normalize to local date (no time) for comparison + const localToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const bookingDate = new Date(date + 'T00:00:00'); + const diffMs = bookingDate.getTime() - localToday.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (isNaN(bookingDate.getTime())) { + return NextResponse.json( + { success: false, error: 'Invalid booking date format. Use YYYY-MM-DD.' }, + { status: 400 } + ); + } + if (diffDays < 1) { + return NextResponse.json( + { success: false, error: 'Bookings must be scheduled at least one day in advance.' }, + { status: 400 } + ); + } + } catch (dateErr) { + console.error('Date validation error:', dateErr); + return NextResponse.json( + { success: false, error: 'Failed to validate booking date' }, + { status: 400 } + ); + } + + // Check if time slot is already booked + const existingBooking = await Booking.findOne({ + serviceId, + date, + time, + status: { $ne: 'cancelled' } + }); + + if (existingBooking) { + return NextResponse.json( + { success: false, error: 'This time slot is already booked' }, + { status: 409 } + ); + } + + // Compute pricing with promotions/referrals + const normalizeCode = (c: string) => (c || '').trim().toUpperCase(); + let promoCode = normalizeCode(rawPromoCode || ''); + let referralCode = normalizeCode(rawReferralCode || ''); + const originalAmount = Number(amount || price || 0); + let runningAmount = originalAmount; + let discountAmount = 0; + let discountPercentApplied = 0; + let referrerEmail: string | undefined; + + // Apply promotion first (if any) + if (promoCode) { + const promo = await Promotion.findOne({ code: promoCode }); + if (promo) { + const now = new Date(); + const active = promo.active !== false && (!promo.startsAt || new Date(promo.startsAt) <= now) && (!promo.endsAt || new Date(promo.endsAt) >= now) && (!promo.usageLimit || promo.usageCount < promo.usageLimit); + if (active) { + let d = 0; + if (promo.type === 'percent') { + d = (runningAmount * (promo.value || 0)) / 100; + if (promo.maxDiscount && d > promo.maxDiscount) d = promo.maxDiscount; + discountPercentApplied += promo.value || 0; + } else { + d = Math.min(runningAmount, promo.value || 0); + } + d = Math.max(0, +d.toFixed(2)); + runningAmount = Math.max(0, +(runningAmount - d).toFixed(2)); + discountAmount += d; + // increment usage + await Promotion.updateOne({ _id: promo._id }, { $inc: { usageCount: 1 }, $set: { updatedAt: new Date() } }); + } else { + // inactive code provided - ignore silently + promoCode = ''; + } + } else { + // invalid code - ignore silently + promoCode = ''; + } + } + + // Apply referral (policy: first booking only, 10% off) + if (referralCode) { + const refUser = await User.findOne({ referralCode: referralCode }); + const customerUser = await User.findOne({ email: customerEmail }); + const isSelf = refUser && customerUser && refUser.email?.toLowerCase() === customerUser.email?.toLowerCase(); + const eligible = refUser && customerUser && !customerUser.hasBookedBefore && !isSelf; + if (eligible) { + const perc = 10; // fixed policy + const d = Math.max(0, +((runningAmount * perc) / 100).toFixed(2)); + runningAmount = Math.max(0, +(runningAmount - d).toFixed(2)); + discountAmount += d; + discountPercentApplied += perc; + referrerEmail = refUser!.email; + // record relationships/credits + await User.updateOne({ _id: customerUser!._id }, { $set: { referredBy: refUser!.email, hasBookedBefore: true, updatedAt: new Date() } }); + await User.updateOne({ _id: refUser!._id }, { $inc: { referralCredits: 1 }, $set: { updatedAt: new Date() } }); + } else { + referralCode = ''; + } + } + + const finalAmount = Math.max(0, +runningAmount.toFixed(2)); + + // Create new booking with all required fields + const booking = new Booking({ + userId, + serviceId, + serviceName, + serviceType, + providerName, + amount: finalAmount, + price: finalAmount, + originalAmount, + finalAmount, + discountPercentApplied, + discountAmount, + estimatedTime, + serviceDuration, + userEmail, + customerEmail, + description, + specialInstructions, + clientName, + clientPhone, + clientEmail, + referralCodeUsed: referralCode || undefined, + referrerEmail: referrerEmail || undefined, + address, + date, + time, + status: status || 'confirmed', + paymentStatus: paymentStatus || 'pending', + createdAt: new Date(), + updatedAt: new Date() + }); + + await booking.save(); + console.log('Booking saved successfully:', booking._id); + + // Validate email format before sending + const isValidEmail = (email: string) => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }; + + // Send confirmation emails + try { + // Format date and time for emails + const bookingDate = new Date(date).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + // Customer confirmation email + const customerEmailSubject = `TradesMonk Booking Confirmation - ${serviceName}`; + + // Validate customer email before sending + if (!isValidEmail(customerEmail)) { + console.error('Invalid customer email format:', customerEmail); + throw new Error('Invalid customer email format'); + } + const customerEmailContent = ` +
    +

    Booking Confirmed!

    + +

    Hi ${clientName},

    + +

    Your service booking has been confirmed. Here are the details:

    + +
    +

    Booking Details

    +

    Service: ${serviceName}

    +

    Provider: ${providerName}

    +

    Date: ${bookingDate}

    +

    Time: ${time}

    +

    Price: $${amount}

    +

    Address: ${address.addressLine1}, ${address.city}, ${address.state} ${address.zipCode}

    + ${specialInstructions ? `

    Special Instructions: ${specialInstructions}

    ` : ''} +
    + +

    Payment: Payment will be collected in person after service completion.

    + +

    The service provider will contact you before the appointment to confirm details.

    + +

    Thank you for choosing TradesMonk!

    + +
    +

    This is an automated message from TradesMonk. Please do not reply to this email.

    +
    + `; + + // Send customer email + console.log('Sending customer confirmation email to:', customerEmail); + await sendEmail(customerEmail, customerEmailSubject, customerEmailContent); + + // Provider notification email (if provider has email) + if (providerName && providerName !== 'Unknown Provider' && providerEmail) { + const providerEmailSubject = `New TradesMonk Booking - ${serviceName}`; + const providerEmailContent = ` +
    +

    New Booking Received!

    + +

    Hello ${providerName},

    + +

    You have received a new service booking through TradesMonk:

    + +
    +

    Booking Details

    +

    Service: ${serviceName}

    +

    Customer: ${clientName}

    +

    Phone: ${clientPhone || 'Not provided'}

    +

    Email: ${customerEmail}

    +

    Date: ${bookingDate}

    +

    Time: ${time}

    +

    Price: $${amount}

    +

    Address: ${address.addressLine1}, ${address.city}, ${address.state} ${address.zipCode}

    + ${specialInstructions ? `

    Special Instructions: ${specialInstructions}

    ` : ''} +
    + +

    Next Steps:

    +
      +
    • Contact the customer to confirm the appointment details
    • +
    • Arrive on time at the specified address
    • +
    • Complete the service as requested
    • +
    • Collect payment in person after completion
    • +
    + +

    Thank you for being part of the TradesMonk network!

    + +
    +

    This is an automated message from TradesMonk. Please do not reply to this email.

    +
    + `; + + // Send provider notification to provider's email + if (isValidEmail(providerEmail)) { + console.log('Sending provider notification email to:', providerEmail); + await sendEmail(providerEmail, providerEmailSubject, providerEmailContent); + } else { + console.warn('No valid provider email available, skipping provider notification'); + } + } + + // Send review request email immediately after booking creation (includes direct review link) + try { + const origin = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const reviewParams = new URLSearchParams({ + bookingId: String(booking._id), + customerName: clientName || 'Customer', + serviceName, + providerName: providerName || 'Service Provider', + }); + const reviewUrl = `${origin}/reviews/submit?${reviewParams.toString()}`; + + const reviewEmailSubject = `How was your ${serviceName}? Please leave a quick review`; + const reviewEmailContent = ` +
    +

    We'd love your feedback

    +

    Hi ${clientName || 'there'},

    +

    Thanks for booking ${serviceName} with ${providerName || 'our provider'}. It would mean a lot if you could leave a quick review.

    +

    + Leave a Review +

    +

    Or copy this link:
    + ${reviewUrl} +

    +
    +

    This is an automated email from TradesMonk.

    +
    + `; + + console.log('Sending review request email to:', customerEmail); + await sendEmail(customerEmail, reviewEmailSubject, reviewEmailContent); + } catch (reviewErr) { + console.error('Error sending review request email:', reviewErr); + } + + console.log('All confirmation emails sent successfully'); + + } catch (emailError) { + console.error('Error sending confirmation emails:', emailError); + // Don't fail the booking if email sending fails + } + + return NextResponse.json({ + success: true, + booking: { + id: booking._id, + serviceId: booking.serviceId, + serviceName: booking.serviceName, + date: booking.date, + time: booking.time, + price: booking.price, + status: booking.status + } + }); + } catch (error) { + console.error('Error creating booking:', error); + return NextResponse.json( + { success: false, error: 'Failed to create booking' }, + { status: 500 } + ); + } +} diff --git a/app/api/bookings/test/route.ts b/app/api/bookings/test/route.ts new file mode 100644 index 0000000..b706c1b --- /dev/null +++ b/app/api/bookings/test/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/lib/dbConnect'; +import Booking from '@/models/Booking'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// Use the central email API that already works in your app +async function sendEmail(to: string, subject: string, htmlContent: string) { + const origin = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const res = await fetch(`${origin}/api/send-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to, subject, message: htmlContent }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data?.details || data?.error || 'Email API failed'); + } + return data.messageId || 'email-api-message-id'; +} + +// GET /api/bookings/test?to=email@example.com&serviceName=Handyman&providerName=Tony +export async function GET(req: NextRequest) { + try { + if (process.env.NODE_ENV === 'production') { + return NextResponse.json({ success: false, error: 'Disabled in production' }, { status: 403 }); + } + + await dbConnect(); + + const url = new URL(req.url); + const to = url.searchParams.get('to'); + const serviceName = url.searchParams.get('serviceName') || 'Handyman - Test Job'; + const providerName = url.searchParams.get('providerName') || 'TradesMonk Provider'; + const providerEmailFromQuery = url.searchParams.get('providerEmail'); + const amountParam = url.searchParams.get('amount'); + const amount = amountParam ? Number(amountParam) : 150; + + if (!to) { + return NextResponse.json({ success: false, error: 'Missing to=email@example.com' }, { status: 400 }); + } + + // Tomorrow's date (YYYY-MM-DD) + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + const date = tomorrow.toISOString().split('T')[0]; + const time = '10:00 AM'; + + // Minimal required fields for Booking model/API validation + const booking = new Booking({ + userId: 'test-user', + serviceId: 'test-service-id', + serviceName, + serviceType: 'test', + providerName, + amount, + price: amount, + estimatedTime: '2 hours', + serviceDuration: 2, + userEmail: process.env.EMAIL_USER || 'noreply@tradesmonk.com', + customerEmail: to, + description: 'Automated test booking created from /api/bookings/test', + specialInstructions: 'Test run', + clientName: 'Test Customer', + clientPhone: '(555) 555-5555', + clientEmail: to, + address: { + addressLine1: '123 Test St', + city: 'San Diego', + state: 'CA', + zipCode: '92101', + serviceNotes: 'Test location', + }, + date, + time, + status: 'confirmed', + paymentStatus: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + }); + + await booking.save(); + + // Prepare and send emails (same style as real route) + const bookingDate = new Date(date).toLocaleDateString('en-US', { + weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' + }); + + const customerEmailSubject = `TradesMonk Booking Confirmation - ${serviceName} (TEST)`; + const customerEmailContent = ` +
    +

    [TEST] Booking Confirmed!

    +

    Hi ${'Test Customer'},

    +

    Your TEST service booking has been created:

    +
    +

    Booking Details

    +

    Service: ${serviceName}

    +

    Provider: ${providerName}

    +

    Date: ${bookingDate}

    +

    Time: ${time}

    +

    Price: $${amount}

    +

    Address: 123 Test St, San Diego, CA 92101

    +
    +

    This is a TEST email triggered by /api/bookings/test.

    +
    + `; + + const providerEmail = providerEmailFromQuery || process.env.EMAIL_USER; + const providerEmailSubject = `New TradesMonk Booking - ${serviceName} (TEST)`; + const providerEmailContent = ` +
    +

    [TEST] New Booking Received!

    +

    Hello ${providerName},

    +

    A TEST booking was created:

    +
    +

    Booking Details

    +

    Service: ${serviceName}

    +

    Customer: Test Customer

    +

    Email: ${to}

    +

    Date: ${bookingDate}

    +

    Time: ${time}

    +

    Price: $${amount}

    +

    Address: 123 Test St, San Diego, CA 92101

    +
    +

    This is a TEST email triggered by /api/bookings/test.

    +
    + `; + + const emailResults: any = {}; + try { + emailResults.customerMessageId = await sendEmail(to, customerEmailSubject, customerEmailContent); + } catch (e: any) { + emailResults.customerError = e?.message || 'Failed to send customer email'; + } + try { + if (providerEmail) { + emailResults.providerMessageId = await sendEmail(providerEmail, providerEmailSubject, providerEmailContent); + } + } catch (e: any) { + emailResults.providerError = e?.message || 'Failed to send provider email'; + } + + return NextResponse.json({ + success: true, + note: 'TEST booking created for tomorrow and emails attempted', + booking: { + id: booking._id, + serviceName, + date, + time, + amount, + providerEmail, + }, + emailResults, + }); + } catch (error: any) { + console.error('Test booking error:', error); + return NextResponse.json({ success: false, error: error?.message || 'Failed to create test booking' }, { status: 500 }); + } +} diff --git a/app/api/bookings/user/route.ts b/app/api/bookings/user/route.ts new file mode 100644 index 0000000..f2b19fc --- /dev/null +++ b/app/api/bookings/user/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + try { + // Get the user's session + const session = await getServerSession(authOptions); + if (!session || !session.user) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + + const userEmail = session.user.email; + if (!userEmail) { + return NextResponse.json({ success: false, error: 'User email not found' }, { status: 400 }); + } + + // Connect to the database + await dbConnect(); + + // Import Mongoose models + const { default: Booking } = await import('@/models/Booking'); + const { default: Service } = await import('@/models/Service'); + + // Query bookings where the user is the customer + const clientBookings = await Booking.find({ + customerEmail: userEmail + }).sort({ createdAt: -1 }); + + // Query services owned by this user (to get list of services offered by provider) + const userServices = await Service.find({ + $or: [{ userEmail: userEmail }, { email: userEmail }] + }); + + // Get service IDs offered by this provider + const serviceIds = userServices.map(service => service._id.toString()); + + // Query bookings where the user is the service provider + const providerBookings = await Booking.find({ + serviceId: { $in: serviceIds } + }).sort({ createdAt: -1 }); + + return NextResponse.json({ + success: true, + clientBookings, + providerBookings, + isProvider: serviceIds.length > 0 + }); + } catch (error: any) { + console.error('Error fetching user bookings:', error); + return NextResponse.json({ + success: false, + error: error.message || 'Failed to fetch bookings' + }, { status: 500 }); + } +} diff --git a/app/api/fetchData.ts b/app/api/fetchData.ts deleted file mode 100644 index c353305..0000000 --- a/app/api/fetchData.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { google } from 'googleapis'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import path from 'path'; - -const auth = new google.auth.GoogleAuth({ - keyFile: path.join(process.cwd(), 'C:\\Users\\yashp\\trucktrack\\delta-deck-433915-q3-1fca1f2d39ba.json'), // Update this path - scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], -}); - -const sheets = google.sheets({ version: 'v4', auth }); - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const response = await sheets.spreadsheets.values.get({ - spreadsheetId: '1g4ceACw8_xPbDzvk5mbIBLr6hu6KWeSxO_qsogXISEM', // Replace this with your actual spreadsheet ID - range: 'Sheet1!A2:C', - }); - - const rows = response.data.values; - - if (rows?.length) { - const locations = rows.map(row => ({ - name: row[0], - lat: parseFloat(row[1]), - lng: parseFloat(row[2]), - })); - res.status(200).json(locations); - } else { - res.status(200).json([]); - } - } catch (error) { - console.error('Error fetching data from Google Sheets:', error.message, error.stack); - res.status(500).json({ error: 'Failed to fetch data' }); - } -} diff --git a/app/api/images/[id]/route.ts b/app/api/images/[id]/route.ts new file mode 100644 index 0000000..58fe30c --- /dev/null +++ b/app/api/images/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "../../../../lib/dbConnect"; +import Image from "../../../../models/Image"; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + await dbConnect(); + + const image = await Image.findById(params.id); + + if (!image) { + return NextResponse.json( + { success: false, error: "Image not found" }, + { status: 404 } + ); + } + + // Return the image with appropriate headers + return new NextResponse(image.data, { + headers: { + 'Content-Type': image.contentType, + 'Content-Length': image.size.toString(), + 'Cache-Control': 'public, max-age=31536000', // Cache for 1 year + }, + }); + } catch (error) { + console.error("Error serving image:", error); + return NextResponse.json( + { success: false, error: "Error serving image" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/promotions/route.ts b/app/api/promotions/route.ts new file mode 100644 index 0000000..8c9e3b9 --- /dev/null +++ b/app/api/promotions/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import Promotion from '@/models/Promotion'; + +function normalizeCode(code: string) { + return (code || '').trim().toUpperCase(); +} + +function isActive(p: any) { + const now = new Date(); + if (p.active === false) return false; + if (p.startsAt && new Date(p.startsAt) > now) return false; + if (p.endsAt && new Date(p.endsAt) < now) return false; + if (typeof p.usageLimit === 'number' && typeof p.usageCount === 'number' && p.usageLimit >= 0) { + if (p.usageCount >= p.usageLimit) return false; + } + return true; +} + +function applyDiscount(p: any, amount: number) { + const base = Math.max(0, amount || 0); + let discount = 0; + if (p.type === 'percent') { + discount = (base * (p.value || 0)) / 100; + if (p.maxDiscount && discount > p.maxDiscount) discount = p.maxDiscount; + } else if (p.type === 'fixed') { + discount = Math.min(base, p.value || 0); + } + const final = Math.max(0, +(base - discount).toFixed(2)); + return { discount: +discount.toFixed(2), final }; +} + +// GET: list promotions or validate a code +export async function GET(req: NextRequest) { + try { + await dbConnect(); + const { searchParams } = new URL(req.url); + const validateCode = searchParams.get('validate'); + const amountParam = searchParams.get('amount'); + + if (validateCode) { + const code = normalizeCode(validateCode); + const promo = await Promotion.findOne({ code }); + if (!promo) return NextResponse.json({ success: false, error: 'Invalid code' }, { status: 404 }); + if (!isActive(promo)) return NextResponse.json({ success: false, error: 'Code not active' }, { status: 400 }); + const amount = amountParam ? parseFloat(amountParam) : 0; + const { discount, final } = applyDiscount(promo, amount); + return NextResponse.json({ success: true, data: { code: promo.code, type: promo.type, value: promo.value, maxDiscount: promo.maxDiscount || null, discount, final } }); + } + + // Admin list + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + const promos = await Promotion.find({}).sort({ createdAt: -1 }).lean(); + return NextResponse.json({ success: true, data: promos }); + } catch (e) { + console.error('Promotions GET error', e); + return NextResponse.json({ success: false, error: 'Failed' }, { status: 500 }); + } +} + +// POST: create promotion (admin) +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + await dbConnect(); + const body = await req.json(); + const code = normalizeCode(body.code); + if (!code) return NextResponse.json({ success: false, error: 'Code required' }, { status: 400 }); + const doc = await Promotion.create({ + code, + description: body.description || '', + type: body.type, + value: body.value, + maxDiscount: body.maxDiscount || undefined, + active: body.active !== false, + startsAt: body.startsAt ? new Date(body.startsAt) : undefined, + endsAt: body.endsAt ? new Date(body.endsAt) : undefined, + usageLimit: typeof body.usageLimit === 'number' ? body.usageLimit : undefined, + }); + return NextResponse.json({ success: true, data: doc }); + } catch (e: any) { + console.error('Promotions POST error', e); + const dup = e?.code === 11000; + return NextResponse.json({ success: false, error: dup ? 'Code already exists' : 'Failed to create' }, { status: dup ? 409 : 500 }); + } +} + +// PUT: update promotion (admin) +export async function PUT(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + await dbConnect(); + const body = await req.json(); + const code = normalizeCode(body.code); + if (!code) return NextResponse.json({ success: false, error: 'Code required' }, { status: 400 }); + + const update: any = { + description: body.description, + type: body.type, + value: body.value, + maxDiscount: body.maxDiscount, + active: body.active, + startsAt: body.startsAt ? new Date(body.startsAt) : undefined, + endsAt: body.endsAt ? new Date(body.endsAt) : undefined, + usageLimit: typeof body.usageLimit === 'number' ? body.usageLimit : undefined, + updatedAt: new Date(), + }; + + const doc = await Promotion.findOneAndUpdate({ code }, update, { new: true }); + if (!doc) return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); + return NextResponse.json({ success: true, data: doc }); + } catch (e) { + console.error('Promotions PUT error', e); + return NextResponse.json({ success: false, error: 'Failed to update' }, { status: 500 }); + } +} + +// DELETE: delete promotion (admin) +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + await dbConnect(); + const { searchParams } = new URL(req.url); + const code = normalizeCode(searchParams.get('code') || ''); + if (!code) return NextResponse.json({ success: false, error: 'Code required' }, { status: 400 }); + const res = await Promotion.findOneAndDelete({ code }); + if (!res) return NextResponse.json({ success: false, error: 'Not found' }, { status: 404 }); + return NextResponse.json({ success: true }); + } catch (e) { + console.error('Promotions DELETE error', e); + return NextResponse.json({ success: false, error: 'Failed to delete' }, { status: 500 }); + } +} diff --git a/app/api/referrals/route.ts b/app/api/referrals/route.ts new file mode 100644 index 0000000..3f267b5 --- /dev/null +++ b/app/api/referrals/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/lib/dbConnect'; +import User, { IUser } from '@/models/User'; + +// GET /api/referrals?validate=TM-XXXXXX&customerEmail=foo@bar +export async function GET(req: NextRequest) { + try { + await dbConnect(); + const { searchParams } = new URL(req.url); + const code = (searchParams.get('validate') || '').trim().toUpperCase(); + const customerEmail = (searchParams.get('customerEmail') || '').trim().toLowerCase(); + if (!code) return NextResponse.json({ success: false, error: 'Code required' }, { status: 400 }); + + const referrer = await User.findOne({ referralCode: code }).lean() as IUser | null; + if (!referrer) return NextResponse.json({ success: false, error: 'Invalid referral code' }, { status: 404 }); + if (!referrer.referralEligible) { + return NextResponse.json({ success: false, error: 'Referral program not enabled for this user' }, { status: 400 }); + } + + if (customerEmail && referrer.email && referrer.email.toLowerCase() === customerEmail) { + return NextResponse.json({ success: false, error: 'Cannot use your own referral code' }, { status: 400 }); + } + + // Basic policy: new customer gets 10% off first booking; referrer gets +1 credit + return NextResponse.json({ success: true, data: { code, referrerEmail: referrer.email, discountPercent: 10 } }); + } catch (e) { + console.error('Referral validate error', e); + return NextResponse.json({ success: false, error: 'Failed' }, { status: 500 }); + } +} diff --git a/app/api/referrals/send-email/route.ts b/app/api/referrals/send-email/route.ts new file mode 100644 index 0000000..c864565 --- /dev/null +++ b/app/api/referrals/send-email/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import User from '@/models/User'; +import nodemailer from 'nodemailer'; + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ success: false, error: 'Auth required' }, { status: 401 }); + const isAdmin = session.user.email === 'yashp.d39@gmail.com'; + if (!isAdmin) return NextResponse.json({ success: false, error: 'Admin only' }, { status: 403 }); + + const { email } = await req.json(); + if (!email) return NextResponse.json({ success: false, error: 'email required' }, { status: 400 }); + + await dbConnect(); + const user = await User.findOne({ email }); + if (!user) return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 }); + + // Ensure referral code exists + if (!user.referralCode) { + const gen = async () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code = ''; + for (let i = 0; i < 6; i++) code += chars[Math.floor(Math.random() * chars.length)]; + return `TM-${code}`; + }; + let code = await gen(); + for (let i = 0; i < 5; i++) { + const taken = await User.findOne({ referralCode: code }); + if (!taken) break; + code = await gen(); + } + user.referralCode = code; + await user.save(); + } + + // Compose email + const referralUrl = `${process.env.NEXTAUTH_URL || 'https://tradesmonk.com'}/?ref=${encodeURIComponent(user.referralCode)}`; + const subject = `Your TradesMonk referral code`; + const html = ` +
    +
    +

    TradesMonk Referral Program

    +
    +
    +

    Hi ${user.name || user.email},

    +

    Your referral code is:

    +

    ${user.referralCode}

    +

    Share this link with friends:

    +

    ${referralUrl}

    +

    When your friend books their first service using your code, they'll get a discount and you'll earn referral credit.

    +

    If you didn't expect this, you can ignore this email.

    +
    +
    + `; + + const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 587, + secure: false, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + await transporter.sendMail({ + from: `TradesMonk <${process.env.EMAIL_USER}>`, + to: user.email, + subject, + html, + }); + + return NextResponse.json({ success: true, data: { email: user.email, referralCode: user.referralCode } }); + } catch (e) { + console.error('Referral send email error', e); + return NextResponse.json({ success: false, error: 'Failed to send email' }, { status: 500 }); + } +} diff --git a/app/api/reviews/schedule/route.ts b/app/api/reviews/schedule/route.ts new file mode 100644 index 0000000..0d9423c --- /dev/null +++ b/app/api/reviews/schedule/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { connectToDatabase } from '@/lib/mongodb'; +import ScheduledReview from '@/models/ScheduledReview'; + +interface ScheduleReviewRequest { + customerEmail: string; + customerName: string; + bookingId: string; + serviceName: string; + serviceType: string; + providerName: string; + providerEmail?: string; + jobCompletedDate: string; // ISO date string when job was completed +} + +export async function POST(request: NextRequest) { + try { + await connectToDatabase(); + + const { + customerEmail, + customerName, + bookingId, + serviceName, + serviceType, + providerName, + providerEmail, + jobCompletedDate + }: ScheduleReviewRequest = await request.json(); + + // Validate required fields + if (!customerEmail || !customerName || !bookingId || !serviceName || !providerName || !jobCompletedDate) { + return NextResponse.json( + { error: 'Missing required fields for scheduling review email' }, + { status: 400 } + ); + } + + // Check if review email is already scheduled for this booking + const existingSchedule = await ScheduledReview.findOne({ bookingId }); + if (existingSchedule) { + return NextResponse.json( + { error: 'Review email already scheduled for this booking' }, + { status: 409 } + ); + } + + // Calculate scheduled send date (24 hours after job completion) + const completedDate = new Date(jobCompletedDate); + const scheduledSendDate = new Date(completedDate.getTime() + (24 * 60 * 60 * 1000)); // Add 24 hours + + // Create scheduled review entry + const scheduledReview = new ScheduledReview({ + bookingId, + customerEmail: customerEmail.toLowerCase(), + customerName, + serviceName, + serviceType, + providerName, + providerEmail, + jobCompletedDate: completedDate, + scheduledSendDate, + emailSent: false, + attemptCount: 0 + }); + + await scheduledReview.save(); + + return NextResponse.json({ + success: true, + message: 'Review email scheduled successfully', + scheduledSendDate: scheduledSendDate.toISOString(), + bookingId + }, { status: 201 }); + + } catch (error) { + console.error('Error scheduling review email:', error); + return NextResponse.json( + { error: 'Failed to schedule review email' }, + { status: 500 } + ); + } +} + +// GET route to check scheduled reviews (for debugging/admin purposes) +export async function GET(request: NextRequest) { + try { + await connectToDatabase(); + + const { searchParams } = new URL(request.url); + const bookingId = searchParams.get('bookingId'); + const pending = searchParams.get('pending') === 'true'; + + let query: any = {}; + + if (bookingId) { + query.bookingId = bookingId; + } + + if (pending) { + query.emailSent = false; + query.scheduledSendDate = { $lte: new Date() }; + } + + const scheduledReviews = await ScheduledReview.find(query) + .sort({ scheduledSendDate: 1 }) + .limit(50); + + return NextResponse.json({ + success: true, + scheduledReviews, + count: scheduledReviews.length + }); + + } catch (error) { + console.error('Error fetching scheduled reviews:', error); + return NextResponse.json( + { error: 'Failed to fetch scheduled reviews' }, + { status: 500 } + ); + } +} diff --git a/app/api/reviews/send-email/route.ts b/app/api/reviews/send-email/route.ts new file mode 100644 index 0000000..5a34d7f --- /dev/null +++ b/app/api/reviews/send-email/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; +import nodemailer from 'nodemailer'; + +interface ReviewEmailRequest { + customerEmail: string; + customerName: string; + bookingId: string; + serviceName: string; + serviceType: string; + providerName: string; + providerEmail?: string; + bookingDate: string; +} + +export async function POST(request: NextRequest) { + try { + const { + customerEmail, + customerName, + bookingId, + serviceName, + serviceType, + providerName, + providerEmail, + bookingDate + }: ReviewEmailRequest = await request.json(); + + // Validate required fields + if (!customerEmail || !customerName || !bookingId || !serviceName || !providerName) { + return NextResponse.json( + { error: 'Missing required fields for review email' }, + { status: 400 } + ); + } + + // Create review link with booking details + const reviewUrl = `${process.env.NEXTAUTH_URL}/reviews/submit?` + + `bookingId=${encodeURIComponent(bookingId)}` + + `&customerEmail=${encodeURIComponent(customerEmail)}` + + `&customerName=${encodeURIComponent(customerName)}` + + `&serviceName=${encodeURIComponent(serviceName)}` + + `&serviceType=${encodeURIComponent(serviceType)}` + + `&providerName=${encodeURIComponent(providerName)}` + + `&bookingDate=${encodeURIComponent(bookingDate)}`; + + // Create email content + const emailSubject = `How was your ${serviceName} service with TradesMonk?`; + + const emailContent = ` +
    +
    +

    TradesMonk

    +

    We'd love your feedback!

    +
    + +
    +

    Hi ${customerName}!

    + +

    + Thank you for choosing TradesMonk for your recent ${serviceName} service with ${providerName} on ${new Date(bookingDate).toLocaleDateString()}. +

    + +

    + Your feedback helps us maintain quality service and helps other customers make informed decisions. It only takes 2 minutes! +

    + + + +
    +

    Your Service Details:

    +
      +
    • Service: ${serviceName}
    • +
    • Provider: ${providerName}
    • +
    • Date: ${new Date(bookingDate).toLocaleDateString()}
    • +
    • Booking ID: ${bookingId}
    • +
    +
    + +

    + Your review will be publicly visible to help other customers. If you experienced any issues, + please also contact us directly at support@tradesmonk.com +

    + +
    + +

    + This email was sent because you recently completed a service booking with TradesMonk.
    + If you believe this was sent in error, please contact us at support@tradesmonk.com +

    +
    +
    + `; + + // Send email using the existing email configuration + const transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 587, + secure: false, + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, + }); + + const mailOptions = { + from: `"TradesMonk Reviews" <${process.env.EMAIL_USER}>`, + to: customerEmail, + subject: emailSubject, + html: emailContent, + }; + + await transporter.sendMail(mailOptions); + + return NextResponse.json({ + success: true, + message: 'Review email sent successfully', + reviewUrl + }); + + } catch (error) { + console.error('Error sending review email:', error); + return NextResponse.json( + { error: 'Failed to send review email' }, + { status: 500 } + ); + } +} diff --git a/app/api/reviews/submit/route.ts b/app/api/reviews/submit/route.ts new file mode 100644 index 0000000..78d0525 --- /dev/null +++ b/app/api/reviews/submit/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/lib/dbConnect'; +import Review from '@/models/Review'; + +interface ReviewSubmission { + customerEmail: string; + customerName: string; + bookingId: string; + serviceName: string; + serviceType: string; + providerName: string; + providerEmail?: string; + rating: number; + reviewText?: string; + wouldRecommend?: boolean; + serviceQuality?: number; + timeliness?: number; + communication?: number; +} + +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + await dbConnect(); + + const reviewData: ReviewSubmission = await request.json(); + + // Validate required fields (include serviceType which the model requires) + if (!reviewData.customerEmail || !reviewData.customerName || !reviewData.bookingId || + !reviewData.serviceName || !reviewData.serviceType || !reviewData.providerName || !reviewData.rating) { + return NextResponse.json( + { success: false, error: 'Missing required fields for review submission' }, + { status: 400 } + ); + } + + // Validate rating is between 1-5 + if (reviewData.rating < 1 || reviewData.rating > 5) { + return NextResponse.json( + { error: 'Rating must be between 1 and 5 stars' }, + { status: 400 } + ); + } + + // Check if review already exists for this booking + const existingReview = await Review.findOne({ + bookingId: reviewData.bookingId, + customerEmail: reviewData.customerEmail + }); + + if (existingReview) { + return NextResponse.json({ + success: false, + error: 'A review has already been submitted for this booking' + }, { status: 409 }); + } + + // Create new review + const newReview = new Review({ + customerEmail: reviewData.customerEmail.toLowerCase(), + customerName: reviewData.customerName, + bookingId: reviewData.bookingId, + serviceName: reviewData.serviceName, + serviceType: reviewData.serviceType, + providerName: reviewData.providerName, + providerEmail: reviewData.providerEmail, + rating: reviewData.rating, + reviewText: reviewData.reviewText || '', + wouldRecommend: reviewData.wouldRecommend, + serviceQuality: reviewData.serviceQuality, + timeliness: reviewData.timeliness, + communication: reviewData.communication, + reviewDate: new Date(), + isVerified: true + }); + + const savedReview = await newReview.save(); + + return NextResponse.json({ + success: true, + message: 'Review submitted successfully', + reviewId: savedReview._id + }, { status: 201 }); + + } catch (error: any) { + console.error('Error submitting review:', error); + return NextResponse.json( + { success: false, error: error?.message || 'Failed to submit review' }, + { status: 500 } + ); + } +} + +// GET route to fetch reviews (for display purposes) +export async function GET(request: NextRequest) { + try { + await dbConnect(); + + const { searchParams } = new URL(request.url); + const providerName = searchParams.get('provider'); + const serviceName = searchParams.get('service'); + const limit = parseInt(searchParams.get('limit') || '10'); + const page = parseInt(searchParams.get('page') || '1'); + + let query: any = {}; + + if (providerName) { + query.providerName = { $regex: providerName, $options: 'i' }; + } + + if (serviceName) { + query.serviceName = { $regex: serviceName, $options: 'i' }; + } + + const skip = (page - 1) * limit; + + const reviews = await Review.find(query) + .sort({ reviewDate: -1 }) + .skip(skip) + .limit(limit) + .select('-customerEmail') // Don't expose customer emails publicly + .lean(); + + const totalReviews = await Review.countDocuments(query); + const totalPages = Math.ceil(totalReviews / limit); + + // Calculate average rating + const avgRatingResult = await Review.aggregate([ + { $match: query }, + { + $group: { + _id: null, + averageRating: { $avg: '$rating' }, + totalReviews: { $sum: 1 } + } + } + ]); + + const averageRating = avgRatingResult.length > 0 ? + Math.round(avgRatingResult[0].averageRating * 10) / 10 : 0; + + return NextResponse.json({ + success: true, + reviews, + pagination: { + currentPage: page, + totalPages, + totalReviews, + hasNextPage: page < totalPages, + hasPrevPage: page > 1 + }, + statistics: { + averageRating, + totalReviews + } + }); + + } catch (error) { + console.error('Error fetching reviews:', error); + return NextResponse.json( + { error: 'Failed to fetch reviews' }, + { status: 500 } + ); + } +} diff --git a/app/api/send-email/route.ts b/app/api/send-email/route.ts new file mode 100644 index 0000000..09e4054 --- /dev/null +++ b/app/api/send-email/route.ts @@ -0,0 +1,86 @@ +// app/api/send-email/route.ts +import { google } from 'googleapis'; +import { NextResponse } from 'next/server'; + +// Configure OAuth2 client +const oauth2Client = new google.auth.OAuth2( + process.env.EMAIL_CLIENT_ID, + process.env.EMAIL_CLIENT_SECRET, + 'https://developers.google.com/oauthplayground' +); + +// Set credentials with refresh token +oauth2Client.setCredentials({ + refresh_token: process.env.GOOGLE_REFRESH_TOKEN +}); + +const gmail = google.gmail({ version: 'v1', auth: oauth2Client }); + +export async function POST(request: Request) { + try { + const { to, subject, message } = await request.json(); + + // Validate required fields + if (!to || !subject || !message) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + // Create the email with proper headers and black text styling + const emailContent = [ + 'Content-Type: text/html; charset=utf-8', + 'MIME-Version: 1.0', + `To: ${to}`, + 'From: "TradesMonk" <' + process.env.EMAIL_USER + '>', + `Subject: ${subject}`, + '', + `
    +

    TradesMonk

    +
    ${message}
    +
    +

    This email was sent from the TradesMonk application.

    +
    ` + ].join('\n'); + + // Encode the email for the Gmail API + const encodedMessage = Buffer.from(emailContent) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + console.log(`Sending email to ${to} with subject "${subject}"`); + + // Send the email + const result = await gmail.users.messages.send({ + userId: 'me', + requestBody: { + raw: encodedMessage + } + }); + + console.log('Email sent successfully, message ID:', result.data.id); + + return NextResponse.json( + { success: true, messageId: result.data.id }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error sending email:', error); + + // More detailed error logging for debugging + if (error.response) { + console.error('API response error:', { + status: error.response.status, + data: error.response.data + }); + } + + return NextResponse.json( + { error: 'Failed to send email', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/api/service-templates/route.ts b/app/api/service-templates/route.ts new file mode 100644 index 0000000..93e1da2 --- /dev/null +++ b/app/api/service-templates/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import ServiceTemplate from '@/models/ServiceTemplate'; + +// Create a new service template +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + try { + await dbConnect(); + const body = await req.json(); + const { trade, name, description, price, timeEstimate } = body || {}; + const ALLOWED = ['handyman', 'plumbing', 'electrician', 'painting']; + + if (!trade || !ALLOWED.includes(trade)) { + return NextResponse.json({ error: `Invalid trade. Must be one of: ${ALLOWED.join(', ')}` }, { status: 400 }); + } + if (!name || !description || !price || !timeEstimate) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const tpl = await ServiceTemplate.create({ + trade, + name, + description, + price, + timeEstimate, + createdBy: session.user.email, + }); + return NextResponse.json({ success: true, data: tpl }, { status: 201 }); + } catch (err: any) { + console.error('ServiceTemplate POST error:', err); + return NextResponse.json({ error: 'Failed to create service template' }, { status: 500 }); + } +} + +// Bulk upsert service templates +export async function PUT(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + try { + await dbConnect(); + const body = await req.json(); + const items: any[] = Array.isArray(body?.items) ? body.items : []; + if (!items.length) return NextResponse.json({ error: 'No items provided' }, { status: 400 }); + + const ALLOWED = ['handyman', 'plumbing', 'electrician', 'painting']; + const ops = items.map((it) => { + const { _id, trade, name, description, price, timeEstimate } = it || {}; + if (!trade || !ALLOWED.includes(trade) || !name || !description || !price || !timeEstimate) { + throw new Error('Invalid item payload'); + } + const doc = { trade, name, description, price, timeEstimate, createdBy: session.user!.email! }; + if (_id) return ServiceTemplate.findByIdAndUpdate(_id, doc, { new: true }); + return ServiceTemplate.create(doc); + }); + + const results = await Promise.all(ops); + return NextResponse.json({ success: true, data: results }); + } catch (err: any) { + console.error('ServiceTemplate PUT error:', err); + return NextResponse.json({ error: 'Failed to upsert service templates' }, { status: 500 }); + } +} + +// Bulk delete by IDs +export async function DELETE(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + try { + await dbConnect(); + const body = await req.json(); + const ids: string[] = Array.isArray(body?.ids) ? body.ids : []; + if (!ids.length) return NextResponse.json({ error: 'No ids provided' }, { status: 400 }); + await ServiceTemplate.deleteMany({ _id: { $in: ids } }); + return NextResponse.json({ success: true }); + } catch (err) { + console.error('ServiceTemplate DELETE error:', err); + return NextResponse.json({ error: 'Failed to delete service templates' }, { status: 500 }); + } +} + +// List service templates (optionally by trade) +export async function GET(req: NextRequest) { + try { + await dbConnect(); + const url = new URL(req.url); + const trade = url.searchParams.get('trade'); + const query: any = {}; + if (trade) query.trade = trade; + const items = await ServiceTemplate.find(query).sort({ createdAt: -1 }).lean(); + return NextResponse.json({ success: true, data: items }); + } catch (err: any) { + console.error('ServiceTemplate GET error:', err); + // Graceful fallback so UI continues working: curated defaults + const fallback = [ + // Handyman + { trade: 'handyman', name: '15AMP Wall Outlet Upgrade Package', description: 'Upgrade 20 outlets to modern 15AMP with USB-A/C. White outlets included.', price: '$500', timeEstimate: '4 hours' }, + { trade: 'handyman', name: 'Kitchen Faucet Replacement', description: 'Remove old and install customer-provided kitchen faucet.', price: '$300', timeEstimate: '3 hours' }, + { trade: 'handyman', name: 'Drywall Patch, Texture & Paint', description: 'Repair 3 drywall patches with texture match and paint touch-up.', price: '$500', timeEstimate: '3 hours' }, + { trade: 'handyman', name: 'House Lock Change Service', description: 'Replace locks/door knobs for up to 10 doors (customer provides locks).', price: '$500', timeEstimate: '5 hours' }, + // Plumbing + { trade: 'plumbing', name: 'Faucet Repair & Replacement', description: 'Cartridge replacement, seal fixes, or full faucet install.', price: '$85', timeEstimate: '2 hours' }, + { trade: 'plumbing', name: 'Toilet Repair & Installation', description: 'Troubleshoot and repair or replace toilet with new wax ring.', price: '$120', timeEstimate: '3 hours' }, + { trade: 'plumbing', name: 'Drain Cleaning & Unclogging', description: 'Snake and clean kitchen/bath drains or main lines.', price: '$95', timeEstimate: '2 hours' }, + // Electrician + { trade: 'electrician', name: 'Outlet Installation & Repair', description: 'Install/repair standard, GFCI, or USB outlets.', price: '$95', timeEstimate: '1 hour' }, + { trade: 'electrician', name: 'Light Switch Installation', description: 'Install/replace dimmer, smart, or three-way switches.', price: '$85', timeEstimate: '1 hour' }, + { trade: 'electrician', name: 'Ceiling Fan Installation', description: 'Mount and wire ceiling fan, balance and test.', price: '$120', timeEstimate: '3 hours' }, + // Painting + { trade: 'painting', name: 'Interior Room Painting', description: 'Prep, prime, and paint interior room with trim.', price: '$180', timeEstimate: '5 hours' }, + { trade: 'painting', name: 'Touch-Up & Repair Painting', description: 'Fill nail holes, minor repairs, and color match touch-ups.', price: '$80', timeEstimate: '1 hour' }, + ]; + return NextResponse.json({ success: true, data: fallback, warning: 'DB unavailable, returning fallback templates' }); + } +} diff --git a/app/api/services/[id]/bookings/route.ts b/app/api/services/[id]/bookings/route.ts new file mode 100644 index 0000000..ea38483 --- /dev/null +++ b/app/api/services/[id]/bookings/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/lib/dbConnect'; + +// Get all bookings for a specific service on a given date +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const serviceId = params.id; + + // Get the date from the query parameters + const url = new URL(req.url); + const date = url.searchParams.get('date'); + + if (!serviceId || !date) { + return NextResponse.json({ + success: false, + error: 'Service ID and date are required' + }, { status: 400 }); + } + + // Connect to the database + await dbConnect(); + + // Import Mongoose models + const { default: Booking } = await import('@/models/Booking'); + + // Find all bookings for this service on the specified date + const bookings = await Booking.find({ + serviceId, + $or: [ + // Check in address.date field + { "address.date": date }, + // Also check in date field + { date } + ] + }).select('time address.time'); + + // Normalize the time data format + const normalizedBookings = bookings.map(booking => { + return { + _id: booking._id, + time: booking.time || (booking.address && booking.address.time) + }; + }); + + return NextResponse.json({ + success: true, + bookings: normalizedBookings + }); + } catch (error: any) { + console.error('Error fetching service bookings:', error); + return NextResponse.json({ + success: false, + error: error.message || 'Failed to fetch bookings' + }, { status: 500 }); + } +} diff --git a/app/api/services/[id]/route.ts b/app/api/services/[id]/route.ts new file mode 100644 index 0000000..7030164 --- /dev/null +++ b/app/api/services/[id]/route.ts @@ -0,0 +1,166 @@ +// app/api/services/[id]/route.ts +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "../../../../lib/dbConnect"; +import Service from "../../../../models/Service"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; + +// Get a specific service by ID +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + await dbConnect(); + + const id = params.id; + + try { + const service = await Service.findById(id); + + if (!service) { + return NextResponse.json( + { success: false, error: "Service not found" }, + { status: 404 } + ); + } + + return NextResponse.json( + { success: true, data: service }, + { status: 200 } + ); + } catch (error: any) { + console.error("Error fetching service:", error); + return NextResponse.json( + { success: false, error: error.message }, + { status: 400 } + ); + } +} + +// Update a service +export async function PUT( + req: NextRequest, + { params }: { params: { id: string } } +) { + await dbConnect(); + + const id = params.id; + + // Get the user session to verify authentication + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + try { + // Parse request body + const body = await req.json(); + + // Find the service first to check ownership + const service = await Service.findById(id); + + if (!service) { + return NextResponse.json( + { success: false, error: "Service not found" }, + { status: 404 } + ); + } + + // Make sure the service belongs to the current user + if (service.userEmail !== session.user.email) { + return NextResponse.json( + { success: false, error: "Unauthorized: You can only update your own services" }, + { status: 403 } + ); + } + + // Validate trade type + const ALLOWED_TRADES = ["food_truck", "plumber", "electrician", "handyman", "painter"]; + if (body.trade && !ALLOWED_TRADES.includes(body.trade)) { + return NextResponse.json( + { + success: false, + error: `Invalid trade type. Must be one of: ${ALLOWED_TRADES.join(", ")}` + }, + { status: 400 } + ); + } + + // Update the service + // Note: findByIdAndUpdate returns the document before update by default + const updatedService = await Service.findByIdAndUpdate( + id, + body, + { new: true, runValidators: true } // Return the updated document and run schema validators + ); + + return NextResponse.json( + { success: true, data: updatedService }, + { status: 200 } + ); + } catch (error: any) { + console.error("Error updating service:", error); + return NextResponse.json( + { success: false, error: error.message }, + { status: 400 } + ); + } +} + +// Delete a service +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + await dbConnect(); + + const id = params.id; + + // Get the user session to verify authentication + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + try { + // Find the service first to check ownership + const service = await Service.findById(id); + + if (!service) { + return NextResponse.json( + { success: false, error: "Service not found" }, + { status: 404 } + ); + } + + // Make sure the service belongs to the current user + if (service.userEmail !== session.user.email) { + return NextResponse.json( + { success: false, error: "Unauthorized: You can only delete your own services" }, + { status: 403 } + ); + } + + // Delete the service + await Service.findByIdAndDelete(id); + + return NextResponse.json( + { success: true, message: "Service deleted successfully" }, + { status: 200 } + ); + } catch (error: any) { + console.error("Error deleting service:", error); + return NextResponse.json( + { success: false, error: error.message }, + { status: 400 } + ); + } +} \ No newline at end of file diff --git a/app/api/services/fallback.ts b/app/api/services/fallback.ts new file mode 100644 index 0000000..06d408e --- /dev/null +++ b/app/api/services/fallback.ts @@ -0,0 +1,52 @@ +// app/api/services/fallback.ts +// Fallback services data if MongoDB connection fails + +export const fallbackServices = [ + { + _id: "fallback1", + name: "Joe's Handyman Services", + description: "Professional handyman services for all your home repair needs.", + trade: "handyman", + price: 65, + priceType: "hourly", + location: { + type: "Point", + coordinates: [-122.4194, 37.7749] // San Francisco + }, + userEmail: "joe@example.com", + phone: "415-555-1234", + stripeAccountId: "acct_sample123" + }, + { + _id: "fallback2", + name: "Quick Fix Handyman", + description: "Fast and reliable handyman services for emergencies and routine repairs.", + trade: "handyman", + price: 75, + priceType: "hourly", + location: { + type: "Point", + coordinates: [-122.4341, 37.7325] // Different location in SF + }, + userEmail: "quickfix@example.com", + phone: "415-555-7890", + stripeAccountId: "acct_sample456" + }, + { + _id: "fallback3", + name: "Elite Electricians", + description: "Licensed electricians for all residential and commercial needs.", + trade: "electrician", + price: 95, + priceType: "hourly", + location: { + type: "Point", + coordinates: [-122.4800, 37.7690] // Another location in SF + }, + userEmail: "elite@example.com", + phone: "415-555-3456", + stripeAccountId: "acct_sample789" + } +]; + +export default fallbackServices; diff --git a/app/api/services/route.ts b/app/api/services/route.ts new file mode 100644 index 0000000..75560d4 --- /dev/null +++ b/app/api/services/route.ts @@ -0,0 +1,193 @@ +// app/api/services/route.ts +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "../../../lib/dbConnect"; +import Service from "../../../models/Service"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../../../lib/auth"; +import mongoose from "mongoose"; + +// Get all services +export async function GET(req: NextRequest) { + try { + // Connect to MongoDB + await dbConnect(); + + // Check if we should filter by provider + const url = new URL(req.url); + const providerOnly = url.searchParams.get('providerOnly') === 'true'; + + if (providerOnly) { + // Get the user session + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json( + { success: false, error: 'Authentication required' }, + { status: 401 } + ); + } + + // Fetch only services created by this provider + const services = await Service.find({ userEmail: session.user.email }); + return NextResponse.json( + { success: true, data: services }, + { status: 200 } + ); + } else { + // Fetch all services from database + const services = await Service.find({}); + return NextResponse.json( + { success: true, data: services }, + { status: 200 } + ); + } + } catch (error: any) { + console.error("Error fetching services:", error); + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +// Create a new service +export async function POST(req: NextRequest) { + // Get the user session to verify authentication + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + try { + // Connect to MongoDB first + await dbConnect(); + + // Parse request body + const body = await req.json(); + // Allow optional contact fields + const { phoneNumber, contactEmail } = body as { phoneNumber?: string; contactEmail?: string }; + + // Validate trade type + const ALLOWED_TRADES = ["plumber", "electrician", "handyman", "painter"]; + if (!body.trade || !ALLOWED_TRADES.includes(body.trade)) { + return NextResponse.json( + { + success: false, + error: `Invalid or missing trade type. Must be one of: ${ALLOWED_TRADES.join(", ")}` + }, + { status: 400 } + ); + } + + // Validate service type against admin-defined categories if it's a handyman service + if (body.trade === "handyman" && body.serviceType) { + try { + // Basic validation for handyman service types + const validHandymanServices = [ + 'tv-shelf-mounting', 'furniture-assembly', 'picture-hanging', 'minor-repairs', + 'outlet-installation', 'light-fixture', 'ceiling-fan', 'smart-device' + ]; + const serviceExists = validHandymanServices.includes(body.serviceType); + + if (!serviceExists) { + return NextResponse.json( + { + success: false, + error: `Invalid service type. Please select from the available services.` + }, + { status: 400 } + ); + } + } catch (error) { + console.log("Warning: Could not validate against admin services", error); + // Continue even if validation fails (admin services might not be set up yet) + } + } + + // Add the user's email to the service + body.userEmail = session.user.email; + if (phoneNumber) body.phoneNumber = phoneNumber; + if (contactEmail) body.contactEmail = contactEmail; + + // If no valid coordinates but we have an address, geocode server-side once + const hasValidCoords = body?.location?.type === 'Point' && Array.isArray(body?.location?.coordinates) && + typeof body.location.coordinates[0] === 'number' && typeof body.location.coordinates[1] === 'number' && + !(body.location.coordinates[0] === 0 && body.location.coordinates[1] === 0); + + if (!hasValidCoords && typeof body.mainLocation === 'string' && body.mainLocation.trim().length > 0) { + try { + const apiKey = process.env.GOOGLE_MAPS_API_KEY || process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY; + if (apiKey) { + const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(body.mainLocation)}&key=${apiKey}`; + const resp = await fetch(url); + const data = await resp.json(); + if (data.status === 'OK' && data.results && data.results[0]) { + const loc = data.results[0].geometry.location; + body.location = { type: 'Point', coordinates: [loc.lng, loc.lat] }; + } + } + } catch (e) { + console.warn('Server geocoding failed, continuing without coordinates:', e); + } + } + + // Create the new service + const newService = await Service.create(body); + + return NextResponse.json( + { success: true, data: newService }, + { status: 201 } + ); + } catch (error: any) { + console.error("Error creating service:", error); + + // Check for validation errors + if (error.name === 'ValidationError') { + const validationErrors = Object.values(error.errors).map((err: any) => err.message); + return NextResponse.json( + { success: false, error: "Validation error", details: validationErrors }, + { status: 400 } + ); + } + + return NextResponse.json( + { success: false, error: error.message }, + { status: 500 } + ); + } +} + +// Get available service categories for handymen +export async function OPTIONS(req: NextRequest) { + try { + // Return predefined service categories + const categories = [ + { id: "handyman", name: "Handyman Services", icon: "🔨" }, + { id: "plumbing", name: "Plumbing", icon: "🔧" }, + { id: "electrician", name: "Electrician Services", icon: "⚡" }, + { id: "painting", name: "Painting Services", icon: "🎨" } + ]; + + const services = [ + { id: "tv-shelf-mounting", name: "TV & Shelf Mounting", category: "handyman" }, + { id: "furniture-assembly", name: "Furniture Assembly", category: "handyman" }, + { id: "picture-hanging", name: "Picture Hanging", category: "handyman" }, + { id: "minor-repairs", name: "Minor Repairs", category: "handyman" } + ]; + + return NextResponse.json({ + success: true, + categories, + services + }); + } catch (error) { + console.error("Error fetching service categories:", error); + return NextResponse.json( + { success: false, error: "Failed to fetch service categories" }, + { status: 500 } + ); + } +} diff --git a/app/api/test/route.ts b/app/api/test/route.ts new file mode 100644 index 0000000..263342b --- /dev/null +++ b/app/api/test/route.ts @@ -0,0 +1,10 @@ +// app/api/test/route.ts +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + return NextResponse.json({ message: "Test route is working!" }); +} + +export async function POST(request: NextRequest) { + return NextResponse.json({ message: "Test POST route is working!" }); +} diff --git a/app/api/upLoad/route.ts b/app/api/upLoad/route.ts new file mode 100644 index 0000000..5d86a4e --- /dev/null +++ b/app/api/upLoad/route.ts @@ -0,0 +1,101 @@ +// app/api/upload/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../../../lib/auth"; +import { writeFile } from "fs/promises"; +import path from "path"; +// @ts-ignore - Add TypeScript ignore for uuid module +import { v4 as uuidv4 } from "uuid"; +import fs from "fs"; + +// Maximum file size (5MB) +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export async function POST(request: NextRequest) { + try { + // Check authentication + const session = await getServerSession(authOptions); + if (!session || !session.user?.email) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + // Get the form data from the request + const formData = await request.formData(); + const file = formData.get("file") as File; + + // Validate the file + if (!file) { + return NextResponse.json( + { success: false, error: "No file uploaded" }, + { status: 400 } + ); + } + + // Check file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { success: false, error: "File too large (max 5MB)" }, + { status: 400 } + ); + } + + // Check file type + const validTypes = ["image/jpeg", "image/png", "image/gif"]; + if (!validTypes.includes(file.type)) { + return NextResponse.json( + { success: false, error: "Invalid file type. Only JPEG, PNG and GIF are allowed" }, + { status: 400 } + ); + } + + // Convert the file to a buffer + const buffer = Buffer.from(await file.arrayBuffer()); + + // Create a unique filename + const uniqueFilename = `${uuidv4()}-${file.name.replace(/\s/g, '_')}`; + + // Define the upload directory and create it if it doesn't exist + const uploadDir = path.join(process.cwd(), "public/uploads"); + try { + // Make sure the directory exists + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + console.log(`Created directory: ${uploadDir}`); + } + + // Write the file + await writeFile(`${uploadDir}/${uniqueFilename}`, new Uint8Array(buffer)); + console.log(`File saved: ${uploadDir}/${uniqueFilename}`); + } catch (error) { + console.error("Error saving file:", error); + return NextResponse.json( + { success: false, error: "Failed to save file" }, + { status: 500 } + ); + } + + // Return the URL to the uploaded file + const fileUrl = `/uploads/${uniqueFilename}`; + + return NextResponse.json( + { + success: true, + url: fileUrl, + filename: uniqueFilename, + size: file.size, + type: file.type + }, + { status: 200 } + ); + + } catch (error) { + console.error("Error uploading file:", error); + return NextResponse.json( + { success: false, error: "An error occurred during upload" }, + { status: 500 } + ); + } +} diff --git a/app/api/upload-image/route.ts b/app/api/upload-image/route.ts new file mode 100644 index 0000000..699acbe --- /dev/null +++ b/app/api/upload-image/route.ts @@ -0,0 +1,87 @@ +// app/api/upload-image/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../../../lib/auth"; +import dbConnect from "../../../lib/dbConnect"; +import Image from "../../../models/Image"; + +// Maximum file size (5MB) +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export async function POST(request: NextRequest) { + try { + // Check authentication + const session = await getServerSession(authOptions); + if (!session || !session.user?.email) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + // Connect to MongoDB + await dbConnect(); + + // Get the form data from the request + const formData = await request.formData(); + const file = formData.get("file") as File; + + // Validate the file + if (!file) { + return NextResponse.json( + { success: false, error: "No file uploaded" }, + { status: 400 } + ); + } + + // Check file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { success: false, error: "File too large (max 5MB)" }, + { status: 400 } + ); + } + + // Check file type + const validTypes = ["image/jpeg", "image/png", "image/gif"]; + if (!validTypes.includes(file.type)) { + return NextResponse.json( + { success: false, error: "Invalid file type. Only JPEG, PNG and GIF are allowed" }, + { status: 400 } + ); + } + + // Convert the file to a buffer + const buffer = Buffer.from(await file.arrayBuffer()); + + // Create a new image document in MongoDB + const image = await Image.create({ + filename: file.name, + contentType: file.type, + data: buffer, + size: file.size, + uploadedBy: session.user.email + }); + + // Return the image ID and URL + const imageUrl = `/api/images/${image._id}`; + + return NextResponse.json( + { + success: true, + url: imageUrl, + imageId: image._id, + size: file.size, + type: file.type + }, + { status: 200 } + ); + + } catch (error) { + console.error("Error uploading file:", error); + return NextResponse.json( + { success: false, error: "An error occurred during upload" }, + { status: 500 } + ); + } +} diff --git a/app/api/user/profile/route.ts b/app/api/user/profile/route.ts new file mode 100644 index 0000000..7fc97d2 --- /dev/null +++ b/app/api/user/profile/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import dbConnect from '@/lib/dbConnect'; +import User from '@/models/User'; + +// POST handler for updating user profile +export async function POST(req: NextRequest) { + try { + await dbConnect(); + + const body = await req.json(); + const { email, profileType } = body; + + if (!email) { + return NextResponse.json({ success: false, error: 'Email is required' }, { status: 400 }); + } + + // Update the user document with profile type + const updateData: any = { updatedAt: new Date() }; + + // Only add profileType if it's provided + if (profileType) { + updateData.profileType = profileType; + } + + // Update the user document + const user = await User.findOneAndUpdate( + { email }, + { + updatedAt: new Date() + }, + { new: true } + ); + + if (!user) { + return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true, profile: user }); + } catch (error: any) { + console.error('Error updating profile:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +// GET handler for retrieving user profile +export async function GET(req: NextRequest) { + try { + await dbConnect(); + + const email = req.nextUrl.searchParams.get('email'); + if (!email) { + return NextResponse.json({ success: false, error: 'Email is required' }, { status: 400 }); + } + + const user = await User.findOne({ email }); + return NextResponse.json({ success: true, user }); + } catch (error: any) { + console.error('Error fetching profile:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/app/api/user/services/[id]/route.ts b/app/api/user/services/[id]/route.ts new file mode 100644 index 0000000..47d554e --- /dev/null +++ b/app/api/user/services/[id]/route.ts @@ -0,0 +1,50 @@ +// app/api/user/services/[id]/route.ts +import { NextRequest, NextResponse } from "next/server"; +import dbConnect from "../../../../../lib/dbConnect"; +import Service from "../../../../../models/Service"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/lib/auth"; + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } } +) { + await dbConnect(); + + const id = params.id; + + // Get the user session to verify authentication + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + try { + // Find the service and verify ownership + const service = await Service.findById(id); + + if (!service) { + return NextResponse.json( + { success: false, error: "Service not found" }, + { status: 404 } + ); + } + + // Verify the service belongs to the current user + if (service.userEmail !== session.user.email) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 403 } + ); + } + + return NextResponse.json({ success: true, data: service }, { status: 200 }); + } catch (error: any) { + console.error("Error fetching user service:", error); + return NextResponse.json({ success: false, error: error.message }, { status: 400 }); + } +} \ No newline at end of file diff --git a/app/api/users/sync/route.ts b/app/api/users/sync/route.ts new file mode 100644 index 0000000..43267e4 --- /dev/null +++ b/app/api/users/sync/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import dbConnect from '@/lib/dbConnect'; +import User from '@/models/User'; + +// POST /api/users/sync - Save/update user in MongoDB when they log in +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 }); + } + + await dbConnect(); + + const { email, name } = session.user; + + // Generate a unique referral code if user doesn't have one + const generateReferralCode = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = 'TM-'; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; + }; + + // Check if user already exists + let user = await User.findOne({ email: email.toLowerCase() }); + + if (!user) { + // Create new user with referral code + let referralCode = generateReferralCode(); + + // Ensure referral code is unique + while (await User.findOne({ referralCode })) { + referralCode = generateReferralCode(); + } + + user = new User({ + email: email.toLowerCase(), + name: name || email.split('@')[0], + referralCode, + role: 'user', + type: 'client' + }); + + await user.save(); + console.log('Created new user:', email); + } else { + // Update existing user's name if changed and ensure they have a referral code + let needsUpdate = false; + + if (user.name !== name && name) { + user.name = name; + needsUpdate = true; + } + + if (!user.referralCode) { + let referralCode = generateReferralCode(); + while (await User.findOne({ referralCode })) { + referralCode = generateReferralCode(); + } + user.referralCode = referralCode; + needsUpdate = true; + } + + if (needsUpdate) { + await user.save(); + console.log('Updated existing user:', email); + } + } + + return NextResponse.json({ + success: true, + user: { + email: user.email, + name: user.name, + referralCode: user.referralCode, + role: user.role, + type: user.type + } + }); + } catch (error) { + console.error('Error syncing user:', error); + return NextResponse.json({ success: false, error: 'Failed to sync user' }, { status: 500 }); + } +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx deleted file mode 100644 index d449568..0000000 --- a/app/contact/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -"use client"; // This marks the file as a Client Component - -import { useEffect } from "react"; \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..170e052 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { SessionProvider, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import PayNowButton from "@/components/PayNowButton"; + +interface Service { + _id: string; + name: string; + description: string; + image: string; + hours: string; + mainLocation: string; + trade: "food_truck" | "plumber" | "electrician" | "handyman" | "painter"; + userEmail: string; +} + +function DashboardContent() { + const { data: session, status } = useSession(); + const router = useRouter(); + const [services, setServices] = useState([]); + + // Redirect to login if unauthenticated + useEffect(() => { + if (status === "unauthenticated") { + router.push("/login"); + } + }, [status, router]); + + // Fetch services data once authenticated + useEffect(() => { + async function fetchServices() { + try { + const res = await fetch("/api/services"); + const json = await res.json(); + if (json.success) { + setServices(json.data); + } + } catch (error) { + console.error("Error fetching services:", error); + } + } + if (status === "authenticated") { + fetchServices(); + } + }, [status]); + + if (status === "loading") { + return

    Loading dashboard...

    ; + } + + return ( +
    +
    +

    Dashboard

    + {session && ( +

    + Welcome, {session.user?.name}! +

    + )} +
    + +
    +
    +

    Available Services

    + + List Your Service + +
    + + {services.length === 0 ? ( +
    +

    No services found.

    +
    + ) : ( +
    + {services.map((service) => ( +
    + {service.image && ( +
    + {service.name} +
    + )} + +
    +
    +

    {service.name}

    + + {service.trade.replace('_', ' ')} + +
    + +

    {service.description}

    + +
    +

    Location: {service.mainLocation}

    +

    Hours: {service.hours}

    +
    +
    + +
    + + View Details + + + {/* Add payment button for handyman services */} + {service.trade === 'handyman' && session?.user?.email !== service.userEmail && ( + + )} +
    +
    + ))} +
    + )} +
    +
    + ); +} + +export default function DashboardPage() { + return ( + + + + ); +} diff --git a/app/electrician_input/page.tsx b/app/electrician_input/page.tsx new file mode 100644 index 0000000..a39a8d9 --- /dev/null +++ b/app/electrician_input/page.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useSession, SessionProvider } from "next-auth/react"; +import Link from "next/link"; +import { FileUploader } from "react-drag-drop-files"; +import Image from "next/image"; + +function ElectricianInput() { + const { data: session, status } = useSession(); + const router = useRouter(); + + // Redirect to login if unauthenticated + useEffect(() => { + if (status === "unauthenticated") { + router.push("/login"); + } + }, [status, router]); + + const [serviceArea, setServiceArea] = useState(""); + const [electricianName, setElectricianName] = useState(""); + const [image, setImage] = useState(null); + const [imagePreview, setImagePreview] = useState(""); + const [description, setDescription] = useState(""); + const [hours, setHours] = useState(""); + const [license, setLicense] = useState(""); + const [schedule, setSchedule] = useState([{ day: "", time: "", address: "" }]); + const [confirmationMessage, setConfirmationMessage] = useState(""); + const [isUploading, setIsUploading] = useState(false); + + const fileTypes = ["JPG", "PNG", "GIF", "JPEG"]; + + const handleImageChange = (file: File) => { + setImage(file); + // Create a preview URL + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + }; + + // Geocode an address to get its lat/lng using Google Geocoding API + const geocodeAddress = async (addr: string) => { + const response = await fetch( + `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent( + addr + )}&key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}` + ); + const data = await response.json(); + if (data.status === "OK") { + return data.results[0].geometry.location; + } else { + console.error("Geocoding Error:", data.status, data.error_message); + return { lat: null, lng: null }; + } + }; + + const uploadImage = async () => { + if (!image) return null; + + setIsUploading(true); + + // In a real implementation, you would: + // 1. Create a FormData object + // 2. Send it to your backend API + // 3. Upload to a service like AWS S3, Cloudinary, etc. + // 4. Return the URL of the uploaded image + + // For the prototype, we'll simulate this with a delay and return a placeholder URL + await new Promise(resolve => setTimeout(resolve, 1000)); + + setIsUploading(false); + + // This is just a placeholder URL. In production, you'd return the actual URL from your image hosting service + return `https://example.com/uploaded-images/${image.name}`; + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + // Geocode the main service area address + const mainCoords = await geocodeAddress(serviceArea); + if ( + mainCoords.lat === null || + mainCoords.lng === null || + (mainCoords.lat === 0 && mainCoords.lng === 0) + ) { + setConfirmationMessage( + "Error: The service area address is invalid. Please check and try again." + ); + return; + } + + // Use the service area as the main location for the schedule + const mainScheduleSlot = { + day: "Main Location", + time: "N/A", // Default value, change if needed + address: serviceArea, + lat: mainCoords.lat, + lng: mainCoords.lng, + }; + + // Process additional schedule entries (filter out empty addresses) + const filteredSchedule = schedule.filter( + (slot) => slot.address && slot.address.trim() !== "" + ); + const scheduleWithCoords = await Promise.all( + filteredSchedule.map(async (slot) => { + const coords = await geocodeAddress(slot.address); + return { ...slot, lat: coords.lat, lng: coords.lng }; + }) + ); + const finalSchedule = [mainScheduleSlot, ...scheduleWithCoords]; + console.log("Final Schedule Data with Lat/Lng:", finalSchedule); + + const hasInvalid = finalSchedule.some( + (slot) => + slot.lat === null || + slot.lng === null || + (slot.lat === 0 && slot.lng === 0) + ); + if (hasInvalid) { + setConfirmationMessage( + "Error: One or more addresses are invalid. Please check and try again." + ); + return; + } + + // Ensure the user is logged in and has an email + if (!session?.user?.email) { + setConfirmationMessage("Error: You must be logged in to add a service."); + return; + } + + // Upload the image and get the URL + let imageUrl = null; + if (image) { + imageUrl = await uploadImage(); + if (!imageUrl) { + setConfirmationMessage("Error uploading image. Please try again."); + return; + } + } + + // Build the new electrician object; trade is set to "electrician" + const newElectrician = { + name: electricianName, + image: imageUrl || imagePreview || "", // Use the uploaded URL or the preview for testing + description, + hours, + license, + mainLocation: serviceArea, + schedule: finalSchedule, + userEmail: session.user.email, // Associate with logged-in user + trade: "electrician", + }; + + try { + const res = await fetch("/api/services", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newElectrician), + }); + const json = await res.json(); + if (json.success) { + setConfirmationMessage("Electrician service listed successfully!"); + console.log("Electrician service successfully added!", newElectrician); + router.push("/profile"); // Redirect to profile after submission + } else { + setConfirmationMessage("Error creating electrician service: " + json.error); + } + } catch (error) { + console.error("Error creating electrician service:", error); + setConfirmationMessage("Error creating electrician service, please try again later."); + } + }; + + const addScheduleSlot = () => { + setSchedule([...schedule, { day: "", time: "", address: "" }]); + }; + + const updateSchedule = (index: number, field: string, value: string) => { + const updated = schedule.map((slot, i) => + i === index ? { ...slot, [field]: value } : slot + ); + setSchedule(updated); + }; + + return ( +
    + {/* Fixed Nav Bar */} + + +

    List Your Electrician Service

    +
    + {/* Electrician Name */} +
    + + setElectricianName(e.target.value)} + className="w-full p-2 border border-gray-300 rounded text-black" + required + /> +
    + + {/* Service Area (Address) */} +
    + + setServiceArea(e.target.value)} + className="w-full p-2 border border-gray-300 rounded text-black" + required + /> +
    + + {/* Electrician Image Upload */} +
    + +
    + + {imagePreview && ( +
    +

    Preview:

    + Preview +
    + )} +
    +
    + + {/* Description */} +
    + +