diff --git a/docs/auth.md b/docs/auth.md index ce70966..7bc5208 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -75,20 +75,20 @@ Peer dependencies: `payload >= 3.69.0`, `@payloadcms/next >= 3.69.0` (satisfied | Variable | Example | Description | |----------|---------|-------------| | `BETTER_AUTH_SECRET` | `openssl rand -hex 32` | Signs session cookies | -| `NEXT_PUBLIC_BETTER_AUTH_URL` | `http://localhost:3000` | Base URL for auth API calls. **Optional on Vercel** — auto-derived from `VERCEL_PROJECT_PRODUCTION_URL`. Only needed for local dev or custom domain overrides. | +| `NEXT_PUBLIC_BETTER_AUTH_URL` | `http://localhost:3000` | Server-side Better Auth base URL override for local development or a production custom domain. The browser client calls its own origin. Previews use their Vercel URL even if this variable is set. | | `GOOGLE_CLIENT_ID` | `xxx.apps.googleusercontent.com` | Google OAuth client ID | | `GOOGLE_CLIENT_SECRET` | `GOCSPX-xxx` | Google OAuth client secret | ### Base URL resolution order -Both the server config (`options.ts`) and the client (`client.ts`) resolve the base URL in the same priority: +The server config (`options.ts`) resolves its base URL in this order: -1. `NEXT_PUBLIC_BETTER_AUTH_URL` — explicit override (local dev, custom domain) -2. `VERCEL_PROJECT_PRODUCTION_URL` / `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` — Vercel's stable production domain, available on **all** deployments (production + preview) -3. `VERCEL_URL` / `NEXT_PUBLIC_VERCEL_URL` — per-deployment URL (fallback) -4. `http://localhost:3000` — local development fallback +1. On previews, `VERCEL_BRANCH_URL`, then `VERCEL_URL` +2. Otherwise, `NEXT_PUBLIC_BETTER_AUTH_URL` — explicit override (local dev, custom domain) +3. On production, `VERCEL_PROJECT_PRODUCTION_URL` +4. `VERCEL_URL`, then `VERCEL_PROJECT_PRODUCTION_URL`, then `http://localhost:3000` -On Vercel, `VERCEL_PROJECT_PRODUCTION_URL` is the production domain (e.g. `learning-platform-six-cyan.vercel.app`) and is always set, even on preview deployments. This means auth API calls from preview deployments route to the production domain, which is correct because Better Auth sessions are tied to a single origin and Google OAuth redirect URIs are registered against the production domain. +The browser client calls `/api/auth` on the page's own origin. Better Auth and Payload both allow the Vercel branch URL, so sign-in works from the stable PR preview link as well as the generated deployment URL. Google OAuth redirect URI: `https://{production-domain}/api/auth/callback/google` @@ -367,7 +367,7 @@ If `/admin` redirects to the first-admin setup screen but rejects your email as ### Session cookie not set after sign in -Check that the resolved base URL matches the origin you're accessing the app from. On Vercel, this is `VERCEL_PROJECT_PRODUCTION_URL`. Locally, set `NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000`. The `trustedOrigins` config must include the URL. +Check that the resolved base URL matches the origin you're accessing the app from. On Vercel previews, check `VERCEL_BRANCH_URL` and `VERCEL_URL`; on production, check the configured custom domain. Locally, set `NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000`. The `trustedOrigins` config must include the URL. ### Google OAuth callback fails diff --git a/src/actions/commentsAndLikes.ts b/src/actions/commentsAndLikes.ts index 15f56b2..6ef8c3c 100644 --- a/src/actions/commentsAndLikes.ts +++ b/src/actions/commentsAndLikes.ts @@ -7,8 +7,8 @@ import { getSession } from '@/lib/auth/getSession' const isRateLimited = (err: unknown): boolean => err instanceof APIError && err.status === 429 -type TargetCollection = 'posts' | 'courses' -type LikeTargetCollection = 'posts' | 'courses' | 'comments' +type TargetCollection = 'posts' | 'courses' | 'events' +type LikeTargetCollection = TargetCollection | 'comments' export type CommentWithMeta = { id: number @@ -249,7 +249,7 @@ export type LikeInfo = { } function revalidateCounts(kind: 'likes' | 'comments', targetCollection: LikeTargetCollection) { - if (targetCollection === 'posts' || targetCollection === 'courses') { + if (targetCollection !== 'comments') { revalidateTag(`${kind}-counts-${targetCollection}`) } } diff --git a/src/app/(frontend)/[locale]/events/[slug]/page.tsx b/src/app/(frontend)/[locale]/events/[slug]/page.tsx new file mode 100644 index 0000000..d6ecf37 --- /dev/null +++ b/src/app/(frontend)/[locale]/events/[slug]/page.tsx @@ -0,0 +1,342 @@ +import type { Metadata } from 'next/types' +import { getPayload } from 'payload' +import configPromise from '@payload-config' +import React, { cache } from 'react' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ArrowLeft, CalendarDays, Clock3, MapPin, Users, Video } from 'lucide-react' + +import { locales, type SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { plural } from '@/utilities/plural' +import type { Event, Media as MediaType } from '@/payload-types' +import { getPreviewAwareServerURL } from '@/utilities/getURL' +import { AccentLine } from '@/components/brand' +import { + formatEventDate, + formatEventDayNumber, + formatEventMonthShort, + formatEventRange, + formatEventTime, + getEventTimes, + isEventPast, +} from '@/utilities/eventTime' +import { EventUserStateProvider } from '@/components/Events/EventUserState' +import { EventActionBar } from '@/components/Events/EventActionBar' +import { EventJoinCard } from '@/components/Events/EventJoinCard' +import { AddToCalendar } from '@/components/Events/AddToCalendar' +import { InteractionSection } from '@/components/CommentsAndLikes/InteractionSection' +import { ShareButtons } from '@/components/ShareButtons' + +export const revalidate = 300 + +type Args = { + params: Promise<{ locale: SiteLocale; slug: string }> +} + +function eventPageUrl(locale: SiteLocale, slug: string): string { + const base = getPreviewAwareServerURL() + return `${base}${locale === 'en' ? '/en' : ''}/events/${encodeURIComponent(slug)}` +} + +const queryEventBySlug = cache(async (locale: SiteLocale, slug: string): Promise => { + const payload = await getPayload({ config: configPromise }) + // The meeting link is excluded from the shared ISR page by field access. + const result = await payload.find({ + collection: 'events', + locale, + depth: 1, + draft: false, + overrideAccess: false, + where: { slug: { equals: slug }, _status: { equals: 'published' } }, + limit: 1, + }) + return result.docs[0] as Event | undefined +}) + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const events = await payload.find({ + collection: 'events', + draft: false, + limit: 1000, + overrideAccess: false, + pagination: false, + where: { _status: { equals: 'published' } }, + select: { slug: true }, + }) + return events.docs.flatMap(({ slug }) => locales.map((locale) => ({ locale, slug }))) +} + +export default async function EventPage({ params: paramsPromise }: Args) { + const { locale, slug } = await paramsPromise + const t = getFrontendMessages(locale) + const event = await queryEventBySlug(locale, slug) + if (!event) notFound() + + const payload = await getPayload({ config: configPromise }) + const { totalDocs: enrolledCount } = await payload.count({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + }) + + const cover = event.cover && typeof event.cover === 'object' ? (event.cover as MediaType) : null + const coverUrl = cover?.sizes?.large?.url || cover?.sizes?.xlarge?.url || cover?.url + const prefix = locale === 'en' ? '/en' : '' + const past = isEventPast(event) + const isFull = typeof event.capacity === 'number' && enrolledCount >= event.capacity + const seatsLeft = typeof event.capacity === 'number' ? Math.max(event.capacity - enrolledCount, 0) : null + const { startsAt, endsAt } = getEventTimes(event) + const eventUrl = eventPageUrl(locale, event.slug) + const locationLabel = event.locationType === 'virtual' ? t.eventOnline : t.eventOffline + const schema = { + '@context': 'https://schema.org', + '@type': 'Event', + name: event.title, + description: event.description || undefined, + startDate: startsAt.toISOString(), + endDate: event.endDate ? endsAt.toISOString() : undefined, + eventStatus: 'https://schema.org/EventScheduled', + eventAttendanceMode: event.locationType === 'virtual' + ? 'https://schema.org/OnlineEventAttendanceMode' + : 'https://schema.org/OfflineEventAttendanceMode', + location: event.locationType === 'virtual' + ? { '@type': 'VirtualLocation', url: eventUrl } + : { '@type': 'Place', name: event.address || t.eventOffline, address: event.address }, + image: coverUrl ? new URL(coverUrl, new URL(eventUrl).origin).href : undefined, + url: eventUrl, + } + + return ( + +
+