From c97813cdbd54f24b10214472a3b9014f8e429684 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Mon, 30 Mar 2026 10:30:14 +0100 Subject: [PATCH 1/2] feat(frontend): add reusable EventAccessGate for event content access --- app/frontend/app/events/[id]/page.tsx | 54 +++++++--- .../EventAccessGate/EventAccessGate.tsx | 102 ++++++++++++++++++ .../ui/molecules/EventAccessGate/index.ts | 2 + app/frontend/components/ui/molecules/index.ts | 2 + 4 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 app/frontend/components/ui/molecules/EventAccessGate/EventAccessGate.tsx create mode 100644 app/frontend/components/ui/molecules/EventAccessGate/index.ts diff --git a/app/frontend/app/events/[id]/page.tsx b/app/frontend/app/events/[id]/page.tsx index c14651d8..959bf67e 100644 --- a/app/frontend/app/events/[id]/page.tsx +++ b/app/frontend/app/events/[id]/page.tsx @@ -6,9 +6,11 @@ import Link from 'next/link'; import { ArrowLeft } from 'lucide-react'; import { eventsApi, Event } from '../../../lib/api/events'; import { ScrollHeader } from '@/components/layout/ScrollHeader'; +import { EventAccessGate } from '@/components/ui/molecules'; import RatingDisplay from '../../../components/reviews/rating-display'; import ReviewList from '../../../components/reviews/review-list'; import ReviewForm from '../../../components/reviews/review-form'; +import { useAuth } from '../../../hooks/useAuth'; /** API may include aggregates not yet on the base `Event` type */ type EventDetailPayload = Event & { @@ -28,6 +30,7 @@ export default function EventDetailPage() { const [error, setError] = useState(null); const [showReviewForm, setShowReviewForm] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const { isAuthenticated, address } = useAuth(); useEffect(() => { const loadEvent = async () => { @@ -95,6 +98,18 @@ export default function EventDetailPage() { }); }; + const normalizedAddress = address?.toLowerCase(); + const normalizedOrganizer = event.organizerId?.toLowerCase(); + + const isOrganizer = Boolean(normalizedAddress && normalizedOrganizer && normalizedAddress === normalizedOrganizer); + const isRegistered = Boolean((event as Event & { isRegistered?: boolean }).isRegistered); + + const viewerStatus = { + isAuthenticated, + isRegistered, + isOrganizer, + }; + return (
@@ -160,22 +175,29 @@ export default function EventDetailPage() { {/* Review Form Toggle */}
- {!showReviewForm ? ( - - ) : ( -
- setShowReviewForm(false)} - /> -
- )} + + {!showReviewForm ? ( + + ) : ( +
+ setShowReviewForm(false)} + /> +
+ )} +
{/* Reviews List */} diff --git a/app/frontend/components/ui/molecules/EventAccessGate/EventAccessGate.tsx b/app/frontend/components/ui/molecules/EventAccessGate/EventAccessGate.tsx new file mode 100644 index 00000000..ffc60757 --- /dev/null +++ b/app/frontend/components/ui/molecules/EventAccessGate/EventAccessGate.tsx @@ -0,0 +1,102 @@ +'use client'; + +import React from 'react'; +import { Lock } from 'lucide-react'; + +export type EventAccessLevel = 'public' | 'registered' | 'organizer'; + +export interface EventViewerStatus { + isAuthenticated: boolean; + isRegistered: boolean; + isOrganizer: boolean; +} + +export interface EventAccessGateProps { + /** Required access level(s) for this UI section. */ + requiredAccess: EventAccessLevel | EventAccessLevel[]; + /** Current viewer status for this event. */ + viewerStatus: EventViewerStatus; + /** Content to render when access is granted. */ + children: React.ReactNode; + /** Optional custom fallback when unauthorized. */ + fallback?: React.ReactNode; + /** Optional message for default fallback. */ + unauthorizedMessage?: string; + /** Optional description for default fallback. */ + unauthorizedDescription?: string; + /** Optional className for wrapper. */ + className?: string; +} + +export function hasEventAccess( + requiredAccess: EventAccessLevel | EventAccessLevel[], + viewerStatus: EventViewerStatus +): boolean { + const required = Array.isArray(requiredAccess) ? requiredAccess : [requiredAccess]; + + return required.some((level) => { + switch (level) { + case 'public': + return true; + case 'registered': + return viewerStatus.isRegistered || viewerStatus.isOrganizer; + case 'organizer': + return viewerStatus.isOrganizer; + default: + return false; + } + }); +} + +function getDefaultAccessLabel(requiredAccess: EventAccessLevel | EventAccessLevel[]): string { + const required = Array.isArray(requiredAccess) ? requiredAccess : [requiredAccess]; + + if (required.includes('public')) return 'Public'; + if (required.includes('organizer')) return 'Organizer'; + if (required.includes('registered')) return 'Registered attendee'; + return 'Restricted'; +} + +export function EventAccessGate({ + requiredAccess, + viewerStatus, + children, + fallback, + unauthorizedMessage, + unauthorizedDescription, + className = '', +}: EventAccessGateProps) { + const allowed = hasEventAccess(requiredAccess, viewerStatus); + + if (allowed) { + return
{children}
; + } + + if (fallback) { + return
{fallback}
; + } + + const accessLabel = getDefaultAccessLabel(requiredAccess); + + return ( +
+
+ + + +
+

+ {unauthorizedMessage || 'You do not currently have access to this section.'} +

+

+ {unauthorizedDescription || `Required access level: ${accessLabel}.`} +

+
+
+
+ ); +} diff --git a/app/frontend/components/ui/molecules/EventAccessGate/index.ts b/app/frontend/components/ui/molecules/EventAccessGate/index.ts new file mode 100644 index 00000000..a7291684 --- /dev/null +++ b/app/frontend/components/ui/molecules/EventAccessGate/index.ts @@ -0,0 +1,2 @@ +export { EventAccessGate, hasEventAccess } from './EventAccessGate'; +export type { EventAccessGateProps, EventAccessLevel, EventViewerStatus } from './EventAccessGate'; diff --git a/app/frontend/components/ui/molecules/index.ts b/app/frontend/components/ui/molecules/index.ts index 7d7fa120..aac976ec 100644 --- a/app/frontend/components/ui/molecules/index.ts +++ b/app/frontend/components/ui/molecules/index.ts @@ -11,3 +11,5 @@ export type { RoleGateProps, Role, UserRole } from './RoleGate'; export { RegistrationGuard } from './RegistrationGuard'; export type { RegistrationGuardProps, RegistrationRule } from './RegistrationGuard'; export { createWalletRule, createCapacityRule, createExpirationRule, createCustomRule } from './RegistrationGuard'; +export { EventAccessGate, hasEventAccess } from './EventAccessGate'; +export type { EventAccessGateProps, EventAccessLevel, EventViewerStatus } from './EventAccessGate'; From baa2206c8a96091a3ffdb2c6b6c347730df2aa7e Mon Sep 17 00:00:00 2001 From: Ahmed Date: Mon, 30 Mar 2026 10:35:17 +0100 Subject: [PATCH 2/2] fix(frontend): repair CreateEventForm schema syntax for build --- app/frontend/components/forms/CreateEventForm.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/frontend/components/forms/CreateEventForm.tsx b/app/frontend/components/forms/CreateEventForm.tsx index 9d33d1ca..cc482c82 100644 --- a/app/frontend/components/forms/CreateEventForm.tsx +++ b/app/frontend/components/forms/CreateEventForm.tsx @@ -44,10 +44,6 @@ const createEventSchema = z.object({ maxAttendees: z .string() .refine(v => !isNaN(Number(v)) && Number(v) >= 1 && Number.isInteger(Number(v)), 'Must be a whole number ≥ 1'), - category: z - .string() - .refine((value) => EVENT_CATEGORIES.includes(value as (typeof EVENT_CATEGORIES)[number]), { - message: 'Please select a category', category: z.enum(['conference', 'workshop', 'concert', 'hackathon', 'meetup', ''], { errorMap: () => ({ message: 'Please select a category' }), }).refine(v => v !== '', { message: 'Please select a category' }),