diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx index 4307095..ca2faa8 100644 --- a/app/admin/talks/page.tsx +++ b/app/admin/talks/page.tsx @@ -214,7 +214,8 @@ export default function AdminTalks() { document.body.appendChild(linkElement) linkElement.click() document.body.removeChild(linkElement) - window.URL.revokeObjectURL(fileUrl) + // Delay revoking the blob URL so the browser can start the asynchronous download + setTimeout(() => window.URL.revokeObjectURL(fileUrl), 10_000) } catch (err: any) { setError(err.message || "Failed to download slides") } finally { diff --git a/app/admin/talks/page.tsx.orig b/app/admin/talks/page.tsx.orig new file mode 100644 index 0000000..4307095 --- /dev/null +++ b/app/admin/talks/page.tsx.orig @@ -0,0 +1,745 @@ +"use client" +import Link from "next/link" +import styled from "styled-components" +import { useState, useEffect, useCallback } from "react" +import { supabaseClient } from "../../../lib/supabaseClient" +import { useRequireAdminAuth } from "../../hooks/useRequireAdminAuth" +import { PotionBackground } from "../../components/PotionBackground" +import { TalkThumbnail } from "../../components/TalkThumbnail" + +// Types // + +type TalkStatus = + | "pending" + | "under_review" + | "approved" + | "rejected" + | "scheduled" + | "completed" + | "cancelled" + +interface TalkSubmission { + id: number + talk_title: string + talk_hook: string | null + talk_synopsis: string + slides_type: "url" | "upload" + slides_url: string | null + slides_file_path: string | null + status: TalkStatus + admin_notes: string | null + created_at: string + updated_at: string + profiles: { + full_name: string + email: string + phone_number: string | null + handle: string | null + profile_photo: string | null + } +} + +// Constants // + +const TALK_STATUSES: TalkStatus[] = [ + "pending", + "under_review", + "approved", + "rejected", + "scheduled", + "completed", + "cancelled" +] + +const DEFAULT_FILTER_STATUSES: TalkStatus[] = ["pending", "under_review", "approved", "scheduled"] + +const STATUS_COLORS: Record = { + pending: "#f59e0b", + under_review: "#3b82f6", + approved: "#10b981", + rejected: "#ef4444", + scheduled: "#8b5cf6", + completed: "#6b7280", + cancelled: "#9ca3af" +} + +// Components // + +export default function AdminTalks() { + const { loading, isAdmin } = useRequireAdminAuth() + const [talks, setTalks] = useState([]) + const [filterStatuses, setFilterStatuses] = useState(DEFAULT_FILTER_STATUSES) + const [updatingId, setUpdatingId] = useState(null) + const [downloadingId, setDownloadingId] = useState(null) + const [error, setError] = useState(null) + + const fetchTalks = useCallback(async () => { + let query = supabaseClient + .from("talk_submissions") + .select( + ` + id, + talk_title, + talk_hook, + talk_synopsis, + slides_type, + slides_url, + slides_file_path, + status, + admin_notes, + created_at, + updated_at, + profiles ( + full_name, + email, + phone_number, + handle, + profile_photo + ) + ` + ) + .order("created_at", { ascending: false }) + + if (filterStatuses.length === 0) { + setTalks([]) + return + } + + if (filterStatuses.length !== TALK_STATUSES.length) { + query = query.in("status", filterStatuses) + } + + const { data, error: fetchError } = await query + + if (fetchError) { + setError(fetchError.message) + return + } + + setTalks((data as unknown as TalkSubmission[]) || []) + }, [filterStatuses]) + + useEffect(() => { + if (isAdmin) { + fetchTalks() + } + }, [isAdmin, fetchTalks]) + + const handleStatusChange = async (talkId: number, newStatus: TalkStatus) => { + setUpdatingId(talkId) + setError(null) + + try { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + const { error: updateError } = await supabaseClient + .from("talk_submissions") + .update({ + status: newStatus, + reviewed_by: user?.id, + reviewed_at: new Date().toISOString() + }) + .eq("id", talkId) + + if (updateError) throw updateError + + await fetchTalks() + } catch (err: any) { + setError(err.message || "Failed to update status") + } finally { + setUpdatingId(null) + } + } + + const handleFilterStatusToggle = (status: TalkStatus, withOptionKey: boolean) => { + setFilterStatuses((previousStatuses) => { + if (withOptionKey) { + const hasOtherEnabledStatuses = previousStatuses.some( + (enabledStatus) => enabledStatus !== status + ) + return hasOtherEnabledStatuses ? [status] : TALK_STATUSES + } + + if (previousStatuses.includes(status)) { + return previousStatuses.filter((enabledStatus) => enabledStatus !== status) + } + + return TALK_STATUSES.filter( + (candidateStatus) => + candidateStatus === status || previousStatuses.includes(candidateStatus) + ) + }) + } + + const getFileNameFromPath = (filePath: string) => { + const pathSegments = filePath.split("/") + return pathSegments[pathSegments.length - 1] || filePath + } + + const getStatusLabel = (status: TalkStatus) => status.replace("_", " ") + + const getStatusWidthCh = (status: TalkStatus) => + Math.max(Math.ceil(getStatusLabel(status).length * 1.05) + 3, 10) + + const buildThumbnailGeneratorUrl = (talk: TalkSubmission) => { + const params = new URLSearchParams() + params.set("hook", talk.talk_hook || talk.talk_title) + params.set("speakerName", talk.profiles.full_name) + if (talk.profiles.handle) { + params.set("handle", talk.profiles.handle) + } + if (talk.profiles.profile_photo) { + params.set("profilePhotoUrl", talk.profiles.profile_photo) + } + return `/admin/thumbnails?${params.toString()}` + } + + const handleSlidesDownload = async (talkId: number, filePath: string) => { + setDownloadingId(talkId) + setError(null) + + try { + const { data: fileData, error: downloadError } = await supabaseClient.storage + .from("talk-slides") + .download(filePath) + + if (downloadError) throw downloadError + + const fileUrl = window.URL.createObjectURL(fileData) + const linkElement = document.createElement("a") + linkElement.href = fileUrl + linkElement.download = getFileNameFromPath(filePath) + document.body.appendChild(linkElement) + linkElement.click() + document.body.removeChild(linkElement) + window.URL.revokeObjectURL(fileUrl) + } catch (err: any) { + setError(err.message || "Failed to download slides") + } finally { + setDownloadingId(null) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }) + } + + if (loading) { + return ( + <> + + + + + Verifying admin access... + + + ) + } + + if (!isAdmin) return null + + return ( + <> + + + + + + + Talk Submissions + + {talks.length} submission{talks.length !== 1 ? "s" : ""} + {filterStatuses.length !== TALK_STATUSES.length + ? ` (${filterStatuses.length} status filter${filterStatuses.length !== 1 ? "s" : ""})` + : ""} + + + + {error && {error}} + + + Filter by status: + + {TALK_STATUSES.map((status) => ( + handleFilterStatusToggle(status, event.altKey)} + > + {status.replace("_", " ")} + + ))} + + + + {talks.length === 0 ? ( + No submissions found. + ) : ( + + {talks.map((talk) => ( + + + {talk.talk_title} + + handleStatusChange(talk.id, e.target.value as TalkStatus)} + disabled={updatingId === talk.id} + aria-label={`Change status for ${talk.talk_title}`} + > + {TALK_STATUSES.map((status) => ( + + ))} + + + + + + + + + + + + {talk.talk_synopsis} + + {(talk.slides_url || talk.slides_file_path) && ( + + Slides: {talk.slides_type === "url" ? "URL" : "Uploaded file"} + {talk.slides_url && ( + <> + {" — "} + + {talk.slides_url} + + + )} + {talk.slides_file_path && ( + <> + {" — "} + + handleSlidesDownload(talk.id, talk.slides_file_path as string) + } + disabled={downloadingId === talk.id} + > + {downloadingId === talk.id + ? "Downloading..." + : `Download ${getFileNameFromPath(talk.slides_file_path)}`} + + + )} + + )} + + + + + Submitter + + {talk.profiles.full_name} + {talk.profiles.handle && ( + + @{talk.profiles.handle} + + )} + + + + Email + {talk.profiles.email} + + + Phone + {talk.profiles.phone_number || "Not provided"} + + + Submitted + {formatDate(talk.created_at)} + + + Updated + {formatDate(talk.updated_at)} + + + + ))} + + )} + + + + ) +} + +// Styled Components // + +const BackgroundContainer = styled.section` + background-color: #0a0a0a; + position: fixed; + height: 100vh; + width: 100vw; + top: 0; + left: 0; + z-index: -1; +` + +const Container = styled.main` + min-height: 100vh; + display: flex; + justify-content: center; + padding: 2rem 1rem; +` + +const ContentWrapper = styled.div` + width: 100%; + max-width: 960px; + display: flex; + flex-direction: column; + gap: 1.5rem; +` + +const PageHeader = styled.div` + text-align: center; +` + +const Title = styled.h1` + font-size: 2rem; + font-weight: 700; + color: white; + margin: 0; +` + +const Subtitle = styled.p` + color: rgba(255, 255, 255, 0.6); + margin: 0.5rem 0 0 0; +` + +const FilterBar = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; +` + +const FilterLabel = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + font-weight: 600; +` + +const FilterButtons = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +` + +const FilterButton = styled.button<{ $active: boolean; $color?: string }>` + padding: 0.375rem 0.75rem; + border-radius: 1rem; + font-size: 0.8125rem; + font-family: inherit; + cursor: pointer; + transition: all 0.2s ease; + text-transform: capitalize; + border: 1px solid + ${(props) => (props.$active ? props.$color || "white" : "rgba(255, 255, 255, 0.2)")}; + background-color: ${(props) => + props.$active ? (props.$color || "white") + "22" : "transparent"}; + color: ${(props) => (props.$active ? props.$color || "white" : "rgba(255, 255, 255, 0.6)")}; + + &:hover { + border-color: ${(props) => props.$color || "white"}; + color: ${(props) => props.$color || "white"}; + } +` + +const TalkList = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +const TalkCard = styled.div` + background-color: rgba(21, 21, 28, 0.75); + -webkit-backdrop-filter: blur(20px); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.75rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +` + +const CardHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +` + +const TalkTitle = styled.h2` + font-size: 1.125rem; + font-weight: 600; + color: white; + margin: 0; + flex: 1; +` + +const DetailsGrid = styled.div` + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); + gap: 1rem; + align-items: start; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +` + +const ThumbnailColumn = styled.div` + max-width: 420px; + width: 100%; + min-width: 0; + + @media (max-width: 900px) { + max-width: none; + justify-self: stretch; + } +` + +const ThumbnailLink = styled(Link)` + display: block; + border-radius: 0.5rem; + text-decoration: none; + + &:hover { + opacity: 0.95; + } + + &:focus-visible { + outline: 2px solid rgba(156, 163, 255, 0.9); + outline-offset: 2px; + } +` + +const DetailsColumn = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + background-color: rgba(255, 255, 255, 0.035); + border-radius: 0.75rem; + padding: 0.875rem; +` + +const StatusPillWrap = styled.div` + position: relative; + display: inline-flex; + align-items: center; +` + +const StatusPillSelect = styled.select<{ $color: string; $widthCh: number }>` + width: ${(props) => `${props.$widthCh}ch`}; + padding: 0.28rem 1.65rem 0.28rem 0.72rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; + white-space: nowrap; + text-align: center; + text-align-last: center; + font-family: inherit; + color: ${(props) => props.$color}; + background-color: ${(props) => props.$color}22; + border: 1px solid ${(props) => props.$color}44; + cursor: pointer; + appearance: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + background-image: none; + line-height: 1.15; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; + + &:hover:not(:disabled) { + background-color: ${(props) => props.$color}2c; + border-color: ${(props) => props.$color}66; + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px ${(props) => props.$color}55; + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &::-ms-expand { + display: none; + } + + option { + background-color: #1a1a2e; + color: white; + } +` + +const StatusChevron = styled.span<{ $color: string }>` + position: absolute; + right: 0.62rem; + top: 50%; + transform: translateY(-58%) rotate(45deg); + width: 0.5rem; + height: 0.5rem; + border-right: 2px solid ${(props) => props.$color}; + border-bottom: 2px solid ${(props) => props.$color}; + pointer-events: none; +` + +const MetaRow = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + gap: 0.75rem; + background-color: rgba(255, 255, 255, 0.03); + border-radius: 0.75rem; + padding: 0.75rem; + + @media (max-width: 900px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (max-width: 560px) { + grid-template-columns: 1fr; + } +` + +const MetaItem = styled.div` + display: flex; + flex-direction: column; + gap: 0.125rem; +` + +const MetaLabel = styled.span` + font-size: 0.6875rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.05em; +` + +const MetaValue = styled.span` + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + gap: 0.5rem; +` + +const HandleLink = styled.a` + color: rgba(156, 163, 255, 0.9); + text-decoration: none; + font-size: 0.8125rem; + + &:hover { + text-decoration: underline; + } +` + +const Synopsis = styled.p` + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; +` + +const SlidesInfo = styled.div` + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.5); +` + +const SlidesLink = styled.a` + color: rgba(156, 163, 255, 0.9); + text-decoration: none; + word-break: break-all; + + &:hover { + text-decoration: underline; + } +` + +const SlidesDownloadButton = styled.button` + background: none; + border: none; + padding: 0; + color: rgba(156, 163, 255, 0.9); + font-size: 0.8125rem; + font-family: inherit; + text-decoration: underline; + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + text-decoration: none; + } + + &:hover:not(:disabled) { + color: rgba(156, 163, 255, 1); + } +` + +const ErrorMessage = styled.div` + color: #ff6b6b; + background-color: rgba(255, 107, 107, 0.1); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 0.875rem; + text-align: center; +` + +const EmptyState = styled.div` + text-align: center; + color: rgba(255, 255, 255, 0.5); + padding: 3rem; + font-size: 1rem; +` + +const LoadingText = styled.div` + color: white; + font-size: 1.25rem; + text-align: center; + margin-top: 4rem; +` diff --git a/app/hooks/useRequireAdminAuth.ts b/app/hooks/useRequireAdminAuth.ts index 6462f78..39109a5 100644 --- a/app/hooks/useRequireAdminAuth.ts +++ b/app/hooks/useRequireAdminAuth.ts @@ -32,12 +32,14 @@ export function useRequireAdminAuth(): UseRequireAdminAuthResult { if (!user) { const redirectPath = encodeURIComponent(pathname || "/") router.push(`/login?redirect=${redirectPath}`) + setLoading(false) return } const admin = await checkIsAdmin() if (!admin) { router.push("/") + setLoading(false) return } diff --git a/app/hooks/useRequireAdminAuth.ts.orig b/app/hooks/useRequireAdminAuth.ts.orig new file mode 100644 index 0000000..6462f78 --- /dev/null +++ b/app/hooks/useRequireAdminAuth.ts.orig @@ -0,0 +1,52 @@ +"use client" +import { useEffect, useState } from "react" +import { usePathname, useRouter } from "next/navigation" +import { supabaseClient } from "../../lib/supabaseClient" +import { checkIsAdmin } from "../../lib/adminCheck" + +// +// Types +// + +type UseRequireAdminAuthResult = { + loading: boolean + isAdmin: boolean +} + +// +// Hooks +// + +export function useRequireAdminAuth(): UseRequireAdminAuthResult { + const router = useRouter() + const pathname = usePathname() + const [loading, setLoading] = useState(true) + const [isAdmin, setIsAdmin] = useState(false) + + useEffect(() => { + const verifyAdminAccess = async () => { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + const redirectPath = encodeURIComponent(pathname || "/") + router.push(`/login?redirect=${redirectPath}`) + return + } + + const admin = await checkIsAdmin() + if (!admin) { + router.push("/") + return + } + + setIsAdmin(true) + setLoading(false) + } + + verifyAdminAccess() + }, [pathname, router]) + + return { loading, isAdmin } +} diff --git a/app/services/luma/ApiLumaService.ts b/app/services/luma/ApiLumaService.ts index 0fcde0c..893aa39 100644 --- a/app/services/luma/ApiLumaService.ts +++ b/app/services/luma/ApiLumaService.ts @@ -1,4 +1,4 @@ -import type { LumaEvent, LumaService } from "./types" +import type { LumaEvent, LumaService, LumaLocation } from "./types" // Constants // @@ -24,12 +24,15 @@ export class ApiLumaService implements LumaService { throw new Error(`Failed to fetch events: ${response.statusText}`) } - const data = await response.json() - return data.entries || [] + const data: { entries?: Array<{ event: Record }> } = await response.json() + // The list endpoint returns entries with nested event objects; extract them. + return (data.entries || []).map((entry) => this.transformApiEvent(entry.event)) } async getEvent(eventId: string): Promise { - const response = await fetch(`${LUMA_API_BASE_URL}/event/get?event_api_id=${eventId}`, { + const url = new URL(`${LUMA_API_BASE_URL}/event/get`) + url.searchParams.set("api_id", eventId) + const response = await fetch(url.toString(), { headers: { "x-luma-api-key": this.apiKey } @@ -42,8 +45,11 @@ export class ApiLumaService implements LumaService { throw new Error(`Failed to fetch event: ${response.statusText}`) } - const data = await response.json() - return data + const data: { event?: Record } = await response.json() + if (!data.event) { + return null + } + return this.transformApiEvent(data.event) } async registerForEvent(eventId: string, email: string): Promise { @@ -66,14 +72,11 @@ export class ApiLumaService implements LumaService { async checkRegistration(eventId: string, email: string): Promise { try { - const response = await fetch( - `${LUMA_API_BASE_URL}/event/get-guests?event_api_id=${eventId}`, - { - headers: { - "x-luma-api-key": this.apiKey - } + const response = await fetch(`${LUMA_API_BASE_URL}/event/get-guests?api_id=${eventId}`, { + headers: { + "x-luma-api-key": this.apiKey } - ) + }) if (!response.ok) { return false @@ -86,4 +89,46 @@ export class ApiLumaService implements LumaService { return false } } + + /** + * Transform a raw API event object into a typed LumaEvent, + * matching the transformation in scripts/update-events.ts. + */ + private transformApiEvent(raw: Record): LumaEvent { + let location: LumaLocation | undefined + const meetingUrl = raw.meeting_url as string | undefined + const geo = raw.geo as Record | undefined + + if (meetingUrl) { + location = { type: "online" } + } else if (geo) { + const address = geo.address as Record | undefined + location = { + type: "physical", + address: (address?.full_address as string) || (address?.street_address as string), + city: address?.city as string, + state: address?.region as string, + coordinates: + geo.latitude && geo.longitude + ? { lat: geo.latitude as string, lng: geo.longitude as string } + : undefined + } + } + + return { + api_id: raw.api_id as string, + name: raw.name as string, + description: (raw.description as string) || (raw.name as string), + description_md: raw.description_md as string | undefined, + description_html: raw.description_html as string | undefined, + start_at: raw.start_at as string, + end_at: raw.end_at as string, + location, + cover_url: raw.cover_url as string | undefined, + url: (raw.url as string) || `https://lu.ma/${(raw.api_id as string).replace(/^evt-/, "")}`, + guest_count: (raw.guest_count as number) ?? -1, + visibility: (raw.visibility as "public" | "private") || "public", + timezone: (raw.timezone as string) || "America/Los_Angeles" + } + } } diff --git a/app/services/luma/ApiLumaService.ts.orig b/app/services/luma/ApiLumaService.ts.orig new file mode 100644 index 0000000..0fcde0c --- /dev/null +++ b/app/services/luma/ApiLumaService.ts.orig @@ -0,0 +1,89 @@ +import type { LumaEvent, LumaService } from "./types" + +// Constants // + +const LUMA_API_BASE_URL = "https://public-api.luma.com/v1" + +// Implementation // + +export class ApiLumaService implements LumaService { + private apiKey: string + + constructor(apiKey: string) { + this.apiKey = apiKey + } + + async listEvents(): Promise { + const response = await fetch(`${LUMA_API_BASE_URL}/calendar/list-events`, { + headers: { + "x-luma-api-key": this.apiKey + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch events: ${response.statusText}`) + } + + const data = await response.json() + return data.entries || [] + } + + async getEvent(eventId: string): Promise { + const response = await fetch(`${LUMA_API_BASE_URL}/event/get?event_api_id=${eventId}`, { + headers: { + "x-luma-api-key": this.apiKey + } + }) + + if (!response.ok) { + if (response.status === 404) { + return null + } + throw new Error(`Failed to fetch event: ${response.statusText}`) + } + + const data = await response.json() + return data + } + + async registerForEvent(eventId: string, email: string): Promise { + const response = await fetch(`${LUMA_API_BASE_URL}/event/add-guests`, { + method: "POST", + headers: { + "x-luma-api-key": this.apiKey, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + event_api_id: eventId, + guests: [{ email }] + }) + }) + + if (!response.ok) { + throw new Error(`Failed to register for event: ${response.statusText}`) + } + } + + async checkRegistration(eventId: string, email: string): Promise { + try { + const response = await fetch( + `${LUMA_API_BASE_URL}/event/get-guests?event_api_id=${eventId}`, + { + headers: { + "x-luma-api-key": this.apiKey + } + } + ) + + if (!response.ok) { + return false + } + + const data = await response.json() + const guests = data.entries || [] + return guests.some((guest: any) => guest.email === email) + } catch { + return false + } + } +} diff --git a/app/services/luma/ApiLumaService.ts.rej b/app/services/luma/ApiLumaService.ts.rej new file mode 100644 index 0000000..429b057 --- /dev/null +++ b/app/services/luma/ApiLumaService.ts.rej @@ -0,0 +1,38 @@ +--- ApiLumaService.ts ++++ ApiLumaService.ts +@@ -17,10 +17,15 @@ + if (!response.ok) { + throw new Error(`Failed to fetch events: ${response.statusText}`) + } + +- const data = await response.json() +- return data.entries || [] ++ const data: { entries?: Array<{ event: Record }> } = await response.json() ++ // The list endpoint returns entries with nested event objects; extract them. ++ return (data.entries || []).map((entry) => this.transformApiEvent(entry.event)) + } + ++ /** ++ * Fetch a single event by its API ID. ++ * The Luma `event/get` endpoint expects `api_id` as a query parameter ++ * and returns the event wrapped in an `{ event: ... }` envelope. ++ */ + async getEvent(eventId: string): Promise { +- const response = await fetch(`${LUMA_API_BASE_URL}/event/get?event_api_id=${eventId}`, { +--- /dev/null ++++ /dev/null +@@ -33,9 +38,12 @@ + return null + } + throw new Error(`Failed to fetch event: ${response.statusText}`) + } + +- const data = await response.json() +- return data ++ const data: { event?: Record } = await response.json() ++ if (!data.event) { ++ return null ++ } ++ return this.transformApiEvent(data.event) + } + diff --git a/app/setup/page.tsx b/app/setup/page.tsx index caab40b..08a0795 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -46,6 +46,7 @@ export default function Setup() { fullName: false }) const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) + const [submitError, setSubmitError] = useState(null) const router = useRouter() useEffect(() => { @@ -231,6 +232,7 @@ export default function Setup() { const handleFinishSetup = async (e: React.FormEvent) => { e.preventDefault() setHasAttemptedSubmit(true) + setSubmitError(null) // Validate form and set error states const errors = { @@ -317,7 +319,7 @@ export default function Setup() { } } catch (err: any) { console.error("Failed to create profile:", err) - // TODO: Show error message to user + setSubmitError(err?.message || "Failed to create profile. Please try again.") } finally { setSaving(false) } @@ -415,6 +417,8 @@ export default function Setup() { + + {submitError && {submitError}} @@ -465,6 +469,17 @@ const Title = styled.h1` width: 100%; ` +const ErrorMessage = styled.p` + color: #ff6b6b; + background-color: rgba(255, 107, 107, 0.1); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 0.875rem; + text-align: center; + width: 100%; + margin: 0; +` + const LoadingText = styled.p` color: rgba(255, 255, 255, 0.7); font-size: 1.2rem;