Compare commits
2 Commits
432842ee13
...
0b44e15b50
| Author | SHA1 | Date |
|---|---|---|
|
|
0b44e15b50 | |
|
|
b16b5828a4 |
|
|
@ -131,7 +131,7 @@ export function HeaderMobile({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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"
|
data-name="Header_Mobile"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
<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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache hit / already themed: overlay was never shown, so skip the hold+fade.
|
||||||
|
if (!overlayVisible) return;
|
||||||
|
|
||||||
const elapsed = Date.now() - shownAtRef.current;
|
const elapsed = Date.now() - shownAtRef.current;
|
||||||
const holdMs = Math.max(0, MIN_VISIBLE_MS - elapsed);
|
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 (holdTimerRef.current) clearTimeout(holdTimerRef.current);
|
||||||
if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current);
|
if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [ready]);
|
}, [ready, overlayVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,30 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { getPublicVisitorTheme } from "../lib/api";
|
import {
|
||||||
|
getAiAvatarMeta,
|
||||||
|
getPhotoSearchMeta,
|
||||||
|
getPhotoWallPresent,
|
||||||
|
getPhotoWallUploadMeta,
|
||||||
|
getPublicVisitorTheme,
|
||||||
|
getSocialShareMeta,
|
||||||
|
} from "../lib/api";
|
||||||
import { applyEventTheme } from "../theme/eventTheme";
|
import { applyEventTheme } from "../theme/eventTheme";
|
||||||
|
import { readCachedVisitorTheme, writeCachedVisitorTheme } from "../theme/visitorThemeCache";
|
||||||
import { readStoredVisitorContext } from "../types/visitorContext";
|
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() || "";
|
const fromEventId = searchParams.get("event_id")?.trim() || "";
|
||||||
if (fromEventId) return fromEventId;
|
if (fromEventId) return fromEventId;
|
||||||
const fromEventIdCamel = searchParams.get("eventId")?.trim() || "";
|
const fromEventIdCamel = searchParams.get("eventId")?.trim() || "";
|
||||||
|
|
@ -12,11 +32,48 @@ function resolveEventIdFromSearch(searchParams: URLSearchParams): string {
|
||||||
const type = searchParams.get("type");
|
const type = searchParams.get("type");
|
||||||
const id = searchParams.get("id")?.trim() || "";
|
const id = searchParams.get("id")?.trim() || "";
|
||||||
if (type === "event" && id) return id;
|
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 = {
|
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;
|
eventId: string;
|
||||||
/**
|
/**
|
||||||
* True when either there is no event to theme, or the theme request has
|
* 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)
|
* Loads GET /api/public/visitor-theme/:eventId once per event and caches it.
|
||||||
* and applies CSS variables to `<html>`.
|
* 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 {
|
export function useEventTheme(): UseEventThemeResult {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const eventId = resolveEventIdFromSearch(searchParams);
|
const { pathname } = useLocation();
|
||||||
const [ready, setReady] = useState(!eventId);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!eventId) {
|
if (kind === "none") {
|
||||||
|
setResolvedEventId("");
|
||||||
setReady(true);
|
setReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setReady(false);
|
|
||||||
|
|
||||||
getPublicVisitorTheme(eventId)
|
if (kind === "eventId") {
|
||||||
.then((data) => {
|
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;
|
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);
|
applyEventTheme(data.theme);
|
||||||
setReady(true);
|
} catch {
|
||||||
})
|
/* keep defaults / previous theme */
|
||||||
.catch(() => {
|
} finally {
|
||||||
if (cancelled) return;
|
if (!cancelled) setReady(true);
|
||||||
// Still unblock UI; keep defaults / any previously applied theme.
|
}
|
||||||
setReady(true);
|
})();
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [eventId]);
|
}, [kind, lookupEventId, tokenSource, token]);
|
||||||
|
|
||||||
return { eventId, ready };
|
return { eventId: resolvedEventId, ready };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,15 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Facebook,
|
|
||||||
Instagram,
|
|
||||||
Linkedin,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
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 { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
import {
|
import {
|
||||||
getAiAvatarMeta,
|
getAiAvatarMeta,
|
||||||
getVisitorLoginPreview,
|
getVisitorLoginPreview,
|
||||||
|
|
@ -42,13 +41,29 @@ import {
|
||||||
type EbVisitorContext,
|
type EbVisitorContext,
|
||||||
} from "../types/visitorContext";
|
} from "../types/visitorContext";
|
||||||
|
|
||||||
function WhiteCard({ children }: { children: ReactNode }) {
|
function AiAvatarLayout({
|
||||||
|
children,
|
||||||
|
meta,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
meta?: AiAvatarMeta | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
<MobileShell className="relative bg-transparent">
|
||||||
<div className="w-full rounded-xl shadow-md">
|
{meta ? (
|
||||||
<div className="rounded-xl bg-whit card-bg px-6 py-6">{children}</div>
|
<PublicEventChrome
|
||||||
</div>
|
event={meta.event}
|
||||||
</div>
|
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…",
|
"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() {
|
export function AiAvatarPage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -255,7 +263,7 @@ export function AiAvatarPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
setErr(e2 instanceof Error ? e2.message : "Could not create avatar");
|
setErr(e2 instanceof Error ? e2.message : "Could not generate avatar");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -365,31 +373,25 @@ export function AiAvatarPage() {
|
||||||
|
|
||||||
if (!uploadToken) {
|
if (!uploadToken) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<AiAvatarLayout>
|
||||||
<WhiteCard>
|
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
</AiAvatarLayout>
|
||||||
</WhiteCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<AiAvatarLayout>
|
||||||
<WhiteCard>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
|
||||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
</AiAvatarLayout>
|
||||||
</WhiteCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err && !meta) {
|
if (err && !meta) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<AiAvatarLayout>
|
||||||
<WhiteCard>
|
<p className="text-center text-sm text-red-600">{err}</p>
|
||||||
<p className="text-center text-sm text-red-600">{err}</p>
|
</AiAvatarLayout>
|
||||||
</WhiteCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,22 +400,13 @@ export function AiAvatarPage() {
|
||||||
const total = GENERATING_PIPELINE_MESSAGES.length;
|
const total = GENERATING_PIPELINE_MESSAGES.length;
|
||||||
const progressPct = Math.round((step / total) * 100);
|
const progressPct = Math.round((step / total) * 100);
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<AiAvatarLayout meta={meta}>
|
||||||
<WhiteCard>
|
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||||
{meta?.event ? (
|
|
||||||
<div className="mb-4">
|
|
||||||
<PhotoWallEventHeader
|
|
||||||
event={meta.event}
|
|
||||||
display={meta.eventHeader || null}
|
|
||||||
eyebrow="AI Avatar"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-col items-center py-4 text-center">
|
<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">
|
<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 />
|
<Loader2 className="h-8 w-8 animate-spin text-[var(--theme-button-color)]" aria-hidden />
|
||||||
</div>
|
</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
|
<p
|
||||||
key={pipelineMsgIndex}
|
key={pipelineMsgIndex}
|
||||||
className="mb-5 min-h-[2.5rem] text-sm text-[var(--theme-card-text-color)]/80 transition-opacity duration-300"
|
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>
|
</div>
|
||||||
<p className="text-xs text-[var(--theme-card-text-color)]/60">This can take up to a minute — hang tight</p>
|
<p className="text-xs text-[var(--theme-card-text-color)]/60">This can take up to a minute — hang tight</p>
|
||||||
</div>
|
</div>
|
||||||
</WhiteCard>
|
</section>
|
||||||
</AuthShell>
|
</AiAvatarLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<AiAvatarLayout meta={meta}>
|
||||||
<WhiteCard>
|
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||||
{meta?.event ? (
|
<h2 className="mb-2 text-center text-[22px] font-semibold">Your avatar</h2>
|
||||||
<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>
|
|
||||||
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
<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.
|
Here's your AI avatar. You can download or share it.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -479,7 +463,7 @@ export function AiAvatarPage() {
|
||||||
onClick={resetForNew}
|
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"
|
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>
|
</button>
|
||||||
{allowDownload ? (
|
{allowDownload ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -487,7 +471,7 @@ export function AiAvatarPage() {
|
||||||
onClick={() => void onDownload()}
|
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"
|
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
|
Download photo
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -503,11 +487,11 @@ export function AiAvatarPage() {
|
||||||
onClick={() => void onNativeShare()}
|
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"
|
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
|
Share
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{enabledSharePlatforms.map((item) => (
|
{enabledSharePlatforms.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
|
@ -515,8 +499,8 @@ export function AiAvatarPage() {
|
||||||
onClick={() => void onShare(item.key)}
|
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 gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
|
||||||
>
|
>
|
||||||
<ShareIcon platform={item.key} />
|
<SharePlatformIcon platform={item.key} />
|
||||||
{item.label}
|
{/* {item.label} */}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -524,27 +508,17 @@ export function AiAvatarPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||||
</WhiteCard>
|
</section>
|
||||||
</AuthShell>
|
</AiAvatarLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<AiAvatarLayout meta={meta}>
|
||||||
<WhiteCard>
|
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||||
{meta?.event ? (
|
|
||||||
<div className="mb-4">
|
|
||||||
<PhotoWallEventHeader
|
|
||||||
event={meta.event}
|
|
||||||
display={meta.eventHeader || null}
|
|
||||||
eyebrow="AI Avatar"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{meta?.accessMode === "login" && !loggedIn ? (
|
{meta?.accessMode === "login" && !loggedIn ? (
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create your AI avatar.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -556,7 +530,7 @@ export function AiAvatarPage() {
|
||||||
</div>
|
</div>
|
||||||
) : !selectedAvatar ? (
|
) : !selectedAvatar ? (
|
||||||
<div className="space-y-4">
|
<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>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick an avatar, then upload your selfie.</p>
|
||||||
{!meta?.avatars?.length ? (
|
{!meta?.avatars?.length ? (
|
||||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No avatars available yet.</p>
|
<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}
|
key={avatar._id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedAvatar(avatar)}
|
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">
|
<div className="aspect-square w-full">
|
||||||
{avatar.imageUrl ? (
|
{avatar.imageUrl ? (
|
||||||
|
|
@ -592,7 +566,7 @@ export function AiAvatarPage() {
|
||||||
{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}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -600,11 +574,11 @@ export function AiAvatarPage() {
|
||||||
clearSelectedPhoto();
|
clearSelectedPhoto();
|
||||||
setErr(null);
|
setErr(null);
|
||||||
}}
|
}}
|
||||||
className="text-sm font-semibold theme-btn-link"
|
className="text-sm font-semibold text-[var(--theme-card-text-color)]"
|
||||||
>
|
>
|
||||||
← Change avatar
|
← Change avatar
|
||||||
</button>
|
</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">
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||||||
Avatar: <span className="font-semibold">{selectedAvatar.name}</span>
|
Avatar: <span className="font-semibold">{selectedAvatar.name}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -634,14 +608,14 @@ export function AiAvatarPage() {
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!file || submitting}
|
disabled={!file || submitting}
|
||||||
onClick={() => void onSubmit()}
|
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>
|
</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}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WhiteCard>
|
</section>
|
||||||
|
|
||||||
{cropSession && cropTarget ? (
|
{cropSession && cropTarget ? (
|
||||||
<PhotoWallCropModal
|
<PhotoWallCropModal
|
||||||
|
|
@ -659,6 +633,6 @@ export function AiAvatarPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</AuthShell>
|
</AiAvatarLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { Link, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
getVisitorEventAgenda,
|
getVisitorEventAgenda,
|
||||||
|
|
@ -83,6 +83,7 @@ export function EventLandingPage() {
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
|
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
|
||||||
|
const [eventMetaOpen, setEventMetaOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDayOpen({});
|
setDayOpen({});
|
||||||
|
|
@ -118,6 +119,9 @@ export function EventLandingPage() {
|
||||||
const ratingsHref = scopedQs ? `/ratings?${scopedQs}` : "/ratings";
|
const ratingsHref = scopedQs ? `/ratings?${scopedQs}` : "/ratings";
|
||||||
|
|
||||||
const bannerSrc = event?.bannerImage?.trim() || event?.image?.trim() || "";
|
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) {
|
if (!eventId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -147,43 +151,67 @@ export function EventLandingPage() {
|
||||||
return (
|
return (
|
||||||
<MobileShell className="relative bg-transparent">
|
<MobileShell className="relative bg-transparent">
|
||||||
<HeaderMobile logoUrl={event.logoUrl} eventId={eventId || null} showBack={false} />
|
<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">
|
<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">
|
<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 ? (
|
{bookmarkedIds.size > 0 ? (
|
||||||
<section className="w-full card-bg rounded-lg shadow-md p-4">
|
<section className="w-full card-bg rounded-lg shadow-md p-4">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Award } from "lucide-react";
|
import { Award, User } from "lucide-react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { getVisitorEventLeaderboard } from "../lib/api";
|
import { getVisitorEventLeaderboard } from "../lib/api";
|
||||||
import { getAttendeeToken } from "../lib/attendeeAuth";
|
import { getAttendeeToken } from "../lib/attendeeAuth";
|
||||||
|
|
@ -11,10 +11,10 @@ import type { VisitorLeaderboardRow } from "../lib/api";
|
||||||
function Medal({ tier }: { tier: "gold" | "silver" | "bronze" }) {
|
function Medal({ tier }: { tier: "gold" | "silver" | "bronze" }) {
|
||||||
const cls =
|
const cls =
|
||||||
tier === "gold"
|
tier === "gold"
|
||||||
? "text-amber-500"
|
? "text-amber-400"
|
||||||
: tier === "silver"
|
: tier === "silver"
|
||||||
? "text-slate-400"
|
? "text-slate-400"
|
||||||
: "text-amber-800";
|
: "text-rose-400";
|
||||||
return <Award className={`size-6 ${cls}`} strokeWidth={1.5} />;
|
return <Award className={`size-6 ${cls}`} strokeWidth={1.5} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,7 +92,7 @@ export function LeaderboardPage() {
|
||||||
</div> */}
|
</div> */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="mt-1 text-lg font-semibold">Your Rank</h1>
|
<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"}
|
{!signedIn ? "Sign in" : loading ? "…" : yourTotal != null ? `${yourTotal} points` : "0 points"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,15 +137,31 @@ export function LeaderboardPage() {
|
||||||
{top5.map((row) => {
|
{top5.map((row) => {
|
||||||
const medal = medalForRank(row.rank);
|
const medal = medalForRank(row.rank);
|
||||||
return (
|
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
|
<li
|
||||||
key={row.participantId}
|
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">
|
<div className="flex size-10 items-center justify-center rounded-full bg-[var(--theme-button-color)]/10">
|
||||||
#{row.rank} · {row.total} pts
|
<User className="size-5 text-[var(theme-card-text-color)]/20" strokeWidth={2} />
|
||||||
</span>
|
</div>
|
||||||
<span className="min-w-0 flex-1 text-sm">{row.displayName}</span>
|
<div className="flex flex-col gap-1 grow-1">
|
||||||
{medal ? <Medal tier={medal} /> : null}
|
<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>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -221,8 +221,8 @@ export function LoginPage() {
|
||||||
|
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
return (
|
return (
|
||||||
<AuthShell>
|
<AuthShell showLogo={false}>
|
||||||
<div className="max-w-md px-4 text-center text-sm text-white/95">
|
<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>
|
<p>Open this app from your event link or QR code to sign in.</p>
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|
@ -237,12 +237,12 @@ export function LoginPage() {
|
||||||
const showInviteField = reg.registrationMode === "Invite Only";
|
const showInviteField = reg.registrationMode === "Invite Only";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell>
|
<AuthShell showLogo={false}>
|
||||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
<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="w-full">
|
||||||
<div className="rounded-xl card-bg">
|
<div className="rounded-xl card-bg shadow-md">
|
||||||
<div className="px-6 pb-2 pt-6">
|
<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 ? (
|
{preview?.logoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={preview.logoUrl}
|
src={preview.logoUrl}
|
||||||
|
|
@ -256,10 +256,11 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-8 px-6 pb-8 pt-2">
|
<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">
|
<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">
|
<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}
|
{headline}
|
||||||
|
{/* Novel Lung Health Summit 2026 Kochi 22nd & 23rd August 2026 */}
|
||||||
</p>
|
</p>
|
||||||
{preview?.subtitle ? (
|
{preview?.subtitle ? (
|
||||||
<p className="text-sm font-medium text-[var(--theme-card-text-color)]/80">{preview.subtitle}</p>
|
<p className="text-sm font-medium text-[var(--theme-card-text-color)]/80">{preview.subtitle}</p>
|
||||||
|
|
@ -276,21 +277,21 @@ export function LoginPage() {
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{showMeta ? (
|
{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 ? (
|
{schedule ? (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
|
<Calendar className="size-4 shrink-0 text-[var(--theme-card-text-color)]/70" strokeWidth={1.75} />
|
||||||
<span className="">{schedule}</span>
|
<span className="">{schedule}</span>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{schedule && preview?.location?.trim() ? (
|
{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}
|
) : null}
|
||||||
{preview?.location?.trim() ? (
|
{preview?.location?.trim() ? (
|
||||||
<>
|
<div className="flex gap-2">
|
||||||
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
|
<MapPin className="size-4 shrink-0 text-[var(--theme-card-text-color)]/70" strokeWidth={1.75} />
|
||||||
<span className="">{preview.location.trim()}</span>
|
<span className="text-left">{preview.location.trim()}</span>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -303,7 +304,7 @@ export function LoginPage() {
|
||||||
value={inviteInput}
|
value={inviteInput}
|
||||||
onChange={(e) => setInviteInput(e.target.value)}
|
onChange={(e) => setInviteInput(e.target.value)}
|
||||||
placeholder="Enter your invite code"
|
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>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,14 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Facebook,
|
|
||||||
Instagram,
|
|
||||||
Linkedin,
|
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { AuthShell } from "../components/auth/AuthShell";
|
import { HeaderMobile } from "../components/HeaderMobile";
|
||||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
import { MobileShell } from "../components/MobileShell";
|
||||||
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
import {
|
import {
|
||||||
getPhotoSearchMeta,
|
getPhotoSearchMeta,
|
||||||
getVisitorLoginPreview,
|
getVisitorLoginPreview,
|
||||||
|
|
@ -40,13 +39,12 @@ import {
|
||||||
type EbVisitorContext,
|
type EbVisitorContext,
|
||||||
} from "../types/visitorContext";
|
} from "../types/visitorContext";
|
||||||
|
|
||||||
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
|
function PhotoSearchShell({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full ${wide ? "max-w-[480px]" : "max-w-[354px]"} flex-col items-center`}>
|
<MobileShell className="relative bg-transparent">
|
||||||
<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)]">
|
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
|
||||||
<div className="rounded-xl card-bg text-[var(--theme-card-text-color)] shadow-md px-6 py-6">{children}</div>
|
{children}
|
||||||
</div>
|
</MobileShell>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,62 +57,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
||||||
{ key: "whatsapp", label: "WhatsApp" },
|
{ 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({
|
function MatchCard({
|
||||||
match,
|
match,
|
||||||
eventName,
|
eventName,
|
||||||
|
|
@ -218,63 +160,57 @@ function MatchCard({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]${shareMenuOpen ? " relative z-20" : ""}`}>
|
<div className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/10 relative">
|
||||||
<div className="overflow-hidden rounded-t-lg bg-white">
|
<img
|
||||||
<img
|
ref={imgRef}
|
||||||
ref={imgRef}
|
crossOrigin="anonymous"
|
||||||
crossOrigin="anonymous"
|
src={previewSrc || undefined}
|
||||||
src={previewSrc || undefined}
|
alt={match.originalFileName || "Matched photo"}
|
||||||
alt={match.originalFileName || "Matched photo"}
|
className="mx-auto max-h-[220px] w-full object-contain bg-white"
|
||||||
className="mx-auto max-h-[220px] w-full object-contain bg-white"
|
onError={(e) => {
|
||||||
onError={(e) => {
|
const img = e.currentTarget;
|
||||||
const img = e.currentTarget;
|
// Retry without CORS for CDN; do not switch to API file proxy for display.
|
||||||
// Retry without CORS for CDN; do not switch to API file proxy for display.
|
if (img.crossOrigin) {
|
||||||
if (img.crossOrigin) {
|
img.crossOrigin = null;
|
||||||
img.crossOrigin = null;
|
if (previewSrc) img.src = previewSrc;
|
||||||
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">
|
||||||
</div>
|
{allowDownload ? (
|
||||||
<div className="p-2">
|
<button
|
||||||
<div ref={shareMenuRef} className="flex flex-nowrap items-center justify-center gap-1.5">
|
type="button"
|
||||||
{allowDownload ? (
|
onClick={() => void onDownload()}
|
||||||
<IconActionButton label="Download" 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="h-4 w-4" />
|
>
|
||||||
</IconActionButton>
|
<Download className="size-5" />
|
||||||
) : null}
|
</button>
|
||||||
{showShare ? (
|
) : null}
|
||||||
<div className="relative">
|
{showShare ? (
|
||||||
<IconActionButton
|
useNativeShare ? (
|
||||||
label="Share"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
if (useNativeShare) {
|
onClick={() => void onNativeShare()}
|
||||||
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)]"
|
||||||
return;
|
>
|
||||||
}
|
<Share2 className="size-5" />
|
||||||
setShareMenuOpen((open) => !open);
|
</button>
|
||||||
}}
|
) : (
|
||||||
>
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
<Share2 className="h-4 w-4" />
|
{enabledSharePlatforms.map((item) => (
|
||||||
</IconActionButton>
|
<button
|
||||||
{showShare && !useNativeShare && shareMenuOpen ? (
|
key={item.key}
|
||||||
<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)]">
|
type="button"
|
||||||
{enabledSharePlatforms.map((item) => (
|
onClick={() => void onShare(item.key)}
|
||||||
<button
|
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)]"
|
||||||
key={item.key}
|
>
|
||||||
type="button"
|
<SharePlatformIcon platform={item.key} />
|
||||||
onClick={() => void onShare(item.key)}
|
{/* {item.label} */}
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold text-[#333] hover:bg-[#f7f7f7]"
|
</button>
|
||||||
>
|
))}
|
||||||
<ShareIcon platform={item.key} className="h-4 w-4 shrink-0" />
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -326,6 +262,9 @@ export function PhotoSearchPage() {
|
||||||
const showShare = enabledSharePlatforms.length > 0;
|
const showShare = enabledSharePlatforms.length > 0;
|
||||||
const allowDownload = meta?.allowDownload !== false;
|
const allowDownload = meta?.allowDownload !== false;
|
||||||
|
|
||||||
|
const eventId = meta?.eventId || "";
|
||||||
|
const eventName = meta?.event?.name?.trim() || meta?.eventName || "the event";
|
||||||
|
|
||||||
const clearFile = () => {
|
const clearFile = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreviewUrl((prev) => {
|
setPreviewUrl((prev) => {
|
||||||
|
|
@ -391,127 +330,129 @@ export function PhotoSearchPage() {
|
||||||
|
|
||||||
if (!searchToken) {
|
if (!searchToken) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<PhotoSearchShell>
|
||||||
<WhiteCard>
|
<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>
|
<p className="text-center text-sm text-red-600">Missing search link</p>
|
||||||
</WhiteCard>
|
</main>
|
||||||
</AuthShell>
|
</PhotoSearchShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<PhotoSearchShell>
|
||||||
<WhiteCard>
|
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
|
||||||
</WhiteCard>
|
</main>
|
||||||
</AuthShell>
|
</PhotoSearchShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err && !meta) {
|
if (err && !meta) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<PhotoSearchShell>
|
||||||
<WhiteCard>
|
<main className="flex flex-1 items-center justify-center px-4 py-10">
|
||||||
<p className="text-center text-sm text-red-600">{err}</p>
|
<p className="text-center text-sm text-red-600">{err}</p>
|
||||||
</WhiteCard>
|
</main>
|
||||||
</AuthShell>
|
</PhotoSearchShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<MobileShell className="relative bg-transparent">
|
||||||
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
|
<PublicEventChrome
|
||||||
{meta?.event ? (
|
event={meta?.event}
|
||||||
<div className="mb-4">
|
eventHeader={meta?.eventHeader}
|
||||||
<PhotoWallEventHeader
|
eventNameFallback={meta?.eventName}
|
||||||
event={meta.event}
|
eventId={eventId}
|
||||||
display={meta.eventHeader || null}
|
/>
|
||||||
eyebrow="Photo Search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Find Photos</h1>
|
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
|
||||||
<p className="mb-5 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
<div className="flex w-full max-w-none flex-col px-4">
|
||||||
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
|
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||||
</p>
|
<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 ? (
|
{meta?.accessMode === "login" && !loggedIn ? (
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to search for your photos.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void goLogin()}
|
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"
|
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
|
Sign in to continue
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="user"
|
capture="user"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
onChange={(e) => onPick(e.target.files?.[0] || null)}
|
||||||
/>
|
/>
|
||||||
{previewUrl ? (
|
{previewUrl && !searched ? (
|
||||||
<div className="mb-3 overflow-hidden rounded-lg">
|
<div className="mb-3 overflow-hidden rounded-lg">
|
||||||
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
|
<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>
|
</div>
|
||||||
) : null}
|
{file && !searched ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileRef.current?.click()}
|
disabled={!file || searching}
|
||||||
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)]"
|
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"
|
||||||
{file ? "Change photo" : "Choose photo"}
|
>
|
||||||
</button>
|
<Search className="h-4 w-4" />
|
||||||
</div>
|
{searching ? "Searching…" : "Find photos"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||||||
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}
|
{searched ? (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
{searched ? (
|
<p className="text-center text-sm font-semibold text-[var(--theme-card-text-color)]">
|
||||||
<div className="space-y-3 pt-2">
|
{matches && matches.length > 0
|
||||||
<p className="text-center text-sm font-semibold text-[#333]">
|
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
|
||||||
{matches && matches.length > 0
|
: "No matches found"}
|
||||||
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
|
</p>
|
||||||
: "No matches found"}
|
{matches && matches.length > 0 ? (
|
||||||
</p>
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{matches && matches.length > 0 ? (
|
{matches.map((m) => (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<MatchCard
|
||||||
{matches.map((m) => (
|
key={m._id}
|
||||||
<MatchCard
|
match={m}
|
||||||
key={m._id}
|
eventName={eventName}
|
||||||
match={m}
|
allowDownload={allowDownload}
|
||||||
eventName={meta?.eventName || "the event"}
|
showShare={showShare}
|
||||||
allowDownload={allowDownload}
|
useNativeShare={useNativeShare}
|
||||||
showShare={showShare}
|
enabledSharePlatforms={enabledSharePlatforms}
|
||||||
useNativeShare={useNativeShare}
|
/>
|
||||||
enabledSharePlatforms={enabledSharePlatforms}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</section>
|
||||||
)}
|
</div>
|
||||||
</WhiteCard>
|
</main>
|
||||||
</AuthShell>
|
</MobileShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,14 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Facebook,
|
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Instagram,
|
|
||||||
Linkedin,
|
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { AuthShell } from "../components/auth/AuthShell";
|
import { AuthShell } from "../components/auth/AuthShell";
|
||||||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||||||
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
import {
|
import {
|
||||||
getPhotoWallUploadMeta,
|
getPhotoWallUploadMeta,
|
||||||
getVisitorLoginPreview,
|
getVisitorLoginPreview,
|
||||||
|
|
@ -79,13 +77,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
|
||||||
{ key: "whatsapp", label: "WhatsApp" },
|
{ 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() {
|
export function PhotoWallUploadPage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -474,7 +465,7 @@ export function PhotoWallUploadPage() {
|
||||||
onClick={() => void onShare(item.key)}
|
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]"
|
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}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -919,7 +919,7 @@ export function QuizPollTakePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const footerBtnClass =
|
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`;
|
const footerSecondaryClass = `${footerBtnClass} flex-1 border border-zinc-300 bg-zinc-100 text-gray-700`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -989,7 +989,7 @@ export function QuizPollTakePage() {
|
||||||
<label
|
<label
|
||||||
key={opt._id}
|
key={opt._id}
|
||||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
|
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
|
<input
|
||||||
|
|
@ -1011,7 +1011,7 @@ export function QuizPollTakePage() {
|
||||||
value={typeof answers[q._id] === "string" ? answers[q._id] : ""}
|
value={typeof answers[q._id] === "string" ? answers[q._id] : ""}
|
||||||
onChange={(e) => setText(q, e.target.value)}
|
onChange={(e) => setText(q, e.target.value)}
|
||||||
rows={3}
|
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"
|
placeholder="Your answer"
|
||||||
/>
|
/>
|
||||||
) : q.type === "rating" ? (
|
) : q.type === "rating" ? (
|
||||||
|
|
@ -1024,7 +1024,7 @@ export function QuizPollTakePage() {
|
||||||
className={`size-10 rounded-full border text-sm font-semibold ${
|
className={`size-10 rounded-full border text-sm font-semibold ${
|
||||||
answers[q._id] === String(n)
|
answers[q._id] === String(n)
|
||||||
? "border-[var(--theme-button-color)] bg-[var(--theme-button-color] text-white"
|
? "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}
|
{n}
|
||||||
|
|
@ -1036,8 +1036,8 @@ export function QuizPollTakePage() {
|
||||||
{q.options.map((opt) => (
|
{q.options.map((opt) => (
|
||||||
<label
|
<label
|
||||||
key={opt._id}
|
key={opt._id}
|
||||||
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
|
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-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)]"
|
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
|
<input
|
||||||
|
|
@ -1045,9 +1045,9 @@ export function QuizPollTakePage() {
|
||||||
name={`q-${q._id}`}
|
name={`q-${q._id}`}
|
||||||
checked={answers[q._id] === opt._id}
|
checked={answers[q._id] === opt._id}
|
||||||
onChange={() => setSingle(q, 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>
|
<span>{opt.text}</span>
|
||||||
<QuestionMedia url={opt.imageUrl} alt="" />
|
<QuestionMedia url={opt.imageUrl} alt="" />
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1132,7 +1132,7 @@ export function QuizPollTakePage() {
|
||||||
type="button"
|
type="button"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onClick={() => void runSubmit()}
|
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}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -126,20 +126,21 @@ export function RewardsPage() {
|
||||||
const signed = row.type === "redeemed" ? "-" : "+";
|
const signed = row.type === "redeemed" ? "-" : "+";
|
||||||
const cls =
|
const cls =
|
||||||
row.type === "redeemed"
|
row.type === "redeemed"
|
||||||
? "bg-[#fff0f0] text-[#c02424]"
|
? "bg-[#fff0f0 text-[#c02424]"
|
||||||
: row.type === "earned"
|
: row.type === "earned"
|
||||||
? "bg-[#ebffee] text-[#009951]"
|
? "bg-[#ebffee text-[#009951]"
|
||||||
: "bg-[#f3f4f6] text-[#24282f]";
|
: "bg-[#f3f4f6 text-[#24282f]";
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={row._id}
|
key={row._id}
|
||||||
className="flex items-center gap-4 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
|
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}
|
{signed}
|
||||||
{row.points} pts
|
{row.points} pts
|
||||||
</span>
|
</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)}
|
{historyLabel(row.source, row.description)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,14 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Facebook,
|
|
||||||
Instagram,
|
|
||||||
Linkedin,
|
|
||||||
Share2,
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
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 { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||||||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
import { PublicEventChrome } from "../components/PublicEventChrome";
|
||||||
|
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||||||
import {
|
import {
|
||||||
getSocialShareMeta,
|
getSocialShareMeta,
|
||||||
getVisitorLoginPreview,
|
getVisitorLoginPreview,
|
||||||
|
|
@ -41,13 +40,29 @@ import {
|
||||||
type EbVisitorContext,
|
type EbVisitorContext,
|
||||||
} from "../types/visitorContext";
|
} from "../types/visitorContext";
|
||||||
|
|
||||||
function WhiteCard({ children }: { children: ReactNode }) {
|
function SocialShareLayout({
|
||||||
|
children,
|
||||||
|
meta,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
meta?: SocialShareMeta | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
<MobileShell className="relative bg-transparent">
|
||||||
<div className="w-full rounded-xl shadow-md">
|
{meta ? (
|
||||||
<div className="rounded-xl card-bg px-6 py-6">{children}</div>
|
<PublicEventChrome
|
||||||
</div>
|
event={meta.event}
|
||||||
</div>
|
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" },
|
{ 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() {
|
export function SocialSharePage() {
|
||||||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -90,6 +98,7 @@ export function SocialSharePage() {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [picking, setPicking] = useState(false);
|
const [picking, setPicking] = useState(false);
|
||||||
const [done, setDone] = useState<DoneState | null>(null);
|
const [done, setDone] = useState<DoneState | null>(null);
|
||||||
|
const [resultImageReady, setResultImageReady] = useState(false);
|
||||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||||
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
||||||
const [shareFile, setShareFile] = useState<File | null>(null);
|
const [shareFile, setShareFile] = useState<File | null>(null);
|
||||||
|
|
@ -125,6 +134,15 @@ export function SocialSharePage() {
|
||||||
};
|
};
|
||||||
}, [cropSession]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!done?.fileUrl) return;
|
if (!done?.fileUrl) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -223,6 +241,7 @@ export function SocialSharePage() {
|
||||||
try {
|
try {
|
||||||
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
|
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
|
||||||
setShareFile(null);
|
setShareFile(null);
|
||||||
|
setResultImageReady(false);
|
||||||
setDone({
|
setDone({
|
||||||
previewUrl: result.previewUrl,
|
previewUrl: result.previewUrl,
|
||||||
fileUrl: result.fileUrl,
|
fileUrl: result.fileUrl,
|
||||||
|
|
@ -244,6 +263,7 @@ export function SocialSharePage() {
|
||||||
|
|
||||||
const resetForNew = () => {
|
const resetForNew = () => {
|
||||||
setDone(null);
|
setDone(null);
|
||||||
|
setResultImageReady(false);
|
||||||
setShareFile(null);
|
setShareFile(null);
|
||||||
setActionMsg(null);
|
setActionMsg(null);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
|
@ -346,59 +366,61 @@ export function SocialSharePage() {
|
||||||
|
|
||||||
if (!uploadToken) {
|
if (!uploadToken) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<SocialShareLayout>
|
||||||
<WhiteCard>
|
<p className="text-center text-sm text-red-600">Missing link</p>
|
||||||
<p className="text-center text-sm text-red-600">Missing link</p>
|
</SocialShareLayout>
|
||||||
</WhiteCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<SocialShareLayout>
|
||||||
<WhiteCard>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
|
||||||
<p className="text-center text-sm text-[#666]">Loading…</p>
|
</SocialShareLayout>
|
||||||
</WhiteCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err && !meta) {
|
if (err && !meta) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<SocialShareLayout>
|
||||||
<WhiteCard>
|
<p className="text-center text-sm text-red-600">{err}</p>
|
||||||
<p className="text-center text-sm text-red-600">{err}</p>
|
</SocialShareLayout>
|
||||||
</WhiteCard>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<SocialShareLayout meta={meta}>
|
||||||
<WhiteCard>
|
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||||
{meta?.event ? (
|
<h2 className="mb-2 text-center text-[22px] font-semibold">Your photo</h2>
|
||||||
<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>
|
|
||||||
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
<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.
|
Here's your framed photo. You can download or share it.
|
||||||
</p>
|
</p>
|
||||||
{(done.fileUrl || done.previewUrl) ? (
|
{(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
|
<img
|
||||||
ref={resultImgRef}
|
ref={resultImgRef}
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
src={done.previewUrl || done.fileUrl || undefined}
|
src={done.previewUrl || done.fileUrl || undefined}
|
||||||
alt="Your framed photo"
|
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) => {
|
onError={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
if (img.crossOrigin) {
|
if (img.crossOrigin) {
|
||||||
|
|
@ -408,11 +430,15 @@ export function SocialSharePage() {
|
||||||
}
|
}
|
||||||
if (done.fileUrl && img.src !== done.fileUrl) {
|
if (done.fileUrl && img.src !== done.fileUrl) {
|
||||||
img.src = done.fileUrl;
|
img.src = done.fileUrl;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setResultImageReady(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -427,7 +453,7 @@ export function SocialSharePage() {
|
||||||
onClick={() => void onDownload()}
|
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"
|
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
|
Download photo
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -443,20 +469,20 @@ export function SocialSharePage() {
|
||||||
onClick={() => void onNativeShare()}
|
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"
|
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
|
Share
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{enabledSharePlatforms.map((item) => (
|
{enabledSharePlatforms.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onShare(item.key)}
|
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} />
|
<SharePlatformIcon platform={item.key} />
|
||||||
{item.label}
|
{/* {item.label} */}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -464,27 +490,17 @@ export function SocialSharePage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||||||
</WhiteCard>
|
</section>
|
||||||
</AuthShell>
|
</SocialShareLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell showLogo={false}>
|
<SocialShareLayout meta={meta}>
|
||||||
<WhiteCard>
|
<section className="w-full card-bg rounded-lg shadow-md p-6">
|
||||||
{meta?.event ? (
|
|
||||||
<div className="mb-4">
|
|
||||||
<PhotoWallEventHeader
|
|
||||||
event={meta.event}
|
|
||||||
display={meta.eventHeader || null}
|
|
||||||
eyebrow="Social Share"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{meta?.accessMode === "login" && !loggedIn ? (
|
{meta?.accessMode === "login" && !loggedIn ? (
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create and share your photo.</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -496,7 +512,7 @@ export function SocialSharePage() {
|
||||||
</div>
|
</div>
|
||||||
) : !selectedFrame ? (
|
) : !selectedFrame ? (
|
||||||
<div className="space-y-4">
|
<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>
|
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick a frame, then upload your photo.</p>
|
||||||
{!meta?.frames?.length ? (
|
{!meta?.frames?.length ? (
|
||||||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No frames available yet.</p>
|
<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}
|
key={frame._id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedFrame(frame)}
|
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">
|
<div className="aspect-square w-full">
|
||||||
{frame.frameImageUrl ? (
|
{frame.frameImageUrl ? (
|
||||||
|
|
@ -532,7 +548,7 @@ export function SocialSharePage() {
|
||||||
{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}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -540,13 +556,13 @@ export function SocialSharePage() {
|
||||||
clearSelectedPhoto();
|
clearSelectedPhoto();
|
||||||
setErr(null);
|
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
|
← Change frame
|
||||||
</button>
|
</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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
@ -576,12 +592,12 @@ export function SocialSharePage() {
|
||||||
onClick={() => void onSubmit()}
|
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"
|
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>
|
</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}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WhiteCard>
|
</section>
|
||||||
|
|
||||||
{cropSession && cropTarget ? (
|
{cropSession && cropTarget ? (
|
||||||
<PhotoWallCropModal
|
<PhotoWallCropModal
|
||||||
|
|
@ -599,6 +615,6 @@ export function SocialSharePage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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