diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index b0c7227..ae3efb5 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -300,7 +300,7 @@ export default function EventsPage() {
Page {page} of {totalPages}
-
+
+ {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + const start = Math.max(1, Math.min(page - 2, totalPages - 4)); + const p = start + i; + if (p > totalPages) return null; + return ( + + ); + })} @@ -125,6 +127,20 @@ export default function AdminLayout({ (item.href !== "/admin" && pathname.startsWith(item.href)); const Icon = item.icon; + if (!item.accessible) { + return ( +
+ + {item.label} + +
+ ); + } + return ( {/* Main content */} -
+
{/* Top bar */}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ec9e2d3..0b3c9c6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -128,8 +128,59 @@ export default function AdminDashboard() { if (loading) { return ( -
- +
+ {/* Header skeleton */} +
+
+
+
+ {/* Stat cards skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+
+
+
+
+
+ + + ))} +
+ {/* Content grid skeleton */} +
+ {Array.from({ length: 2 }).map((_, i) => ( + + +
+
+ + + {Array.from({ length: 3 }).map((_, j) => ( +
+
+
+
+
+
+
+ ))} + + + ))} +
+ {/* Support tickets skeleton */} + + +
+
+ + +
+ +
); } diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 2bff119..4e11b1c 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -31,6 +31,8 @@ interface Settings { export default function SettingsPage() { const [saving, setSaving] = useState(false); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [clearConfirmText, setClearConfirmText] = useState(""); const [settings, setSettings] = useState({ siteName: "nhimbe", supportEmail: "support@nhimbe.com", @@ -194,6 +196,9 @@ export default function SettingsPage() {
Allow users to RSVP for events
+
+ When disabled, new registrations will be paused across all events +
Allow users to leave reviews on past events
+
+ When disabled, users will not be able to submit or view reviews +
Enable referral tracking and leaderboards
+
+ When disabled, referral codes and leaderboards will be hidden +
{ setShowClearConfirm(true); setClearConfirmText(""); }} > Clear All Data
+ + {/* Clear All Data confirmation modal */} + {showClearConfirm && ( +
+
+

Confirm Data Deletion

+

+ This will permanently delete all events, users, and data. This action cannot be undone. +

+

+ Type DELETE to confirm: +

+ setClearConfirmText(e.target.value)} + placeholder="Type DELETE" + className="w-full px-4 py-3 bg-surface rounded-xl border-none outline-none mb-4 text-base" + autoFocus + /> +
+ + +
+
+
+ )}
diff --git a/src/app/admin/signage/page.tsx b/src/app/admin/signage/page.tsx index d136494..13f9e71 100644 --- a/src/app/admin/signage/page.tsx +++ b/src/app/admin/signage/page.tsx @@ -127,9 +127,9 @@ function PairingScreen({ onPaired }: { onPaired: (session: KioskSession) => void

{error}

) : code ? ( <> -
+
{code.split("").map((char, i) => ( -
+
{char}
))} diff --git a/src/app/admin/support/page.tsx b/src/app/admin/support/page.tsx index 84be1e5..6152ea8 100644 --- a/src/app/admin/support/page.tsx +++ b/src/app/admin/support/page.tsx @@ -448,8 +448,8 @@ export default function SupportPage() { key={message.id} className={`p-4 rounded-xl ${ message.sender === "admin" - ? "bg-primary/10 ml-8" - : "bg-elevated mr-8" + ? "bg-primary/10 ml-3 sm:ml-8" + : "bg-elevated mr-3 sm:mr-8" }`} >
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index cdcad77..e2b36ac 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -44,6 +44,7 @@ export default function UsersPage() { const [totalPages, setTotalPages] = useState(1); const [selectedUser, setSelectedUser] = useState(null); const [actionMenuOpen, setActionMenuOpen] = useState(null); + const [pendingAction, setPendingAction] = useState<{ userId: string; action: "suspend" | "activate"; userName: string } | null>(null); const limit = 20; @@ -93,7 +94,7 @@ export default function UsersPage() { function formatDate(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleDateString("en-US", { + return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", @@ -259,9 +260,10 @@ export default function UsersPage() { {user.status === "active" ? ( + {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + const start = Math.max(1, Math.min(page - 2, totalPages - 4)); + const p = start + i; + if (p > totalPages) return null; + return ( + + ); + })} + +
+
+
+ )} + {/* User Detail Modal */} {selectedUser && (
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index e942ec7..724265d 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { ArrowLeft, Loader2 } from "lucide-react"; import Link from "next/link"; import { useStytchUser, useStytchSession, StytchLogin } from "@stytch/nextjs"; +import { Skeleton } from "@/components/ui/skeleton"; import { Products, OTPMethods } from "@stytch/vanilla-js"; import type { StyleConfig } from "@stytch/vanilla-js"; import Image from "next/image"; @@ -121,16 +122,26 @@ function SignInContent() {

- + + + + +
+ } + > + +

By continuing, you agree to our{" "} diff --git a/src/app/authenticate/page.tsx b/src/app/authenticate/page.tsx index a12e784..962dc81 100644 --- a/src/app/authenticate/page.tsx +++ b/src/app/authenticate/page.tsx @@ -9,6 +9,29 @@ import Link from "next/link"; const SESSION_DURATION_MINUTES = 10080; // 7 days +function getFriendlyErrorMessage(error: string): string { + const lower = error.toLowerCase(); + if (lower.includes("expired") || lower.includes("token_expired")) { + return "Your sign-in link has expired. Please request a new one."; + } + if (lower.includes("already been used") || lower.includes("already_used") || lower.includes("consumed")) { + return "This sign-in link has already been used. Please request a new one."; + } + if (lower.includes("invalid") || lower.includes("malformed")) { + return "This sign-in link is not valid. Please request a new one."; + } + if (lower.includes("network") || lower.includes("fetch") || lower.includes("failed to fetch")) { + return "We could not reach the authentication server. Please check your internet connection and try again."; + } + if (lower.includes("unsupported authentication method")) { + return "This sign-in method is not supported. Please try signing in with email."; + } + if (lower.includes("no authentication token")) { + return error; // Already friendly + } + return "Something went wrong during sign-in. Please try again."; +} + function AuthenticateContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -114,7 +137,7 @@ function AuthenticateContent() {

Authentication Failed

-

{error}

+

{getFriendlyErrorMessage(error)}

diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 951eed8..422e4da 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -111,20 +111,39 @@ export default function CalendarPage() {
-

- {MONTHS[month]} {year} -

+
+ + +
diff --git a/src/app/e/[shortCode]/loading.tsx b/src/app/e/[shortCode]/loading.tsx new file mode 100644 index 0000000..4876fa4 --- /dev/null +++ b/src/app/e/[shortCode]/loading.tsx @@ -0,0 +1,10 @@ +import { Spinner } from "@/components/ui/spinner"; + +export default function ShortCodeLoading() { + return ( +
+ +

Opening event...

+
+ ); +} diff --git a/src/app/events/[id]/event-actions.tsx b/src/app/events/[id]/event-actions.tsx index 7a2141b..eb556b8 100644 --- a/src/app/events/[id]/event-actions.tsx +++ b/src/app/events/[id]/event-actions.tsx @@ -33,17 +33,25 @@ interface EventActionsProps { } // Helper to create CalendarEvent from event data +// The nhimbe event page is always the primary URL so people come back to the platform. function createCalendarEvent(event: EventActionsProps["event"]): CalendarEvent { const startDate = new Date(event.startDate); const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours default + const eventPageUrl = typeof window !== "undefined" ? `${window.location.origin}/e/${event.shortCode}` : undefined; + + // Build a rich description that links back to nhimbe + const descLines = [event.description.slice(0, 500)]; + descLines.push(""); + descLines.push(`View event details, RSVP, and explore more: ${eventPageUrl || "https://nhimbe.com"}`); + descLines.push("Powered by nhimbe — Together we gather, together we grow"); return { title: event.name, - description: event.description, + description: descLines.join("\n"), location: `${event.location.name}, ${event.location.streetAddress}, ${event.location.addressLocality}, ${event.location.addressCountry}`, startDate, endDate, - url: typeof window !== "undefined" ? `${window.location.origin}/e/${event.shortCode}` : undefined, + url: eventPageUrl, }; } @@ -51,36 +59,7 @@ export function EventActions({ event }: EventActionsProps) { const [copySuccess, setCopySuccess] = useState(false); const handleAddToCalendar = () => { - // Generate ICS file content - const startDate = new Date(event.startDate); - const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // Default 2 hours - - const formatDateForICS = (date: Date) => { - return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; - }; - - const icsContent = `BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//nhimbe//Event//EN -BEGIN:VEVENT -DTSTART:${formatDateForICS(startDate)} -DTEND:${formatDateForICS(endDate)} -SUMMARY:${event.name} -DESCRIPTION:${event.description.slice(0, 200).replace(/\n/g, "\\n")} -LOCATION:${event.location.name}, ${event.location.streetAddress}, ${event.location.addressLocality}, ${event.location.addressCountry} -END:VEVENT -END:VCALENDAR`; - - // Create and download ICS file - const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `${event.name.toLowerCase().replace(/\s+/g, "-")}.ics`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); + downloadICS(createCalendarEvent(event)); }; const handleGetDirections = () => { diff --git a/src/app/events/[id]/event-cover.tsx b/src/app/events/[id]/event-cover.tsx index 968b9ef..f0beefc 100644 --- a/src/app/events/[id]/event-cover.tsx +++ b/src/app/events/[id]/event-cover.tsx @@ -29,7 +29,7 @@ export function EventCover({ event, stats, reviewStats }: EventCoverProps) { return (
{event.image && ( @@ -38,57 +38,57 @@ export function EventCover({ event, stats, reviewStats }: EventCoverProps) {
{/* Top Left Badges */} -
-
-
+
+
+
{event.date.day}
-
+
{event.date.month}
{event.category} {stats?.isHot && ( - + HOT )} {!stats?.isHot && stats?.trend && stats.trend > 20 && ( - + +{stats.trend}% )}
{/* Top Right Stats */} -
+
{stats?.views !== undefined && stats.views > 0 && ( -
- - {formatViews(stats.views)} views +
+ + {formatViews(stats.views)} views
)} {reviewStats && reviewStats.averageRating > 0 && reviewStats.totalReviews > 0 && ( -
- - {reviewStats.averageRating.toFixed(1)} - ({reviewStats.totalReviews}) +
+ + {reviewStats.averageRating.toFixed(1)} + ({reviewStats.totalReviews})
)}
{/* Bottom Tags */} {event.keywords && event.keywords.length > 0 && ( -
+
{event.keywords.slice(0, 5).map((tag) => ( - + #{tag} ))} diff --git a/src/app/events/[id]/event-detail-content.tsx b/src/app/events/[id]/event-detail-content.tsx index 2909df9..56785c1 100644 --- a/src/app/events/[id]/event-detail-content.tsx +++ b/src/app/events/[id]/event-detail-content.tsx @@ -3,7 +3,8 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import dynamic from "next/dynamic"; -import { ArrowLeft, CalendarDays, MapPin, Video } from "lucide-react"; +import { ArrowLeft, MapPin, Video, Bookmark, Globe, ChevronRight } from "lucide-react"; +import { useTrackedLink } from "@/lib/use-tracked-link"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -15,6 +16,7 @@ import { EventMap } from "./event-map"; import { EventWeather } from "./event-weather"; import { EventCover } from "./event-cover"; import { EventSidebar } from "./event-sidebar"; +import { RSVPButton } from "./rsvp-button"; import { useAuth } from "@/components/auth/auth-context"; import { getUserReferralCode, generateUserReferralCode, getEventStats, getEventReviews, type UserReferralCode, type EventStats, type ReviewStats } from "@/lib/api"; import type { Event } from "@/lib/api"; @@ -38,10 +40,23 @@ export function EventDetailContent({ event }: EventDetailContentProps) { const [userReferral, setUserReferral] = useState(null); const [stats, setStats] = useState(null); const [reviewStats, setReviewStats] = useState(null); + const [bookmarked, setBookmarked] = useState(false); const isPastEvent = new Date(event.startDate) < new Date(); const isOnline = event.eventAttendanceMode === "OnlineEventAttendanceMode"; const isInPerson = !isOnline && event.location.addressLocality !== "Online"; + // Tracked links for click analytics — all external links route through /r/[code] + const trackedMeetingUrl = useTrackedLink( + isOnline ? event.meetingUrl : undefined, + event.id, + "meeting_url" + ); + const trackedTicketUrl = useTrackedLink( + event.offers?.url, + event.id, + "ticket" + ); + useEffect(() => { const controller = new AbortController(); getEventStats(event.id).then(data => { @@ -75,74 +90,70 @@ export function EventDetailContent({ event }: EventDetailContentProps) { return ( -
- + {/* Extra bottom padding on mobile for the sticky RSVP bar */} +
+ Back to events -
+
{/* Main Content */}
-

{event.name}

+ {/* Featured in badge - Luma style */} + {event.location.addressLocality && event.location.addressLocality !== "Online" && ( +
+ + Featured in {event.location.addressLocality} + +
+ )} + +

{event.name}

- {/* Host Row */} -
+ {/* Compact host link under title */} +
{event.organizer.initials}
-
-
-

{event.organizer.name}

- {event.organizer.eventCount > 5 && ( - - Trusted Host - - )} + + {event.organizer.name} + + + + {/* Date Row - Luma calendar block style */} +
+
+
+ {event.date.month.slice(0, 3)}
-
- {event.organizer.identifier} - · - {event.organizer.eventCount} events hosted - {reviewStats && reviewStats.averageRating > 0 && ( - <> - · - - - )} +
+ {event.date.day}
- -
- - - - {/* Date Row */} -
-
- -
-
+

{event.date.full}

{event.date.time}

- - - {/* Location Row */} -
-
+ {/* Location Row - Luma style with pin */} +
+
{isOnline ?
-
-

{event.location.name}

+
+

{event.location.name}

{isOnline && event.meetingPlatform ? (

{event.meetingPlatform === "zoom" && "Zoom Meeting"} @@ -151,47 +162,109 @@ export function EventDetailContent({ event }: EventDetailContentProps) { {event.meetingPlatform === "other" && "Online Meeting"}

) : ( -

- {event.location.streetAddress && `${event.location.streetAddress}, `}{event.location.addressLocality}, {event.location.addressCountry} +

+ {event.location.addressLocality}, {event.location.addressCountry}

)}
{isOnline && event.meetingUrl ? ( - + ) : ( )}
- {/* Map & Weather */} - {isInPerson && ( - <> -
- -
-
- -
- - )} - {/* Description */} -
+

About This Event

{event.description.split("\n\n").map((paragraph, index) => (

{paragraph}

))}
+ {/* Location Section - Luma style: heading, venue, address, map */} + {isInPerson && ( +
+ +

Location

+

{event.location.name}

+ {event.location.streetAddress && ( +

{event.location.streetAddress}

+ )} +

+ {event.location.addressLocality}, {event.location.addressCountry} +

+ +
+ )} + + {/* Weather for in-person events */} + {isInPerson && ( +
+ +
+ )} + + {/* Hosted By Section - Luma style */} +
+ +

Hosted By

+
+
+ {event.organizer.initials} +
+
+
+

{event.organizer.name}

+ +
+ {event.organizer.identifier && ( +

{event.organizer.identifier}

+ )} +
+ {event.organizer.eventCount} events hosted + {event.organizer.eventCount > 5 && ( + Trusted Host + )} + {reviewStats && reviewStats.averageRating > 0 && ( + <> + · + + + )} +
+ {/* Social links */} +
+ +
+ +
+
+
+ {/* Ratings */} {isPastEvent && ( -
+
+
)} {/* Referral Leaderboard */} -
+
+
+ + {/* Sticky Mobile RSVP + Bookmark Bar */} +
+
+ {/* Bookmark / Interested button */} + + {/* Price + RSVP */} +
+ +
+
+
); } diff --git a/src/app/events/[id]/event-sidebar.tsx b/src/app/events/[id]/event-sidebar.tsx index 30e91d4..15d5781 100644 --- a/src/app/events/[id]/event-sidebar.tsx +++ b/src/app/events/[id]/event-sidebar.tsx @@ -41,7 +41,7 @@ export function EventSidebar({ event, stats, reviewStats }: EventSidebarProps) { : null; return ( -