Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 38 additions & 16 deletions app/frontend/app/events/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -28,6 +30,7 @@ export default function EventDetailPage() {
const [error, setError] = useState<string | null>(null);
const [showReviewForm, setShowReviewForm] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const { isAuthenticated, address } = useAuth();

useEffect(() => {
const loadEvent = async () => {
Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<ScrollHeader threshold={20}>
Expand Down Expand Up @@ -160,22 +175,29 @@ export default function EventDetailPage() {

{/* Review Form Toggle */}
<div className="mb-6">
{!showReviewForm ? (
<button
onClick={() => setShowReviewForm(true)}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Write a Review
</button>
) : (
<div>
<ReviewForm
eventId={eventId}
onSubmit={handleReviewSubmit}
onCancel={() => setShowReviewForm(false)}
/>
</div>
)}
<EventAccessGate
requiredAccess={['registered', 'organizer']}
viewerStatus={viewerStatus}
unauthorizedMessage="Only registered attendees can submit reviews."
unauthorizedDescription="Register for this event to unlock the review form."
>
{!showReviewForm ? (
<button
onClick={() => setShowReviewForm(true)}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Write a Review
</button>
) : (
<div>
<ReviewForm
eventId={eventId}
onSubmit={handleReviewSubmit}
onCancel={() => setShowReviewForm(false)}
/>
</div>
)}
</EventAccessGate>
</div>

{/* Reviews List */}
Expand Down
4 changes: 0 additions & 4 deletions app/frontend/components/forms/CreateEventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <div className={className}>{children}</div>;
}

if (fallback) {
return <div className={className}>{fallback}</div>;
}

const accessLabel = getDefaultAccessLabel(requiredAccess);

return (
<div
className={`rounded-lg border border-amber-200 bg-amber-50/70 p-4 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/20 dark:text-amber-200 ${className}`.trim()}
role="note"
aria-live="polite"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-200/70 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200">
<Lock className="h-4 w-4" aria-hidden />
</span>
<div>
<p className="text-sm font-semibold">
{unauthorizedMessage || 'You do not currently have access to this section.'}
</p>
<p className="mt-1 text-xs opacity-90">
{unauthorizedDescription || `Required access level: ${accessLabel}.`}
</p>
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions app/frontend/components/ui/molecules/EventAccessGate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { EventAccessGate, hasEventAccess } from './EventAccessGate';
export type { EventAccessGateProps, EventAccessLevel, EventViewerStatus } from './EventAccessGate';
2 changes: 2 additions & 0 deletions app/frontend/components/ui/molecules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading