Compare commits
2 Commits
432842ee13
...
0b44e15b50
| Author | SHA1 | Date |
|---|---|---|
|
|
0b44e15b50 | |
|
|
b16b5828a4 |
|
|
@ -131,7 +131,7 @@ export function HeaderMobile({
|
|||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 flex h-[60px] shrink-0 items-center border-b border-white/40 bg-[var(--theme-header-color)] backdrop-blur-sm px-4 shadow-xs"
|
||||
className="sticky top-0 z-20 flex h-[60px] shrink-0 items-center border-b-0 border-white/40 bg-[var(--theme-header-color)] backdrop-blur-sm px-4 shadow-xs"
|
||||
data-name="Header_Mobile"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import { useState } from "react";
|
||||
import { Calendar, ChevronDown, MapPin } from "lucide-react";
|
||||
import type { PhotoWallEventHeaderDisplay, PhotoWallPublicEvent } from "../lib/api";
|
||||
import { formatEventScheduleRange } from "../lib/sessionDisplay";
|
||||
import { EventBanner } from "./EventBanner";
|
||||
import { HeaderMobile } from "./HeaderMobile";
|
||||
|
||||
const EVENT_TYPE_LABEL: Record<string, string> = {
|
||||
conference: "Conference",
|
||||
workshop: "Workshop",
|
||||
summit: "Summit",
|
||||
cme: "CME",
|
||||
webinar: "Webinar",
|
||||
};
|
||||
|
||||
function resolveEventHeader(display?: PhotoWallEventHeaderDisplay | null) {
|
||||
return {
|
||||
showLogo: display?.showLogo !== false,
|
||||
showBanner: display?.showBanner === true,
|
||||
showTitle: display?.showTitle !== false,
|
||||
showDateRange: display?.showDateRange !== false,
|
||||
showEventType: display?.showEventType !== false,
|
||||
showLocationType: display?.showLocationType !== false,
|
||||
showVenue: display?.showVenue !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function formatEventType(t?: string): string {
|
||||
if (!t) return "";
|
||||
return EVENT_TYPE_LABEL[t] || t.replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function formatLocationKind(locationType?: string): string {
|
||||
if (locationType === "virtual") return "Virtual";
|
||||
if (locationType === "hybrid") return "Hybrid";
|
||||
if (locationType === "physical") return "In person";
|
||||
return locationType?.trim() || "";
|
||||
}
|
||||
|
||||
export function PublicEventChrome({
|
||||
event,
|
||||
eventHeader,
|
||||
eventNameFallback = "",
|
||||
eventId = "",
|
||||
}: {
|
||||
event?: PhotoWallPublicEvent | null;
|
||||
eventHeader?: PhotoWallEventHeaderDisplay | null;
|
||||
eventNameFallback?: string;
|
||||
eventId?: string;
|
||||
}) {
|
||||
const [eventMetaOpen, setEventMetaOpen] = useState(false);
|
||||
const flags = resolveEventHeader(eventHeader);
|
||||
const eventName = event?.name?.trim() || eventNameFallback || "Event";
|
||||
const bannerSrc = event?.imageUrl?.trim() || "";
|
||||
const headerLogoUrl = flags.showLogo ? event?.logoUrl?.trim() || null : null;
|
||||
const schedule = event?.startDate
|
||||
? formatEventScheduleRange(
|
||||
typeof event.startDate === "string" ? event.startDate : String(event.startDate),
|
||||
event.endDate
|
||||
? typeof event.endDate === "string"
|
||||
? event.endDate
|
||||
: String(event.endDate)
|
||||
: String(event.startDate),
|
||||
)
|
||||
: "";
|
||||
const eventSubtitle = event?.subtitle?.trim() || "";
|
||||
const eventLocation = event?.location?.trim() || "";
|
||||
const eventTypeLabel = formatEventType(event?.eventType);
|
||||
const locationKind = formatLocationKind(event?.locationType);
|
||||
const showDate = flags.showDateRange && Boolean(schedule);
|
||||
const showType = flags.showEventType && Boolean(eventTypeLabel);
|
||||
const showLocationKind = flags.showLocationType && Boolean(locationKind);
|
||||
const showVenue = flags.showVenue && Boolean(eventLocation);
|
||||
const hasCollapsibleEventMeta = Boolean(eventSubtitle || showLocationKind || showVenue);
|
||||
const showEventInfoCard = flags.showTitle || showDate || showType || hasCollapsibleEventMeta;
|
||||
|
||||
return (
|
||||
<>
|
||||
{flags.showLogo ? (
|
||||
<HeaderMobile
|
||||
logoUrl={headerLogoUrl}
|
||||
eventName={eventName}
|
||||
eventId={eventId || null}
|
||||
showBack={false}
|
||||
showRewardsAndLogout={false}
|
||||
/>
|
||||
) : null}
|
||||
{flags.showBanner ? (
|
||||
<EventBanner imageUrl={bannerSrc} alt={eventName} className="shadow-sm" />
|
||||
) : null}
|
||||
{showEventInfoCard ? (
|
||||
<div className="w-full rounded-b-lg card-bg p-4 shadow-md">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{showType ? (
|
||||
<div className="mb-2 flex items-center text-xs">
|
||||
<span className="rounded bg-[var(--theme-button-color)]/20 px-2 py-0.5">{eventTypeLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{flags.showTitle ? <h1 className="text-lg font-semibold">{eventName}</h1> : null}
|
||||
{showDate ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs text-[var(--theme-card-text-color)]/95 ${flags.showTitle ? "mt-1.5" : ""}`}
|
||||
>
|
||||
<Calendar className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||||
<span>{schedule}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{hasCollapsibleEventMeta ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventMetaOpen((o) => !o)}
|
||||
aria-expanded={eventMetaOpen}
|
||||
aria-label={eventMetaOpen ? "Hide event details" : "Show event details"}
|
||||
className="inline-flex shrink-0 items-center rounded-md p-0.5 bg-[var(--theme-button-color)]/5 text-[var(--theme-card-text-color)] hover:bg-[var(--theme-button-color)]/15"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-5 transition-transform ${eventMetaOpen ? "rotate-180" : ""}`}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{eventMetaOpen && hasCollapsibleEventMeta ? (
|
||||
<div className="mt-3 w-full border-t border-[var(--theme-card-text-color)]/5 pt-3 text-left text-sm text-[var(--theme-card-text-color)]/95">
|
||||
{eventSubtitle ? (
|
||||
<p className="whitespace-pre-wrap leading-5">{eventSubtitle}</p>
|
||||
) : null}
|
||||
<div className={`${eventSubtitle ? "mt-3" : ""} flex flex-col gap-2 text-xs`}>
|
||||
{showLocationKind ? (
|
||||
<div className="flex items-center gap-2 font-semibold uppercase">
|
||||
<span>{locationKind}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{showVenue ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="mt-0.5 size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||||
<span>{eventLocation}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Facebook, Instagram, Linkedin, Share2 } from "lucide-react";
|
||||
import type { PhotoWallSharePlatforms } from "../lib/api";
|
||||
|
||||
type SharePlatformIconProps = {
|
||||
platform: keyof PhotoWallSharePlatforms;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function BrandSvg({
|
||||
className = "h-4 w-4",
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
aria-label={label}
|
||||
className={`shrink-0 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrandSvg className={className} label="X">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.74l7.73-8.835L1.254 2.25H8.08l4.253 5.622L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z" />
|
||||
</BrandSvg>
|
||||
);
|
||||
}
|
||||
|
||||
function WhatsAppIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrandSvg className={className} label="WhatsApp">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z" />
|
||||
</BrandSvg>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapchatIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<BrandSvg className={className} label="Snapchat">
|
||||
<path d="M12.165.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.323 4.862l-.003.032c-.013.253-.026.508-.026.772 0 .223.026.332.084.448.144.286.568.549 1.056.791.394.196.862.429 1.074.687.297.334.32.72.183 1.081-.22.589-.87.867-1.417 1.035-.12.036-.229.07-.302.116-.047.03-.058.049-.058.08 0 .075.056.156.148.26.368.42.922 1.027 1.946 1.027.139 0 .286-.008.442-.022 1.021-.09 1.378.499 1.485.743.107.256.107.563-.107.79-.427.45-1.741.854-4.347 1.145-.047.13-.103.374-.138.598-.047.3-.093.59-.186.896-.108.353-.236.428-.48.428-.139 0-.313-.04-.534-.09-.49-.11-1.164-.262-2.14-.262-.388 0-.79.047-1.18.14-.39.094-.78.14-1.16.14-.38 0-.77-.046-1.16-.14-.39-.093-.792-.14-1.18-.14-.976 0-1.65.152-2.14.262-.221.05-.395.09-.534.09-.244 0-.372-.075-.48-.428-.093-.306-.139-.596-.186-.896-.035-.224-.09-.468-.138-.598-2.606-.291-3.92-.695-4.347-1.145-.214-.227-.214-.534-.107-.79.107-.244.464-.833 1.485-.743.156.014.303.022.442.022 1.024 0 1.578-.607 1.946-1.027.092-.104.148-.185.148-.26 0-.031-.011-.05-.058-.08-.073-.046-.182-.08-.302-.116-.547-.168-1.197-.446-1.417-1.035-.137-.361-.114-.747.183-1.081.212-.258.68-.491 1.074-.687.488-.242.912-.505 1.056-.791.058-.116.084-.225.084-.448 0-.264-.013-.519-.026-.772l-.003-.032c-.08-1.643-.206-3.669.323-4.862C7.406 1.069 10.763.793 11.753.793h.412z" />
|
||||
</BrandSvg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Platform mark for share buttons. Lucide covers LinkedIn/Facebook/Instagram; X, WhatsApp, Snapchat are custom SVGs. */
|
||||
export function SharePlatformIcon({ platform, className = "size-5" }: SharePlatformIconProps) {
|
||||
if (platform === "linkedin") return <Linkedin className={className} />;
|
||||
if (platform === "facebook") return <Facebook className={className} />;
|
||||
if (platform === "instagram") return <Instagram className={className} />;
|
||||
if (platform === "x") return <XIcon className={className} />;
|
||||
if (platform === "whatsapp") return <WhatsAppIcon className={className} />;
|
||||
if (platform === "snapchat") return <SnapchatIcon className={className} />;
|
||||
return <Share2 className={className} />;
|
||||
}
|
||||
|
|
@ -33,6 +33,9 @@ export function ThemeGate({ ready, children }: ThemeGateProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Cache hit / already themed: overlay was never shown, so skip the hold+fade.
|
||||
if (!overlayVisible) return;
|
||||
|
||||
const elapsed = Date.now() - shownAtRef.current;
|
||||
const holdMs = Math.max(0, MIN_VISIBLE_MS - elapsed);
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ export function ThemeGate({ ready, children }: ThemeGateProps) {
|
|||
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
|
||||
if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current);
|
||||
};
|
||||
}, [ready]);
|
||||
}, [ready, overlayVisible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,30 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { getPublicVisitorTheme } from "../lib/api";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
getAiAvatarMeta,
|
||||
getPhotoSearchMeta,
|
||||
getPhotoWallPresent,
|
||||
getPhotoWallUploadMeta,
|
||||
getPublicVisitorTheme,
|
||||
getSocialShareMeta,
|
||||
} from "../lib/api";
|
||||
import { applyEventTheme } from "../theme/eventTheme";
|
||||
import { readCachedVisitorTheme, writeCachedVisitorTheme } from "../theme/visitorThemeCache";
|
||||
import { readStoredVisitorContext } from "../types/visitorContext";
|
||||
|
||||
function resolveEventIdFromSearch(searchParams: URLSearchParams): string {
|
||||
type PublicTokenSource =
|
||||
| "ai-avatar"
|
||||
| "photo-search"
|
||||
| "social-share"
|
||||
| "photo-wall"
|
||||
| "photo-wall-present";
|
||||
|
||||
type ThemeLookup =
|
||||
| { kind: "none" }
|
||||
| { kind: "eventId"; eventId: string }
|
||||
| { kind: "token"; source: PublicTokenSource; token: string };
|
||||
|
||||
function eventIdFromQuery(searchParams: URLSearchParams): string {
|
||||
const fromEventId = searchParams.get("event_id")?.trim() || "";
|
||||
if (fromEventId) return fromEventId;
|
||||
const fromEventIdCamel = searchParams.get("eventId")?.trim() || "";
|
||||
|
|
@ -12,11 +32,48 @@ function resolveEventIdFromSearch(searchParams: URLSearchParams): string {
|
|||
const type = searchParams.get("type");
|
||||
const id = searchParams.get("id")?.trim() || "";
|
||||
if (type === "event" && id) return id;
|
||||
return readStoredVisitorContext()?.eventId || "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveThemeLookup(pathname: string, searchParams: URLSearchParams): ThemeLookup {
|
||||
const fromQuery = eventIdFromQuery(searchParams);
|
||||
if (fromQuery) return { kind: "eventId", eventId: fromQuery };
|
||||
|
||||
const tokenRoutes: Array<{ re: RegExp; source: PublicTokenSource }> = [
|
||||
{ re: /^\/ai-avatar\/([^/]+)/, source: "ai-avatar" },
|
||||
{ re: /^\/photo-search\/([^/]+)/, source: "photo-search" },
|
||||
{ re: /^\/social-share\/([^/]+)/, source: "social-share" },
|
||||
{ re: /^\/photo-wall-present\/([^/]+)/, source: "photo-wall-present" },
|
||||
{ re: /^\/photo-wall\/([^/]+)/, source: "photo-wall" },
|
||||
];
|
||||
for (const { re, source } of tokenRoutes) {
|
||||
const match = pathname.match(re);
|
||||
const token = match?.[1] ? decodeURIComponent(match[1]).trim() : "";
|
||||
if (token) return { kind: "token", source, token };
|
||||
}
|
||||
|
||||
const stored = readStoredVisitorContext()?.eventId?.trim() || "";
|
||||
if (stored) return { kind: "eventId", eventId: stored };
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
async function eventIdFromPublicToken(source: PublicTokenSource, token: string): Promise<string> {
|
||||
if (source === "ai-avatar") return (await getAiAvatarMeta(token)).eventId;
|
||||
if (source === "photo-search") return (await getPhotoSearchMeta(token)).eventId;
|
||||
if (source === "social-share") return (await getSocialShareMeta(token)).eventId;
|
||||
if (source === "photo-wall") return (await getPhotoWallUploadMeta(token)).eventId;
|
||||
return (await getPhotoWallPresent(token)).eventId;
|
||||
}
|
||||
|
||||
function applyCachedTheme(eventId: string): boolean {
|
||||
const cached = readCachedVisitorTheme(eventId);
|
||||
if (!cached) return false;
|
||||
applyEventTheme(cached.theme);
|
||||
return true;
|
||||
}
|
||||
|
||||
export type UseEventThemeResult = {
|
||||
/** Event id used for the public theme request (empty when none). */
|
||||
/** Event id used for the public theme request (empty when none / still resolving). */
|
||||
eventId: string;
|
||||
/**
|
||||
* True when either there is no event to theme, or the theme request has
|
||||
|
|
@ -26,39 +83,96 @@ export type UseEventThemeResult = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Fetches GET /api/public/visitor-theme/:eventId (from URL / stored context)
|
||||
* and applies CSS variables to `<html>`.
|
||||
* Loads GET /api/public/visitor-theme/:eventId once per event and caches it.
|
||||
* In-app navigation for the same event reuses the cache (no loader / no refetch).
|
||||
* Activity token routes resolve event id first, then use the same cache.
|
||||
*/
|
||||
export function useEventTheme(): UseEventThemeResult {
|
||||
const [searchParams] = useSearchParams();
|
||||
const eventId = resolveEventIdFromSearch(searchParams);
|
||||
const [ready, setReady] = useState(!eventId);
|
||||
const { pathname } = useLocation();
|
||||
const lookup = useMemo(
|
||||
() => resolveThemeLookup(pathname, searchParams),
|
||||
[pathname, searchParams],
|
||||
);
|
||||
const kind = lookup.kind;
|
||||
const lookupEventId = lookup.kind === "eventId" ? lookup.eventId : "";
|
||||
const tokenSource = lookup.kind === "token" ? lookup.source : "";
|
||||
const token = lookup.kind === "token" ? lookup.token : "";
|
||||
|
||||
const [ready, setReady] = useState(() => {
|
||||
if (kind === "none") return true;
|
||||
if (kind === "eventId") return applyCachedTheme(lookupEventId);
|
||||
const cached = readCachedVisitorTheme();
|
||||
if (!cached) return false;
|
||||
applyEventTheme(cached.theme);
|
||||
return true;
|
||||
});
|
||||
const [resolvedEventId, setResolvedEventId] = useState(lookupEventId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventId) {
|
||||
if (kind === "none") {
|
||||
setResolvedEventId("");
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
|
||||
getPublicVisitorTheme(eventId)
|
||||
.then((data) => {
|
||||
if (kind === "eventId") {
|
||||
setResolvedEventId(lookupEventId);
|
||||
if (applyCachedTheme(lookupEventId)) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
setReady(false);
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await getPublicVisitorTheme(lookupEventId);
|
||||
if (cancelled) return;
|
||||
writeCachedVisitorTheme(data.eventId || lookupEventId, data.theme);
|
||||
applyEventTheme(data.theme);
|
||||
} catch {
|
||||
/* keep defaults / previous theme */
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Token activity route: resolve event id, then cache or fetch theme.
|
||||
// Do not flip ready off until we know this event is not cached.
|
||||
void (async () => {
|
||||
try {
|
||||
const eventId = await eventIdFromPublicToken(tokenSource as PublicTokenSource, token);
|
||||
if (cancelled) return;
|
||||
setResolvedEventId(eventId || "");
|
||||
if (!eventId) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
if (applyCachedTheme(eventId)) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
setReady(false);
|
||||
const data = await getPublicVisitorTheme(eventId);
|
||||
if (cancelled) return;
|
||||
writeCachedVisitorTheme(data.eventId || eventId, data.theme);
|
||||
applyEventTheme(data.theme);
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// Still unblock UI; keep defaults / any previously applied theme.
|
||||
setReady(true);
|
||||
});
|
||||
} catch {
|
||||
/* keep defaults / previous theme */
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [eventId]);
|
||||
}, [kind, lookupEventId, tokenSource, token]);
|
||||
|
||||
return { eventId, ready };
|
||||
return { eventId: resolvedEventId, ready };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,15 @@ import {
|
|||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Loader2,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { HeaderMobile } from "../components/HeaderMobile";
|
||||
import { MobileShell } from "../components/MobileShell";
|
||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||
import {
|
||||
getAiAvatarMeta,
|
||||
getVisitorLoginPreview,
|
||||
|
|
@ -42,13 +41,29 @@ import {
|
|||
type EbVisitorContext,
|
||||
} from "../types/visitorContext";
|
||||
|
||||
function WhiteCard({ children }: { children: ReactNode }) {
|
||||
function AiAvatarLayout({
|
||||
children,
|
||||
meta,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
meta?: AiAvatarMeta | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||||
<div className="w-full rounded-xl shadow-md">
|
||||
<div className="rounded-xl bg-whit card-bg px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MobileShell className="relative bg-transparent">
|
||||
{meta ? (
|
||||
<PublicEventChrome
|
||||
event={meta.event}
|
||||
eventHeader={meta.eventHeader}
|
||||
eventNameFallback={meta.eventName}
|
||||
eventId={meta.eventId}
|
||||
/>
|
||||
) : (
|
||||
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
|
||||
)}
|
||||
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
|
||||
<div className="flex w-full max-w-none flex-col px-4">{children}</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -85,13 +100,6 @@ const GENERATING_PIPELINE_MESSAGES = [
|
|||
"Almost there — finishing your avatar…",
|
||||
];
|
||||
|
||||
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
||||
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
||||
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
||||
return <Share2 className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function AiAvatarPage() {
|
||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -255,7 +263,7 @@ export function AiAvatarPage() {
|
|||
});
|
||||
}
|
||||
} catch (e2) {
|
||||
setErr(e2 instanceof Error ? e2.message : "Could not create avatar");
|
||||
setErr(e2 instanceof Error ? e2.message : "Could not generate avatar");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -365,31 +373,25 @@ export function AiAvatarPage() {
|
|||
|
||||
if (!uploadToken) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<AiAvatarLayout>
|
||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||
</AiAvatarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<AiAvatarLayout>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
|
||||
</AiAvatarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !meta) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<AiAvatarLayout>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</AiAvatarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -398,22 +400,13 @@ export function AiAvatarPage() {
|
|||
const total = GENERATING_PIPELINE_MESSAGES.length;
|
||||
const progressPct = Math.round((step / total) * 100);
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="AI Avatar"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<AiAvatarLayout meta={meta}>
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--theme-button-color)]/10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--theme-button-color)]" aria-hidden />
|
||||
</div>
|
||||
<h1 className="mb-2 text-[22px] font-semibold text-[var(--theme-card-text-color)]">Creating your avatar</h1>
|
||||
<h2 className="mb-2 text-[22px] font-semibold text-[var(--theme-card-text-color)]">Generating your avatar</h2>
|
||||
<p
|
||||
key={pipelineMsgIndex}
|
||||
className="mb-5 min-h-[2.5rem] text-sm text-[var(--theme-card-text-color)]/80 transition-opacity duration-300"
|
||||
|
|
@ -429,25 +422,16 @@ export function AiAvatarPage() {
|
|||
</div>
|
||||
<p className="text-xs text-[var(--theme-card-text-color)]/60">This can take up to a minute — hang tight</p>
|
||||
</div>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
</section>
|
||||
</AiAvatarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="AI Avatar"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="mb-2 text-center text-[22px] font-semibold">Your avatar</h1>
|
||||
<AiAvatarLayout meta={meta}>
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||
<h2 className="mb-2 text-center text-[22px] font-semibold">Your avatar</h2>
|
||||
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||
Here's your AI avatar. You can download or share it.
|
||||
</p>
|
||||
|
|
@ -479,7 +463,7 @@ export function AiAvatarPage() {
|
|||
onClick={resetForNew}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white"
|
||||
>
|
||||
Create another
|
||||
Generate another
|
||||
</button>
|
||||
{allowDownload ? (
|
||||
<button
|
||||
|
|
@ -487,7 +471,7 @@ export function AiAvatarPage() {
|
|||
onClick={() => void onDownload()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<Download className="size-5" />
|
||||
Download photo
|
||||
</button>
|
||||
) : null}
|
||||
|
|
@ -503,11 +487,11 @@ export function AiAvatarPage() {
|
|||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
<Share2 className="size-5" />
|
||||
Share
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
|
|
@ -515,8 +499,8 @@ export function AiAvatarPage() {
|
|||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
<SharePlatformIcon platform={item.key} />
|
||||
{/* {item.label} */}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -524,27 +508,17 @@ export function AiAvatarPage() {
|
|||
</div>
|
||||
) : null}
|
||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
</section>
|
||||
</AiAvatarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="AI Avatar"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AiAvatarLayout meta={meta}>
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">AI Avatar</h1>
|
||||
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">AI Avatar</h2>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create your AI avatar.</p>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -556,7 +530,7 @@ export function AiAvatarPage() {
|
|||
</div>
|
||||
) : !selectedAvatar ? (
|
||||
<div className="space-y-4">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Choose an avatar</h1>
|
||||
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Choose an avatar</h2>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick an avatar, then upload your selfie.</p>
|
||||
{!meta?.avatars?.length ? (
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No avatars available yet.</p>
|
||||
|
|
@ -567,7 +541,7 @@ export function AiAvatarPage() {
|
|||
key={avatar._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedAvatar(avatar)}
|
||||
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/50"
|
||||
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/30"
|
||||
>
|
||||
<div className="aspect-square w-full">
|
||||
{avatar.imageUrl ? (
|
||||
|
|
@ -592,7 +566,7 @@ export function AiAvatarPage() {
|
|||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-4 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -600,11 +574,11 @@ export function AiAvatarPage() {
|
|||
clearSelectedPhoto();
|
||||
setErr(null);
|
||||
}}
|
||||
className="text-sm font-semibold theme-btn-link"
|
||||
className="text-sm font-semibold text-[var(--theme-card-text-color)]"
|
||||
>
|
||||
← Change avatar
|
||||
</button>
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload selfie</h1>
|
||||
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload selfie</h2>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||
Avatar: <span className="font-semibold">{selectedAvatar.name}</span>
|
||||
</p>
|
||||
|
|
@ -634,14 +608,14 @@ export function AiAvatarPage() {
|
|||
type="button"
|
||||
disabled={!file || submitting}
|
||||
onClick={() => void onSubmit()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
|
||||
>
|
||||
{submitting ? "Creating avatar…" : "Create avatar"}
|
||||
{submitting ? "Generating avatar…" : "Generate avatar"}
|
||||
</button>
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</WhiteCard>
|
||||
</section>
|
||||
|
||||
{cropSession && cropTarget ? (
|
||||
<PhotoWallCropModal
|
||||
|
|
@ -659,6 +633,6 @@ export function AiAvatarPage() {
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AuthShell>
|
||||
</AiAvatarLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Calendar, MapPin } from "lucide-react";
|
||||
import { Calendar, ChevronDown, MapPin } from "lucide-react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
getVisitorEventAgenda,
|
||||
|
|
@ -83,6 +83,7 @@ export function EventLandingPage() {
|
|||
}, [eventId]);
|
||||
|
||||
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
|
||||
const [eventMetaOpen, setEventMetaOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDayOpen({});
|
||||
|
|
@ -118,6 +119,9 @@ export function EventLandingPage() {
|
|||
const ratingsHref = scopedQs ? `/ratings?${scopedQs}` : "/ratings";
|
||||
|
||||
const bannerSrc = event?.bannerImage?.trim() || event?.image?.trim() || "";
|
||||
const eventDescription = event?.description?.trim() || event?.subtitle?.trim() || "";
|
||||
const eventLocation = event?.location?.trim() || "";
|
||||
const hasExpandableEventMeta = Boolean(eventDescription || schedule || eventLocation);
|
||||
|
||||
if (!eventId) {
|
||||
return (
|
||||
|
|
@ -147,43 +151,67 @@ export function EventLandingPage() {
|
|||
return (
|
||||
<MobileShell className="relative bg-transparent">
|
||||
<HeaderMobile logoUrl={event.logoUrl} eventId={eventId || null} showBack={false} />
|
||||
<EventBanner imageUrl={bannerSrc} alt={event.name} className="rounded-b-lg shadow-sm" />
|
||||
<EventBanner imageUrl={bannerSrc} alt={event.name} className="shadow-sm" />
|
||||
|
||||
<div className="w-full rounded-b-lg card-bg p-4 shadow-md">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h1 className="min-w-0 flex-1 text-lg font-semibold">{event.name}</h1>
|
||||
{hasExpandableEventMeta ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEventMetaOpen((o) => !o)}
|
||||
aria-expanded={eventMetaOpen}
|
||||
aria-label={
|
||||
eventMetaOpen
|
||||
? "Hide event description, date, and location"
|
||||
: "Show event description, date, and location"
|
||||
}
|
||||
className="inline-flex shrink-0 items-center rounded-md p-0.5 bg-[var(--theme-button-color)]/5 text-[var(--theme-card-text-color)] hover:bg-[var(--theme-button-color)]/15"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-5 transition-transform ${eventMetaOpen ? "rotate-180" : ""}`}
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<EventRatingBadge ratings={event.ratings} />
|
||||
{reg.enableRatings ? (
|
||||
<Link to={ratingsHref} className="font-semibold uppercase tracking-wide theme-btn-link">
|
||||
rate now
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-[var(--theme-card-text-color)]/50">Ratings off</span>
|
||||
)}
|
||||
</div>
|
||||
{eventMetaOpen && hasExpandableEventMeta ? (
|
||||
<div className="mt-3 w-full border-t border-[var(--theme-card-text-color)]/5 pt-3 text-left text-sm text-[var(--theme-card-text-color)]/95">
|
||||
{eventDescription ? (
|
||||
<p className="whitespace-pre-wrap leading-5">{eventDescription}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-2 text-xs">
|
||||
{schedule ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||||
<span>{schedule}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{eventLocation ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPin className="mt-0.5 size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||||
<span>{eventLocation}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
|
||||
<div className="flex w-full max-w-none flex-col gap-6 px-4">
|
||||
<section className="w-full">
|
||||
<div className="w-full rounded-lg card-bg p-4 shadow-md">
|
||||
<h1 className="text-lg font-semibold">{event.name}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<EventRatingBadge ratings={event.ratings} />
|
||||
{reg.enableRatings ? (
|
||||
<Link to={ratingsHref} className="font-semibold uppercase tracking-wide theme-btn-link">
|
||||
rate now
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-[var(--theme-card-text-color)]/50">Ratings off</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-5 text-[var(--theme-card-text-color)]">
|
||||
{event.description?.trim() || event.subtitle?.trim() || "Welcome to this event."}
|
||||
</p>
|
||||
{/* <button type="button" className="mt-2 text-xs font-semibold uppercase tracking-wide text-[#1e72e theme-btn-link">
|
||||
Read More
|
||||
</button> */}
|
||||
<div className="mt-4 flex flex-col gap-2 border-t border-[var(--theme-card-text-color)]/5 pt-4 text-xs text-[var(--theme-card-text-color)]/95">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||||
<span className="">{schedule || "—"}</span>
|
||||
</div>
|
||||
{event.location?.trim() ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 shrink-0 text-[var(--theme-button-color)]" strokeWidth={1.75} />
|
||||
<span className="">{event.location.trim()}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{bookmarkedIds.size > 0 ? (
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-4">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Award } from "lucide-react";
|
||||
import { Award, User } from "lucide-react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { getVisitorEventLeaderboard } from "../lib/api";
|
||||
import { getAttendeeToken } from "../lib/attendeeAuth";
|
||||
|
|
@ -11,10 +11,10 @@ import type { VisitorLeaderboardRow } from "../lib/api";
|
|||
function Medal({ tier }: { tier: "gold" | "silver" | "bronze" }) {
|
||||
const cls =
|
||||
tier === "gold"
|
||||
? "text-amber-500"
|
||||
? "text-amber-400"
|
||||
: tier === "silver"
|
||||
? "text-slate-400"
|
||||
: "text-amber-800";
|
||||
: "text-rose-400";
|
||||
return <Award className={`size-6 ${cls}`} strokeWidth={1.5} />;
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ export function LeaderboardPage() {
|
|||
</div> */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="mt-1 text-lg font-semibold">Your Rank</h1>
|
||||
<span className="rounded-full bg-green-100 text-green-700 px-3 py-1 text-sm inline-block w-fit">
|
||||
<span className="rounded-lg bg-green-100 text-green-700 px-2 py-1 text-sm font-semibold inline-block w-fit">
|
||||
{!signedIn ? "Sign in" : loading ? "…" : yourTotal != null ? `${yourTotal} points` : "0 points"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -137,15 +137,31 @@ export function LeaderboardPage() {
|
|||
{top5.map((row) => {
|
||||
const medal = medalForRank(row.rank);
|
||||
return (
|
||||
// <li
|
||||
// key={row.participantId}
|
||||
// className="flex items-center gap-3 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
|
||||
// >
|
||||
// <span className="shrink-0 rounded bg-green-100 text-green-700 px-3 py-2 text-xs font-medium shadow-sm">
|
||||
// #{row.rank} · {row.total} pts
|
||||
// </span>
|
||||
// <span className="min-w-0 flex-1 text-sm">{row.displayName}</span>
|
||||
// {medal ? <Medal tier={medal} /> : null}
|
||||
// </li>
|
||||
<li
|
||||
key={row.participantId}
|
||||
className="flex items-center gap-3 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
|
||||
className="flex items-center gap-3 border-b border-[var(--theme-card-text-color)]/5 pb-3 pr-6 last:border-0 relative"
|
||||
>
|
||||
<span className="shrink-0 rounded bg-green-100 text-green-700 px-3 py-2 text-xs font-medium shadow-sm">
|
||||
#{row.rank} · {row.total} pts
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-sm">{row.displayName}</span>
|
||||
{medal ? <Medal tier={medal} /> : null}
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-[var(--theme-button-color)]/10">
|
||||
<User className="size-5 text-[var(theme-card-text-color)]/20" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 grow-1">
|
||||
<span className="min-w-0 flex-1 text-sm font-semibold">{row.displayName}</span>
|
||||
<span className="shrink-0 text-green-500 text-xs">
|
||||
#{row.rank} <span className="text-[var(--theme-card-text-color)]/70 w-[4px] h-[4px] rounded-full inline-block mx-1">·</span> {row.total} pts
|
||||
</span>
|
||||
</div>
|
||||
{/* <span className="min-w-0 flex-1 text-sm">{row.displayName}</span> */}
|
||||
{medal ? <span className="absolute right-0 top-0"><Medal tier={medal} /></span> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -221,8 +221,8 @@ export function LoginPage() {
|
|||
|
||||
if (!ctx) {
|
||||
return (
|
||||
<AuthShell>
|
||||
<div className="max-w-md px-4 text-center text-sm text-white/95">
|
||||
<AuthShell showLogo={false}>
|
||||
<div className="max-w-md px-4 text-center card-bg rounded-lg shadow-md text-sm">
|
||||
<p>Open this app from your event link or QR code to sign in.</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
|
@ -237,12 +237,12 @@ export function LoginPage() {
|
|||
const showInviteField = reg.registrationMode === "Invite Only";
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthShell showLogo={false}>
|
||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||||
<div className="rounded-xl card-bg">
|
||||
<div className="w-full">
|
||||
<div className="rounded-xl card-bg shadow-md">
|
||||
<div className="px-6 pb-2 pt-6">
|
||||
<div className="flex h-[103px] items-center justify-center rounded-xl border border-[var(--theme-card-color)]/25 px-4">
|
||||
<div className="flex h-[103px] items-center justify-center px-4">
|
||||
{preview?.logoUrl ? (
|
||||
<img
|
||||
src={preview.logoUrl}
|
||||
|
|
@ -256,10 +256,11 @@ export function LoginPage() {
|
|||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-8 px-6 pb-8 pt-2">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<p className="text-base font-semibold text-[var(--theme-card-text-color)]">Log in to</p>
|
||||
<p className="text-base font-semibold text-[var(--theme-card-text-color)]/60">Log in to</p>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-[28px] font-bold leading-tight text-[var(--theme-card-text-color)] sm:text-[32px]">
|
||||
<p className="text-xl font-bold leading-tight text-[var(--theme-card-text-color)] sm:text-2xl">
|
||||
{headline}
|
||||
{/* Novel Lung Health Summit 2026 Kochi 22nd & 23rd August 2026 */}
|
||||
</p>
|
||||
{preview?.subtitle ? (
|
||||
<p className="text-sm font-medium text-[var(--theme-card-text-color)]/80">{preview.subtitle}</p>
|
||||
|
|
@ -276,21 +277,21 @@ export function LoginPage() {
|
|||
</p>
|
||||
) : null}
|
||||
{showMeta ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-[var(--theme-card-text-color)]">
|
||||
<div className="flex flex-col items-start justify-center gap-2 text-xs text-[var(--theme-card-text-color)] border border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/3 rounded-lg p-2">
|
||||
{schedule ? (
|
||||
<>
|
||||
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="size-4 shrink-0 text-[var(--theme-card-text-color)]/70" strokeWidth={1.75} />
|
||||
<span className="">{schedule}</span>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
{schedule && preview?.location?.trim() ? (
|
||||
<span className="hidden h-3 w-px bg-[rgba(36,40,47,0.5)] sm:inline" aria-hidden />
|
||||
<span className="hidden h-3 w-px bg-[var(--theme-card-color)]/50] sm:inline" aria-hidden />
|
||||
) : null}
|
||||
{preview?.location?.trim() ? (
|
||||
<>
|
||||
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
|
||||
<span className="">{preview.location.trim()}</span>
|
||||
</>
|
||||
<div className="flex gap-2">
|
||||
<MapPin className="size-4 shrink-0 text-[var(--theme-card-text-color)]/70" strokeWidth={1.75} />
|
||||
<span className="text-left">{preview.location.trim()}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -303,7 +304,7 @@ export function LoginPage() {
|
|||
value={inviteInput}
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
placeholder="Enter your invite code"
|
||||
className="h-[44px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 text-sm text-[var(--theme-card-text-color)] transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
|
||||
className="h-[44px] w-full rounded-lg border border-[var(--theme-card-text-color)]/10 bg-[var(--theme-card-color)]/40 px-4 text-sm text-[var(--theme-card-text-color)] placeholder:text-[var(--theme-card-text-color)]/60 transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,14 @@ import {
|
|||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Search,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import { HeaderMobile } from "../components/HeaderMobile";
|
||||
import { MobileShell } from "../components/MobileShell";
|
||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||
import {
|
||||
getPhotoSearchMeta,
|
||||
getVisitorLoginPreview,
|
||||
|
|
@ -40,13 +39,12 @@ import {
|
|||
type EbVisitorContext,
|
||||
} from "../types/visitorContext";
|
||||
|
||||
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
|
||||
function PhotoSearchShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className={`flex w-full ${wide ? "max-w-[480px]" : "max-w-[354px]"} flex-col items-center`}>
|
||||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||||
<div className="rounded-xl card-bg text-[var(--theme-card-text-color)] shadow-md px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MobileShell className="relative bg-transparent">
|
||||
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
|
||||
{children}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -59,62 +57,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
|||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
];
|
||||
|
||||
function ShareIcon({
|
||||
platform,
|
||||
className = "h-4 w-4",
|
||||
}: {
|
||||
platform: keyof PhotoWallSharePlatforms;
|
||||
className?: string;
|
||||
}) {
|
||||
if (platform === "linkedin") return <Linkedin className={className} />;
|
||||
if (platform === "facebook") return <Facebook className={className} />;
|
||||
if (platform === "instagram") return <Instagram className={className} />;
|
||||
if (platform === "x") {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.253 5.622L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (platform === "whatsapp") {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (platform === "snapchat") {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
|
||||
<path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.322 4.847a3.753 3.753 0 002.3.718c.386 0 .829-.119.829-.538 0-.364.214-1.28.917-1.28.646 0 1.239.685.839 1.961-.165.456.428 1.47 1.56 1.47.646 0 .839.321.839.538 0 .718-1.239 2.049-4.026 2.119.373 2.627 2.362 3.858 3.687 4.097.272.047.53.315.53.639 0 .593-.61.793-1.239.793-.214 0-.646-.047-1.3-.047-.646 0-1.364.119-2.013.538-1.239.793-2.666 2.134-5.287 2.134-2.7 0-4.026-1.341-5.287-2.134-.646-.419-1.364-.538-2.013-.538-.654 0-1.086.047-1.3.047-.629 0-1.239-.2-1.239-.793 0-.324.258-.592.53-.639 1.325-.239 3.314-1.47 3.687-4.097-2.787-.07-4.026-1.401-4.026-2.119 0-.217.193-.538.839-.538 1.132 0 1.725-1.014 1.56-1.47-.4-1.276.193-1.961.839-1.961.703 0 .917.916.917 1.28 0 .419.443.538.829.538a3.753 3.753 0 002.3-.718c-.081-1.628-.207-3.654.322-4.847C7.859 1.069 11.216.793 12.206.793z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return <Share2 className={className} />;
|
||||
}
|
||||
|
||||
function IconActionButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#ddd] bg-white text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchCard({
|
||||
match,
|
||||
eventName,
|
||||
|
|
@ -218,63 +160,57 @@ function MatchCard({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]${shareMenuOpen ? " relative z-20" : ""}`}>
|
||||
<div className="overflow-hidden rounded-t-lg bg-white">
|
||||
<img
|
||||
ref={imgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={previewSrc || undefined}
|
||||
alt={match.originalFileName || "Matched photo"}
|
||||
className="mx-auto max-h-[220px] w-full object-contain bg-white"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
// Retry without CORS for CDN; do not switch to API file proxy for display.
|
||||
if (img.crossOrigin) {
|
||||
img.crossOrigin = null;
|
||||
if (previewSrc) img.src = previewSrc;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div ref={shareMenuRef} className="flex flex-nowrap items-center justify-center gap-1.5">
|
||||
{allowDownload ? (
|
||||
<IconActionButton label="Download" onClick={() => void onDownload()}>
|
||||
<Download className="h-4 w-4" />
|
||||
</IconActionButton>
|
||||
) : null}
|
||||
{showShare ? (
|
||||
<div className="relative">
|
||||
<IconActionButton
|
||||
label="Share"
|
||||
onClick={() => {
|
||||
if (useNativeShare) {
|
||||
void onNativeShare();
|
||||
return;
|
||||
}
|
||||
setShareMenuOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</IconActionButton>
|
||||
{showShare && !useNativeShare && shareMenuOpen ? (
|
||||
<div className="absolute right-0 top-full z-30 mt-1 min-w-[11.5rem] divide-y divide-[#f0f0f0] overflow-hidden rounded-lg border border-[#e5e5e5] bg-white shadow-[0_8px_24px_rgba(0,0,0,0.16)]">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold text-[#333] hover:bg-[#f7f7f7]"
|
||||
>
|
||||
<ShareIcon platform={item.key} className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/10 relative">
|
||||
<img
|
||||
ref={imgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={previewSrc || undefined}
|
||||
alt={match.originalFileName || "Matched photo"}
|
||||
className="mx-auto max-h-[220px] w-full object-contain bg-white"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
// Retry without CORS for CDN; do not switch to API file proxy for display.
|
||||
if (img.crossOrigin) {
|
||||
img.crossOrigin = null;
|
||||
if (previewSrc) img.src = previewSrc;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-end p-2 gap-3 rounded-lg absolute right-[5px] bottom-[5px] bg-[var(--theme-card-color)]/85 backdrop-blur-xs">
|
||||
{allowDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDownload()}
|
||||
className="inline-flex h-[34px] w-[34px] items-center justify-center rounded-lg border-0 border-[var(--theme-card-text-color)]/45 bg-[var(--theme-button-color)] text-xs font-semibold text-[var(--theme-card-text-color)]"
|
||||
>
|
||||
<Download className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
{showShare ? (
|
||||
useNativeShare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-[34px] w-[34px] items-center justify-center rounded-lg border-0 border-[var(--theme-card-text-color)]/45 bg-[var(--theme-card-color)]/80 text-xs font-semibold text-[var(--theme-card-text-color)]"
|
||||
>
|
||||
<Share2 className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-[34px] w-[34px] items-center justify-center rounded-lg border-0 border-[var(--theme-card-text-color)]/45 bg-[var(--theme-card-color)]/80 text-xs font-semibold text-[var(--theme-card-text-color)]"
|
||||
>
|
||||
<SharePlatformIcon platform={item.key} />
|
||||
{/* {item.label} */}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -326,6 +262,9 @@ export function PhotoSearchPage() {
|
|||
const showShare = enabledSharePlatforms.length > 0;
|
||||
const allowDownload = meta?.allowDownload !== false;
|
||||
|
||||
const eventId = meta?.eventId || "";
|
||||
const eventName = meta?.event?.name?.trim() || meta?.eventName || "the event";
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
setPreviewUrl((prev) => {
|
||||
|
|
@ -391,127 +330,129 @@ export function PhotoSearchPage() {
|
|||
|
||||
if (!searchToken) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<PhotoSearchShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||
<p className="text-center text-sm text-red-600">Missing search link</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
</main>
|
||||
</PhotoSearchShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<PhotoSearchShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
|
||||
</main>
|
||||
</PhotoSearchShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !meta) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<PhotoSearchShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
</main>
|
||||
</PhotoSearchShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="Photo Search"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<MobileShell className="relative bg-transparent">
|
||||
<PublicEventChrome
|
||||
event={meta?.event}
|
||||
eventHeader={meta?.eventHeader}
|
||||
eventNameFallback={meta?.eventName}
|
||||
eventId={eventId}
|
||||
/>
|
||||
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Find Photos</h1>
|
||||
<p className="mb-5 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
|
||||
</p>
|
||||
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
|
||||
<div className="flex w-full max-w-none flex-col px-4">
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Find Photos</h2>
|
||||
<p className="mb-5 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||
Upload a selfie to find photos of you from {eventName}.
|
||||
</p>
|
||||
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to search for your photos.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goLogin()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white"
|
||||
>
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
className="hidden"
|
||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="mb-3 overflow-hidden rounded-lg">
|
||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to search for your photos.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goLogin()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white"
|
||||
>
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
className="hidden"
|
||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||
/>
|
||||
{previewUrl && !searched ? (
|
||||
<div className="mb-3 overflow-hidden rounded-lg">
|
||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
|
||||
>
|
||||
{file ? "Change your photo" : "Upload your photo"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-[var(--theme-card-text-color)]"
|
||||
>
|
||||
{file ? "Change photo" : "Choose photo"}
|
||||
</button>
|
||||
</div>
|
||||
{file && !searched ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || searching}
|
||||
onClick={() => void onSearch()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Find photos"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || searching}
|
||||
onClick={() => void onSearch()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Find photos"}
|
||||
</button>
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
|
||||
{searched ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<p className="text-center text-sm font-semibold text-[#333]">
|
||||
{matches && matches.length > 0
|
||||
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
|
||||
: "No matches found"}
|
||||
</p>
|
||||
{matches && matches.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{matches.map((m) => (
|
||||
<MatchCard
|
||||
key={m._id}
|
||||
match={m}
|
||||
eventName={meta?.eventName || "the event"}
|
||||
allowDownload={allowDownload}
|
||||
showShare={showShare}
|
||||
useNativeShare={useNativeShare}
|
||||
enabledSharePlatforms={enabledSharePlatforms}
|
||||
/>
|
||||
))}
|
||||
{searched ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<p className="text-center text-sm font-semibold text-[var(--theme-card-text-color)]">
|
||||
{matches && matches.length > 0
|
||||
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
|
||||
: "No matches found"}
|
||||
</p>
|
||||
{matches && matches.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{matches.map((m) => (
|
||||
<MatchCard
|
||||
key={m._id}
|
||||
match={m}
|
||||
eventName={eventName}
|
||||
allowDownload={allowDownload}
|
||||
showShare={showShare}
|
||||
useNativeShare={useNativeShare}
|
||||
enabledSharePlatforms={enabledSharePlatforms}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ import {
|
|||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
ImagePlus,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||
import {
|
||||
getPhotoWallUploadMeta,
|
||||
getVisitorLoginPreview,
|
||||
|
|
@ -79,13 +77,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
|||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
];
|
||||
|
||||
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
||||
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
||||
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
||||
return <Share2 className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function PhotoWallUploadPage() {
|
||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -474,7 +465,7 @@ export function PhotoWallUploadPage() {
|
|||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
<SharePlatformIcon platform={item.key} />
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -919,7 +919,7 @@ export function QuizPollTakePage() {
|
|||
}
|
||||
|
||||
const footerBtnClass =
|
||||
"rounded-lg px-3 py-1.5 text-center text-xs font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit";
|
||||
"rounded-lg p-3 text-center font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit";
|
||||
const footerSecondaryClass = `${footerBtnClass} flex-1 border border-zinc-300 bg-zinc-100 text-gray-700`;
|
||||
|
||||
return (
|
||||
|
|
@ -989,7 +989,7 @@ export function QuizPollTakePage() {
|
|||
<label
|
||||
key={opt._id}
|
||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||
selected ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10 text-[var(--theme-button-color)]" : "bg-[var(--theme-card-color)]/30 border-[var(--theme-card-color)]/15 text-[var(--theme-card-text-color)]"
|
||||
selected ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10" : "border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/8 text-[var(--theme-card-text-color)]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
|
|
@ -1011,7 +1011,7 @@ export function QuizPollTakePage() {
|
|||
value={typeof answers[q._id] === "string" ? answers[q._id] : ""}
|
||||
onChange={(e) => setText(q, e.target.value)}
|
||||
rows={3}
|
||||
className="mt-3 w-full rounded-md border border-[#e5e5ea] px-3 py-2 text-sm transition-all duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
|
||||
className="mt-3 w-full rounded-md border border-[var(--theme-card-text-color)]/10 bg-[var(--theme-card-color)]/40 px-3 py-2 text-sm text-[var(--theme-card-text-color)] placeholder:text-[var(--theme-card-text-color)]/60 transition-all duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
|
||||
placeholder="Your answer"
|
||||
/>
|
||||
) : q.type === "rating" ? (
|
||||
|
|
@ -1024,7 +1024,7 @@ export function QuizPollTakePage() {
|
|||
className={`size-10 rounded-full border text-sm font-semibold ${
|
||||
answers[q._id] === String(n)
|
||||
? "border-[var(--theme-button-color)] bg-[var(--theme-button-color] text-white"
|
||||
: "border-[#e5e5ea] bg-white text-[#24282f]"
|
||||
: "border-[var(--theme-card-text-color)] text-[var(--theme-card-text-color)]"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
|
|
@ -1036,8 +1036,8 @@ export function QuizPollTakePage() {
|
|||
{q.options.map((opt) => (
|
||||
<label
|
||||
key={opt._id}
|
||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
|
||||
answers[q._id] === opt._id ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10 text-[var(--theme-button-color)]" : "bg-[var(--theme-card-color)]/30 border-[var(--theme-card-color)]/15 text-[var(--theme-card-text-color)]"
|
||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm transition-all duration-300 ease ${
|
||||
answers[q._id] === opt._id ? "border-[var(--theme-button-color)] bg-[var(--theme-button-color)]/10" : "border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/8 text-[var(--theme-card-text-color)]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
|
|
@ -1045,9 +1045,9 @@ export function QuizPollTakePage() {
|
|||
name={`q-${q._id}`}
|
||||
checked={answers[q._id] === opt._id}
|
||||
onChange={() => setSingle(q, opt._id)}
|
||||
className="mt-0.5"
|
||||
className="mt-0.5 opacity-0 invisible w-[1px] h-[1px]"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<span>{opt.text}</span>
|
||||
<QuestionMedia url={opt.imageUrl} alt="" />
|
||||
</span>
|
||||
|
|
@ -1132,7 +1132,7 @@ export function QuizPollTakePage() {
|
|||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void runSubmit()}
|
||||
className="w-full rounded-lg theme-btn-bg px-3 py-1.5 text-center text-xs font-semibold text-white shadow-sm disabled:opacity-60 disabled:hover:bg-[var(--theme-button-color)]/50"
|
||||
className="w-full rounded-lg theme-btn-bg px-3 py-3 text-center text-sm font-semibold text-white shadow-sm disabled:opacity-60 disabled:hover:bg-[var(--theme-button-color)]/50"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -126,20 +126,21 @@ export function RewardsPage() {
|
|||
const signed = row.type === "redeemed" ? "-" : "+";
|
||||
const cls =
|
||||
row.type === "redeemed"
|
||||
? "bg-[#fff0f0] text-[#c02424]"
|
||||
? "bg-[#fff0f0 text-[#c02424]"
|
||||
: row.type === "earned"
|
||||
? "bg-[#ebffee] text-[#009951]"
|
||||
: "bg-[#f3f4f6] text-[#24282f]";
|
||||
? "bg-[#ebffee text-[#009951]"
|
||||
: "bg-[#f3f4f6 text-[#24282f]";
|
||||
return (
|
||||
<li
|
||||
key={row._id}
|
||||
className="flex items-center gap-4 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
|
||||
>
|
||||
<span className={`shrink-0 rounded px-3 py-2 text-xs font-medium shadow-sm ${cls}`}>
|
||||
<span className={`shrink-0 px-1 py-1 text-sm font-semibold ${cls}`}>
|
||||
{signed}
|
||||
{row.points} pts
|
||||
</span>
|
||||
<span className="text-sm leading-snug text-[var(--theme-card-text-color)]/70">
|
||||
<span className="w-[2px] h-[2px] bg-[var(--theme-card-text-color)]/30 rounded-full"></span>
|
||||
<span className="text-sm leading-snug text-[var(--theme-card-text-color)]/80">
|
||||
{historyLabel(row.source, row.description)}
|
||||
</span>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -8,15 +8,14 @@ import {
|
|||
} from "react";
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { HeaderMobile } from "../components/HeaderMobile";
|
||||
import { MobileShell } from "../components/MobileShell";
|
||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||
import {
|
||||
getSocialShareMeta,
|
||||
getVisitorLoginPreview,
|
||||
|
|
@ -41,13 +40,29 @@ import {
|
|||
type EbVisitorContext,
|
||||
} from "../types/visitorContext";
|
||||
|
||||
function WhiteCard({ children }: { children: ReactNode }) {
|
||||
function SocialShareLayout({
|
||||
children,
|
||||
meta,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
meta?: SocialShareMeta | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||||
<div className="w-full rounded-xl shadow-md">
|
||||
<div className="rounded-xl card-bg px-6 py-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MobileShell className="relative bg-transparent">
|
||||
{meta ? (
|
||||
<PublicEventChrome
|
||||
event={meta.event}
|
||||
eventHeader={meta.eventHeader}
|
||||
eventNameFallback={meta.eventName}
|
||||
eventId={meta.eventId}
|
||||
/>
|
||||
) : (
|
||||
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
|
||||
)}
|
||||
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
|
||||
<div className="flex w-full max-w-none flex-col px-4">{children}</div>
|
||||
</main>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -70,13 +85,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
|||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
];
|
||||
|
||||
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
|
||||
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
|
||||
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
|
||||
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
|
||||
return <Share2 className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function SocialSharePage() {
|
||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -90,6 +98,7 @@ export function SocialSharePage() {
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [done, setDone] = useState<DoneState | null>(null);
|
||||
const [resultImageReady, setResultImageReady] = useState(false);
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
||||
const [shareFile, setShareFile] = useState<File | null>(null);
|
||||
|
|
@ -125,6 +134,15 @@ export function SocialSharePage() {
|
|||
};
|
||||
}, [cropSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const src = done?.previewUrl || done?.fileUrl;
|
||||
if (!done || !src) return;
|
||||
const img = resultImgRef.current;
|
||||
if (img?.complete && img.naturalWidth > 0) {
|
||||
setResultImageReady(true);
|
||||
}
|
||||
}, [done]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!done?.fileUrl) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -223,6 +241,7 @@ export function SocialSharePage() {
|
|||
try {
|
||||
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
|
||||
setShareFile(null);
|
||||
setResultImageReady(false);
|
||||
setDone({
|
||||
previewUrl: result.previewUrl,
|
||||
fileUrl: result.fileUrl,
|
||||
|
|
@ -244,6 +263,7 @@ export function SocialSharePage() {
|
|||
|
||||
const resetForNew = () => {
|
||||
setDone(null);
|
||||
setResultImageReady(false);
|
||||
setShareFile(null);
|
||||
setActionMsg(null);
|
||||
setErr(null);
|
||||
|
|
@ -346,59 +366,61 @@ export function SocialSharePage() {
|
|||
|
||||
if (!uploadToken) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<SocialShareLayout>
|
||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||
</SocialShareLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<SocialShareLayout>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
|
||||
</SocialShareLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (err && !meta) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
<SocialShareLayout>
|
||||
<p className="text-center text-sm text-red-600">{err}</p>
|
||||
</SocialShareLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="Social Share"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="mb-2 text-center text-[22px] font-semibold">Your photo</h1>
|
||||
<SocialShareLayout meta={meta}>
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||
<h2 className="mb-2 text-center text-[22px] font-semibold">Your photo</h2>
|
||||
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||
Here's your framed photo. You can download or share it.
|
||||
</p>
|
||||
{(done.fileUrl || done.previewUrl) ? (
|
||||
<div className="mb-4 overflow-hidden rounded-lg">
|
||||
<div className="relative mb-4 min-h-[220px] overflow-hidden rounded-lg bg-[var(--theme-card-text-color)]/5">
|
||||
{!resultImageReady ? (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading generated photo"
|
||||
>
|
||||
<div
|
||||
className="size-10 animate-spin rounded-full border-[3px] border-[var(--theme-button-color)]/25 border-t-[var(--theme-button-color)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-xs font-medium text-[var(--theme-card-text-color)]/70">Preparing your photo…</p>
|
||||
</div>
|
||||
) : null}
|
||||
<img
|
||||
ref={resultImgRef}
|
||||
crossOrigin="anonymous"
|
||||
src={done.previewUrl || done.fileUrl || undefined}
|
||||
alt="Your framed photo"
|
||||
className="mx-auto max-h-[360px] w-full object-contain"
|
||||
className={`mx-auto max-h-[360px] w-full object-contain transition-opacity duration-300 ${
|
||||
resultImageReady ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => setResultImageReady(true)}
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.crossOrigin) {
|
||||
|
|
@ -408,11 +430,15 @@ export function SocialSharePage() {
|
|||
}
|
||||
if (done.fileUrl && img.src !== done.fileUrl) {
|
||||
img.src = done.fileUrl;
|
||||
return;
|
||||
}
|
||||
setResultImageReady(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">Photo is ready, but preview is unavailable.</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -427,7 +453,7 @@ export function SocialSharePage() {
|
|||
onClick={() => void onDownload()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<Download className="size-5" />
|
||||
Download photo
|
||||
</button>
|
||||
) : null}
|
||||
|
|
@ -443,20 +469,20 @@ export function SocialSharePage() {
|
|||
onClick={() => void onNativeShare()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
<Share2 className="size-5" />
|
||||
Share
|
||||
</button>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{enabledSharePlatforms.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => void onShare(item.key)}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
|
||||
className="inline-flex h-10 items-center justify-center w-auto gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
|
||||
>
|
||||
<ShareIcon platform={item.key} />
|
||||
{item.label}
|
||||
<SharePlatformIcon platform={item.key} />
|
||||
{/* {item.label} */}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -464,27 +490,17 @@ export function SocialSharePage() {
|
|||
</div>
|
||||
) : null}
|
||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||
</WhiteCard>
|
||||
</AuthShell>
|
||||
</section>
|
||||
</SocialShareLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell showLogo={false}>
|
||||
<WhiteCard>
|
||||
{meta?.event ? (
|
||||
<div className="mb-4">
|
||||
<PhotoWallEventHeader
|
||||
event={meta.event}
|
||||
display={meta.eventHeader || null}
|
||||
eyebrow="Social Share"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SocialShareLayout meta={meta}>
|
||||
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||
{meta?.accessMode === "login" && !loggedIn ? (
|
||||
<div className="space-y-3">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold">Social Share</h1>
|
||||
<h2 className="mb-1 text-center text-[22px] font-semibold">Social Share</h2>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create and share your photo.</p>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -496,7 +512,7 @@ export function SocialSharePage() {
|
|||
</div>
|
||||
) : !selectedFrame ? (
|
||||
<div className="space-y-4">
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold">Choose a frame</h1>
|
||||
<h2 className="mb-1 text-center text-[22px] font-semibold">Choose a frame</h2>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick a frame, then upload your photo.</p>
|
||||
{!meta?.frames?.length ? (
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No frames available yet.</p>
|
||||
|
|
@ -507,7 +523,7 @@ export function SocialSharePage() {
|
|||
key={frame._id}
|
||||
type="button"
|
||||
onClick={() => setSelectedFrame(frame)}
|
||||
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/25"
|
||||
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/25 hover:border-[var(--theme-card-text-color)]/30"
|
||||
>
|
||||
<div className="aspect-square w-full">
|
||||
{frame.frameImageUrl ? (
|
||||
|
|
@ -532,7 +548,7 @@ export function SocialSharePage() {
|
|||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="space-y-4 text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -540,13 +556,13 @@ export function SocialSharePage() {
|
|||
clearSelectedPhoto();
|
||||
setErr(null);
|
||||
}}
|
||||
className="text-sm font-semibold theme-btn-link"
|
||||
className="text-sm font-semibold theme-btn-lin text-[var(--theme-card-text-color)]"
|
||||
>
|
||||
← Change frame
|
||||
</button>
|
||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h1>
|
||||
<h2 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h2>
|
||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||
Frame: <span className="font-semibold">{selectedFrame.name}</span>
|
||||
Selected frame: <span className="font-semibold">{selectedFrame.name}</span>
|
||||
</p>
|
||||
|
||||
<input
|
||||
|
|
@ -576,12 +592,12 @@ export function SocialSharePage() {
|
|||
onClick={() => void onSubmit()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white disabled:opacity-55"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create photo"}
|
||||
{submitting ? "Generating" : "Generate photo"}
|
||||
</button>
|
||||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</WhiteCard>
|
||||
</section>
|
||||
|
||||
{cropSession && cropTarget ? (
|
||||
<PhotoWallCropModal
|
||||
|
|
@ -599,6 +615,6 @@ export function SocialSharePage() {
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AuthShell>
|
||||
</SocialShareLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { EventThemeConfig } from "../types/visitorContext";
|
||||
|
||||
const STORAGE_KEY = "eb_visitor_theme_cache";
|
||||
|
||||
export type CachedVisitorTheme = {
|
||||
eventId: string;
|
||||
theme: EventThemeConfig;
|
||||
};
|
||||
|
||||
let memoryCache: CachedVisitorTheme | null = null;
|
||||
|
||||
function readSessionCache(): CachedVisitorTheme | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as CachedVisitorTheme;
|
||||
if (!parsed?.eventId || !parsed?.theme) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSessionCache(entry: CachedVisitorTheme): void {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(entry));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
/** Last themed event (memory, then sessionStorage). Optionally require a matching event id. */
|
||||
export function readCachedVisitorTheme(eventId?: string): CachedVisitorTheme | null {
|
||||
const cached = memoryCache ?? readSessionCache();
|
||||
if (cached && !memoryCache) memoryCache = cached;
|
||||
if (!cached) return null;
|
||||
if (eventId && cached.eventId !== eventId) return null;
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function writeCachedVisitorTheme(eventId: string, theme: EventThemeConfig): void {
|
||||
const entry = { eventId, theme };
|
||||
memoryCache = entry;
|
||||
writeSessionCache(entry);
|
||||
}
|
||||
|
||||
export function clearCachedVisitorTheme(): void {
|
||||
memoryCache = null;
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue