diff --git a/frontend/app/bookings/[id]/page.tsx b/frontend/app/bookings/[id]/page.tsx index 6455319..7c31e02 100644 --- a/frontend/app/bookings/[id]/page.tsx +++ b/frontend/app/bookings/[id]/page.tsx @@ -18,8 +18,11 @@ import { Loader2, XCircle, Download, - AlertCircle + AlertCircle, + CalendarPlus } from "lucide-react"; +import * as ics from "ics"; +import { apiClient } from "@/lib/apiClient"; const STATUS_STYLES: Record = { PENDING: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400", @@ -80,6 +83,76 @@ export default function BookingDetailPage({ params }: { params: Promise<{ id: st } } + const handleDownloadICS = () => { + if (!booking) return; + + const startDate = new Date(booking.startDate); + const endDate = new Date(booking.endDate); + + const end = [ + endDate.getFullYear(), + endDate.getMonth() + 1, + endDate.getDate(), + endDate.getHours() || 18, + endDate.getMinutes(), + ]; + + if (isNaN(endDate.getTime())) { + end[0] = startDate.getFullYear(); + end[1] = startDate.getMonth() + 1; + end[2] = startDate.getDate(); + end[3] = startDate.getHours() + 1 || 18; + end[4] = startDate.getMinutes(); + } + + const event: any = { + title: `ManageHub Booking: ${booking.workspace?.name || booking.workspaceId}`, + description: `Plan: ${booking.planType} - ${booking.seatCount} seats`, + start: [ + startDate.getFullYear(), + startDate.getMonth() + 1, + startDate.getDate(), + startDate.getHours() || 9, + startDate.getMinutes(), + ], + end: end, + }; + + ics.createEvent(event, (error, value) => { + if (error) { + toast.error("Failed to generate calendar file"); + return; + } + + const blob = new Blob([value], { type: "text/calendar" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `booking-${booking.id}.ics`; + a.click(); + URL.revokeObjectURL(url); + }); + }; + + const handleGoogleCalendar = async () => { + if (!booking) return; + try { + const statusRes = await apiClient.get<{ data: { connected: boolean } }>("/calendar-sync/status"); + if (statusRes.data?.connected) { + // Assuming backend has an endpoint to create event + await apiClient.post("/calendar-sync/events", { bookingId: booking.id }).catch(() => null); + toast.success("Event added to Google Calendar"); + } else { + const authRes = await apiClient.get<{ data: { url: string } }>("/calendar-sync/auth/google"); + if (authRes.data?.url) { + window.location.href = authRes.data.url; + } + } + } catch (err) { + toast.error("Failed to add to Google Calendar"); + } + }; + if (loadingBooking || loadingInvoices) { return ( @@ -260,6 +333,25 @@ export default function BookingDetailPage({ params }: { params: Promise<{ id: st Download Invoice )} + + {(booking.status === "CONFIRMED") && ( +
+ + +
+ )}
diff --git a/frontend/app/members/page.tsx b/frontend/app/members/page.tsx new file mode 100644 index 0000000..6117575 --- /dev/null +++ b/frontend/app/members/page.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState, useEffect } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { apiClient } from "@/lib/apiClient"; +import { Search, User, X, Linkedin, Twitter } from "lucide-react"; + +type Member = { + id: string; + firstName: string; + lastName: string; + bio: string; + skills: string[]; + avatarUrl?: string; + isPublicProfile: boolean; + socialLinks?: { + linkedin?: string; + twitter?: string; + }; +}; + +export default function MembersDirectoryPage() { + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [skillsFilter, setSkillsFilter] = useState([]); + const [allSkills, setAllSkills] = useState([]); + const [selectedMember, setSelectedMember] = useState(null); + + useEffect(() => { + fetchMembers(); + }, [search, skillsFilter]); + + const fetchMembers = async () => { + try { + setLoading(true); + const res = await apiClient.get<{ data: Member[] }>(`/users/directory?search=${search}`); + let data = res.data || []; + if (skillsFilter.length > 0) { + data = data.filter(member => member.skills?.some(skill => skillsFilter.includes(skill))); + } + setMembers(data); + + if (allSkills.length === 0) { + const skillsSet = new Set(); + data.forEach(m => m.skills?.forEach(s => skillsSet.add(s))); + setAllSkills(Array.from(skillsSet)); + } + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + const toggleSkill = (skill: string) => { + setSkillsFilter(prev => prev.includes(skill) ? prev.filter(s => s !== skill) : [...prev, skill]); + }; + + return ( + +
+

Member Directory

+

+ Discover and connect with other members in the hub. +

+
+ Opt-in notice: Only members who have set their profile to public appear here. You can change this in your profile settings. +
+
+ +
+
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 text-gray-900 dark:text-gray-100" + /> +
+
+ + {allSkills.length > 0 && ( +
+ {allSkills.map(skill => ( + + ))} +
+ )} + + {loading ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : members.length === 0 ? ( +
+

No members found.

+
+ ) : ( +
+ {members.map(member => ( +
setSelectedMember(member)} + className="bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-xl p-6 flex flex-col cursor-pointer hover:shadow-md transition-shadow" + > +
+ {member.avatarUrl ? ( + {`${member.firstName} + ) : ( +
+ +
+ )} +
+

+ {member.firstName} {member.lastName} +

+
+
+

+ {member.bio ? member.bio.slice(0, 100) + (member.bio.length > 100 ? "..." : "") : "No bio provided."} +

+
+ {member.skills?.slice(0, 3).map(skill => ( + + {skill} + + ))} + {member.skills && member.skills.length > 3 && ( + + +{member.skills.length - 3} + + )} +
+
+ ))} +
+ )} + + {selectedMember && ( +
+
+ +
+ {selectedMember.avatarUrl ? ( + {`${selectedMember.firstName} + ) : ( +
+ +
+ )} +

+ {selectedMember.firstName} {selectedMember.lastName} +

+
+ {selectedMember.socialLinks?.linkedin && ( + + + + )} + {selectedMember.socialLinks?.twitter && ( + + + + )} +
+
+ +
+
+

About

+

+ {selectedMember.bio || "No bio provided."} +

+
+ + {selectedMember.skills && selectedMember.skills.length > 0 && ( +
+

Skills

+
+ {selectedMember.skills.map(skill => ( + + {skill} + + ))} +
+
+ )} +
+
+
+ )} + + ); +} diff --git a/frontend/app/referrals/page.tsx b/frontend/app/referrals/page.tsx new file mode 100644 index 0000000..1eefc81 --- /dev/null +++ b/frontend/app/referrals/page.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState, useEffect } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { apiClient } from "@/lib/apiClient"; +import { Copy, Share2, Users, CheckCircle, Wallet, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; + +type ReferralStats = { + totalReferrals: number; + successfulConversions: number; + totalRewards: number; + history: Array<{ + id: string; + referredName: string | null; + status: "PENDING" | "COMPLETED"; + rewardEarned: number; + createdAt: string; + }>; +}; + +export default function ReferralsPage() { + const [referralCode, setReferralCode] = useState(""); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchReferralData(); + }, []); + + const fetchReferralData = async () => { + try { + setLoading(true); + const [codeRes, statsRes] = await Promise.all([ + apiClient.get<{ data: { code: string } }>("/referrals/my-code").catch(() => null), + apiClient.get<{ data: ReferralStats }>("/referrals/stats").catch(() => null) + ]); + + if (codeRes?.data) setReferralCode(codeRes.data.code); + if (statsRes?.data) setStats(statsRes.data); + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + const referralLink = typeof window !== 'undefined' ? `${window.location.origin}/register?ref=${referralCode}` : `https://managehub.vercel.app/register?ref=${referralCode}`; + + const copyToClipboard = (text: string, message: string) => { + navigator.clipboard.writeText(text); + toast.success(message); + }; + + const shareWhatsApp = () => { + const text = `Join me on ManageHub using my referral link: ${referralLink}`; + window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, '_blank'); + }; + + const shareTwitter = () => { + const text = `Join me on ManageHub using my referral link:`; + window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(referralLink)}`, '_blank'); + }; + + if (loading) { + return ( + +
+
+
+
+
+ {[...Array(3)].map((_, i) =>
)} +
+ + ); + } + + return ( + +
+

Referral Program

+

+ Invite friends to ManageHub and earn rewards. +

+
+ +
+
+
+ +

Total Referrals

+
+

{stats?.totalReferrals || 0}

+
+ +
+
+ +

Successful Conversions

+
+

{stats?.successfulConversions || 0}

+
+ +
+
+ +

Total Rewards

+
+

+ ₦{((stats?.totalRewards || 0) / 100).toLocaleString('en-NG')} +

+
+
+ +
+

Your Referral Code

+
+
+ {referralCode || "Loading..."} + +
+ +
+ + + +
+
+
+ +
+
+

Referral History

+
+ + {(!stats?.history || stats.history.length === 0) ? ( +
+ +

You haven't referred anyone yet.

+
+ ) : ( +
+ + + + + + + + + + + {stats.history.map(item => ( + + + + + + + ))} + +
Referred PersonStatusReward EarnedDate
+ {item.referredName || "Pending User"} + + + {item.status} + + + ₦{(item.rewardEarned / 100).toLocaleString('en-NG')} + + {new Date(item.createdAt).toLocaleDateString()} +
+
+ )} +
+
+ ); +} diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 129ab93..7eb2075 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -9,8 +9,9 @@ import { apiClient } from "@/lib/apiClient"; import DashboardLayout from "@/components/dashboard/DashboardLayout"; import TwoFactorModal from "@/components/settings/TwoFactorModal"; import { use2faStatus } from "@/lib/react-query/hooks/two-factor/use2faStatus"; -import { Eye, EyeOff, Shield, Bell, Palette } from "lucide-react"; +import { Eye, EyeOff, Shield, Bell, Palette, Calendar } from "lucide-react"; import { useTheme } from "next-themes"; +import { toast } from "sonner"; /* ── Password change schema ── */ const passwordSchema = z @@ -124,6 +125,38 @@ export default function SettingsPage() { null ); + /* ── Calendar Sync ── */ + const [calendarStatus, setCalendarStatus] = useState<"connected" | "not_connected" | "loading">("loading"); + + useEffect(() => { + if (user) { + apiClient.get<{ data: { connected: boolean } }>("/calendar-sync/status") + .then(res => setCalendarStatus(res.data?.connected ? "connected" : "not_connected")) + .catch(() => setCalendarStatus("not_connected")); + } + }, [user]); + + const handleConnectCalendar = async () => { + try { + const res = await apiClient.get<{ data: { url: string } }>("/calendar-sync/auth/google"); + if (res.data?.url) { + window.location.href = res.data.url; + } + } catch (error) { + toast.error("Failed to initiate calendar sync"); + } + }; + + const handleDisconnectCalendar = async () => { + try { + await apiClient.delete("/calendar-sync/disconnect"); + setCalendarStatus("not_connected"); + toast.success("Calendar disconnected"); + } catch (error) { + toast.error("Failed to disconnect calendar"); + } + }; + /* ── Danger zone ── */ const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); @@ -343,6 +376,44 @@ export default function SettingsPage() {
+ {/* ── Calendar Sync ── */} +
+
+ +

Calendar Sync

+
+

+ Connect your Google Calendar to automatically sync your bookings. +

+
+
+
+ + {calendarStatus === "loading" ? "Checking status..." : calendarStatus === "connected" ? "Connected ✓" : "Not connected"} + +
+ {calendarStatus !== "loading" && ( + calendarStatus === "connected" ? ( + + ) : ( + + ) + )} +
+
+ {/* ── Danger zone ── */}

diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a33203c..a0a4e66 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-query-devtools": "^5.91.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "ics": "^3.12.0", "js-cookie": "^3.0.5", "lucide-react": "^0.544.0", "next": "^15.5.9", @@ -4067,6 +4068,17 @@ "node": ">= 0.4" } }, + "node_modules/ics": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/ics/-/ics-3.12.0.tgz", + "integrity": "sha512-BBPiZ/TbUyZrxui7SHKGOa/n2HUxaW9ucn7cw47NW9OSKe5tItYxIdN7Uczti5nKURmfwVvYi2aLigCJGH1+cA==", + "license": "ISC", + "dependencies": { + "nanoid": "^3.1.23", + "runes2": "^1.1.2", + "yup": "^1.2.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5563,6 +5575,12 @@ "dev": true, "license": "MIT" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5849,6 +5867,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/runes2": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", + "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", + "license": "MIT" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -6372,6 +6396,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -6439,6 +6469,12 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -6494,6 +6530,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6816,6 +6864,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 83ac60c..886a4da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query-devtools": "^5.91.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "ics": "^3.12.0", "js-cookie": "^3.0.5", "lucide-react": "^0.544.0", "next": "^15.5.9",