Compare commits

...

2 Commits

Author SHA1 Message Date
Amey Bhagwat 0b44e15b50 Merge branch 'main' of http://192.168.1.200:3000/Stratawiz/EventBuzz_Website
* 'main' of http://192.168.1.200:3000/Stratawiz/EventBuzz_Website:
  Photo Search

# Conflicts:
#	src/pages/PhotoSearchPage.tsx
2026-08-17 19:34:55 +05:30
Amey Bhagwat b16b5828a4 Updated changes in the layout and all activity pages. 2026-08-17 19:19:08 +05:30
15 changed files with 856 additions and 502 deletions

View File

@ -131,7 +131,7 @@ export function HeaderMobile({
return (
<header
className="sticky top-0 z-20 flex h-[60px] shrink-0 items-center border-b border-white/40 bg-[var(--theme-header-color)] backdrop-blur-sm px-4 shadow-xs"
className="sticky top-0 z-20 flex h-[60px] shrink-0 items-center border-b-0 border-white/40 bg-[var(--theme-header-color)] backdrop-blur-sm px-4 shadow-xs"
data-name="Header_Mobile"
>
<div className="flex w-full items-center justify-between gap-2">

View File

@ -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}
</>
);
}

View File

@ -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} />;
}

View File

@ -33,6 +33,9 @@ export function ThemeGate({ ready, children }: ThemeGateProps) {
return;
}
// Cache hit / already themed: overlay was never shown, so skip the hold+fade.
if (!overlayVisible) return;
const elapsed = Date.now() - shownAtRef.current;
const holdMs = Math.max(0, MIN_VISIBLE_MS - elapsed);
@ -48,7 +51,7 @@ export function ThemeGate({ ready, children }: ThemeGateProps) {
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current);
};
}, [ready]);
}, [ready, overlayVisible]);
return (
<>

View File

@ -1,10 +1,30 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { getPublicVisitorTheme } from "../lib/api";
import { useEffect, useMemo, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import {
getAiAvatarMeta,
getPhotoSearchMeta,
getPhotoWallPresent,
getPhotoWallUploadMeta,
getPublicVisitorTheme,
getSocialShareMeta,
} from "../lib/api";
import { applyEventTheme } from "../theme/eventTheme";
import { readCachedVisitorTheme, writeCachedVisitorTheme } from "../theme/visitorThemeCache";
import { readStoredVisitorContext } from "../types/visitorContext";
function resolveEventIdFromSearch(searchParams: URLSearchParams): string {
type PublicTokenSource =
| "ai-avatar"
| "photo-search"
| "social-share"
| "photo-wall"
| "photo-wall-present";
type ThemeLookup =
| { kind: "none" }
| { kind: "eventId"; eventId: string }
| { kind: "token"; source: PublicTokenSource; token: string };
function eventIdFromQuery(searchParams: URLSearchParams): string {
const fromEventId = searchParams.get("event_id")?.trim() || "";
if (fromEventId) return fromEventId;
const fromEventIdCamel = searchParams.get("eventId")?.trim() || "";
@ -12,11 +32,48 @@ function resolveEventIdFromSearch(searchParams: URLSearchParams): string {
const type = searchParams.get("type");
const id = searchParams.get("id")?.trim() || "";
if (type === "event" && id) return id;
return readStoredVisitorContext()?.eventId || "";
return "";
}
function resolveThemeLookup(pathname: string, searchParams: URLSearchParams): ThemeLookup {
const fromQuery = eventIdFromQuery(searchParams);
if (fromQuery) return { kind: "eventId", eventId: fromQuery };
const tokenRoutes: Array<{ re: RegExp; source: PublicTokenSource }> = [
{ re: /^\/ai-avatar\/([^/]+)/, source: "ai-avatar" },
{ re: /^\/photo-search\/([^/]+)/, source: "photo-search" },
{ re: /^\/social-share\/([^/]+)/, source: "social-share" },
{ re: /^\/photo-wall-present\/([^/]+)/, source: "photo-wall-present" },
{ re: /^\/photo-wall\/([^/]+)/, source: "photo-wall" },
];
for (const { re, source } of tokenRoutes) {
const match = pathname.match(re);
const token = match?.[1] ? decodeURIComponent(match[1]).trim() : "";
if (token) return { kind: "token", source, token };
}
const stored = readStoredVisitorContext()?.eventId?.trim() || "";
if (stored) return { kind: "eventId", eventId: stored };
return { kind: "none" };
}
async function eventIdFromPublicToken(source: PublicTokenSource, token: string): Promise<string> {
if (source === "ai-avatar") return (await getAiAvatarMeta(token)).eventId;
if (source === "photo-search") return (await getPhotoSearchMeta(token)).eventId;
if (source === "social-share") return (await getSocialShareMeta(token)).eventId;
if (source === "photo-wall") return (await getPhotoWallUploadMeta(token)).eventId;
return (await getPhotoWallPresent(token)).eventId;
}
function applyCachedTheme(eventId: string): boolean {
const cached = readCachedVisitorTheme(eventId);
if (!cached) return false;
applyEventTheme(cached.theme);
return true;
}
export type UseEventThemeResult = {
/** Event id used for the public theme request (empty when none). */
/** Event id used for the public theme request (empty when none / still resolving). */
eventId: string;
/**
* True when either there is no event to theme, or the theme request has
@ -26,39 +83,96 @@ export type UseEventThemeResult = {
};
/**
* Fetches GET /api/public/visitor-theme/:eventId (from URL / stored context)
* and applies CSS variables to `<html>`.
* Loads GET /api/public/visitor-theme/:eventId once per event and caches it.
* In-app navigation for the same event reuses the cache (no loader / no refetch).
* Activity token routes resolve event id first, then use the same cache.
*/
export function useEventTheme(): UseEventThemeResult {
const [searchParams] = useSearchParams();
const eventId = resolveEventIdFromSearch(searchParams);
const [ready, setReady] = useState(!eventId);
const { pathname } = useLocation();
const lookup = useMemo(
() => resolveThemeLookup(pathname, searchParams),
[pathname, searchParams],
);
const kind = lookup.kind;
const lookupEventId = lookup.kind === "eventId" ? lookup.eventId : "";
const tokenSource = lookup.kind === "token" ? lookup.source : "";
const token = lookup.kind === "token" ? lookup.token : "";
const [ready, setReady] = useState(() => {
if (kind === "none") return true;
if (kind === "eventId") return applyCachedTheme(lookupEventId);
const cached = readCachedVisitorTheme();
if (!cached) return false;
applyEventTheme(cached.theme);
return true;
});
const [resolvedEventId, setResolvedEventId] = useState(lookupEventId);
useEffect(() => {
if (!eventId) {
if (kind === "none") {
setResolvedEventId("");
setReady(true);
return;
}
let cancelled = false;
setReady(false);
getPublicVisitorTheme(eventId)
.then((data) => {
if (kind === "eventId") {
setResolvedEventId(lookupEventId);
if (applyCachedTheme(lookupEventId)) {
setReady(true);
return;
}
setReady(false);
void (async () => {
try {
const data = await getPublicVisitorTheme(lookupEventId);
if (cancelled) return;
writeCachedVisitorTheme(data.eventId || lookupEventId, data.theme);
applyEventTheme(data.theme);
setReady(true);
})
.catch(() => {
} 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;
// Still unblock UI; keep defaults / any previously applied theme.
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);
} catch {
/* keep defaults / previous theme */
} finally {
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [eventId]);
}, [kind, lookupEventId, tokenSource, token]);
return { eventId, ready };
return { eventId: resolvedEventId, ready };
}

View File

@ -8,16 +8,15 @@ import {
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Loader2,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getAiAvatarMeta,
getVisitorLoginPreview,
@ -42,13 +41,29 @@ import {
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children }: { children: ReactNode }) {
function AiAvatarLayout({
children,
meta,
}: {
children: ReactNode;
meta?: AiAvatarMeta | null;
}) {
return (
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-md">
<div className="rounded-xl bg-whit card-bg px-6 py-6">{children}</div>
</div>
</div>
<MobileShell className="relative bg-transparent">
{meta ? (
<PublicEventChrome
event={meta.event}
eventHeader={meta.eventHeader}
eventNameFallback={meta.eventName}
eventId={meta.eventId}
/>
) : (
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
)}
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="flex w-full max-w-none flex-col px-4">{children}</div>
</main>
</MobileShell>
);
}
@ -85,13 +100,6 @@ const GENERATING_PIPELINE_MESSAGES = [
"Almost there — finishing your avatar…",
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
export function AiAvatarPage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
@ -255,7 +263,7 @@ export function AiAvatarPage() {
});
}
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : "Could not create avatar");
setErr(e2 instanceof Error ? e2.message : "Could not generate avatar");
} finally {
setSubmitting(false);
}
@ -365,31 +373,25 @@ export function AiAvatarPage() {
if (!uploadToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<AiAvatarLayout>
<p className="text-center text-sm text-red-600">Missing link</p>
</WhiteCard>
</AuthShell>
</AiAvatarLayout>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
<AiAvatarLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
</AiAvatarLayout>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<AiAvatarLayout>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
</AiAvatarLayout>
);
}
@ -398,22 +400,13 @@ export function AiAvatarPage() {
const total = GENERATING_PIPELINE_MESSAGES.length;
const progressPct = Math.round((step / total) * 100);
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<AiAvatarLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
<div className="flex flex-col items-center py-4 text-center">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--theme-button-color)]/10">
<Loader2 className="h-8 w-8 animate-spin text-[var(--theme-button-color)]" aria-hidden />
</div>
<h1 className="mb-2 text-[22px] font-semibold text-[var(--theme-card-text-color)]">Creating your avatar</h1>
<h2 className="mb-2 text-[22px] font-semibold text-[var(--theme-card-text-color)]">Generating your avatar</h2>
<p
key={pipelineMsgIndex}
className="mb-5 min-h-[2.5rem] text-sm text-[var(--theme-card-text-color)]/80 transition-opacity duration-300"
@ -429,25 +422,16 @@ export function AiAvatarPage() {
</div>
<p className="text-xs text-[var(--theme-card-text-color)]/60">This can take up to a minute — hang tight</p>
</div>
</WhiteCard>
</AuthShell>
</section>
</AiAvatarLayout>
);
}
if (done) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold">Your avatar</h1>
<AiAvatarLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
<h2 className="mb-2 text-center text-[22px] font-semibold">Your avatar</h2>
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
Here's your AI avatar. You can download or share it.
</p>
@ -479,7 +463,7 @@ export function AiAvatarPage() {
onClick={resetForNew}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white"
>
Create another
Generate another
</button>
{allowDownload ? (
<button
@ -487,7 +471,7 @@ export function AiAvatarPage() {
onClick={() => void onDownload()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
>
<Download className="h-4 w-4" />
<Download className="size-5" />
Download photo
</button>
) : null}
@ -503,11 +487,11 @@ export function AiAvatarPage() {
onClick={() => void onNativeShare()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
>
<Share2 className="h-4 w-4" />
<Share2 className="size-5" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-wrap justify-center gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
@ -515,8 +499,8 @@ export function AiAvatarPage() {
onClick={() => void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
>
<ShareIcon platform={item.key} />
{item.label}
<SharePlatformIcon platform={item.key} />
{/* {item.label} */}
</button>
))}
</div>
@ -524,27 +508,17 @@ export function AiAvatarPage() {
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
</WhiteCard>
</AuthShell>
</section>
</AiAvatarLayout>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="AI Avatar"
/>
</div>
) : null}
<AiAvatarLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">AI Avatar</h1>
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">AI Avatar</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create your AI avatar.</p>
<button
type="button"
@ -556,7 +530,7 @@ export function AiAvatarPage() {
</div>
) : !selectedAvatar ? (
<div className="space-y-4">
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Choose an avatar</h1>
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Choose an avatar</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick an avatar, then upload your selfie.</p>
{!meta?.avatars?.length ? (
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No avatars available yet.</p>
@ -567,7 +541,7 @@ export function AiAvatarPage() {
key={avatar._id}
type="button"
onClick={() => setSelectedAvatar(avatar)}
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/50"
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/30"
>
<div className="aspect-square w-full">
{avatar.imageUrl ? (
@ -592,7 +566,7 @@ export function AiAvatarPage() {
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4 text-center">
<div className="space-y-4 text-left">
<button
type="button"
onClick={() => {
@ -600,11 +574,11 @@ export function AiAvatarPage() {
clearSelectedPhoto();
setErr(null);
}}
className="text-sm font-semibold theme-btn-link"
className="text-sm font-semibold text-[var(--theme-card-text-color)]"
>
← Change avatar
</button>
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload selfie</h1>
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload selfie</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
Avatar: <span className="font-semibold">{selectedAvatar.name}</span>
</p>
@ -634,14 +608,14 @@ export function AiAvatarPage() {
type="button"
disabled={!file || submitting}
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-55"
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
>
{submitting ? "Creating avatar…" : "Create avatar"}
{submitting ? "Generating avatar…" : "Generate avatar"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</WhiteCard>
</section>
{cropSession && cropTarget ? (
<PhotoWallCropModal
@ -659,6 +633,6 @@ export function AiAvatarPage() {
}}
/>
) : null}
</AuthShell>
</AiAvatarLayout>
);
}

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Calendar, MapPin } from "lucide-react";
import { Calendar, ChevronDown, MapPin } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import {
getVisitorEventAgenda,
@ -83,6 +83,7 @@ export function EventLandingPage() {
}, [eventId]);
const [dayOpen, setDayOpen] = useState<Record<string, boolean>>({});
const [eventMetaOpen, setEventMetaOpen] = useState(false);
useEffect(() => {
setDayOpen({});
@ -118,6 +119,9 @@ export function EventLandingPage() {
const ratingsHref = scopedQs ? `/ratings?${scopedQs}` : "/ratings";
const bannerSrc = event?.bannerImage?.trim() || event?.image?.trim() || "";
const eventDescription = event?.description?.trim() || event?.subtitle?.trim() || "";
const eventLocation = event?.location?.trim() || "";
const hasExpandableEventMeta = Boolean(eventDescription || schedule || eventLocation);
if (!eventId) {
return (
@ -147,13 +151,31 @@ export function EventLandingPage() {
return (
<MobileShell className="relative bg-transparent">
<HeaderMobile logoUrl={event.logoUrl} eventId={eventId || null} showBack={false} />
<EventBanner imageUrl={bannerSrc} alt={event.name} className="rounded-b-lg shadow-sm" />
<EventBanner imageUrl={bannerSrc} alt={event.name} className="shadow-sm" />
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="flex w-full max-w-none flex-col gap-6 px-4">
<section className="w-full">
<div className="w-full rounded-lg card-bg p-4 shadow-md">
<h1 className="text-lg font-semibold">{event.name}</h1>
<div className="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 ? (
@ -164,26 +186,32 @@ export function EventLandingPage() {
<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">
{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 className="">{schedule || "—"}</span>
<span>{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>
) : 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>
</section>
) : null}
</div>
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="flex w-full max-w-none flex-col gap-6 px-4">
{bookmarkedIds.size > 0 ? (
<section className="w-full card-bg rounded-lg shadow-md p-4">

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Award } from "lucide-react";
import { Award, User } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
import { getVisitorEventLeaderboard } from "../lib/api";
import { getAttendeeToken } from "../lib/attendeeAuth";
@ -11,10 +11,10 @@ import type { VisitorLeaderboardRow } from "../lib/api";
function Medal({ tier }: { tier: "gold" | "silver" | "bronze" }) {
const cls =
tier === "gold"
? "text-amber-500"
? "text-amber-400"
: tier === "silver"
? "text-slate-400"
: "text-amber-800";
: "text-rose-400";
return <Award className={`size-6 ${cls}`} strokeWidth={1.5} />;
}
@ -92,7 +92,7 @@ export function LeaderboardPage() {
</div> */}
<div className="flex flex-col gap-1">
<h1 className="mt-1 text-lg font-semibold">Your Rank</h1>
<span className="rounded-full bg-green-100 text-green-700 px-3 py-1 text-sm inline-block w-fit">
<span className="rounded-lg bg-green-100 text-green-700 px-2 py-1 text-sm font-semibold inline-block w-fit">
{!signedIn ? "Sign in" : loading ? "…" : yourTotal != null ? `${yourTotal} points` : "0 points"}
</span>
</div>
@ -137,15 +137,31 @@ export function LeaderboardPage() {
{top5.map((row) => {
const medal = medalForRank(row.rank);
return (
// <li
// key={row.participantId}
// className="flex items-center gap-3 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
// >
// <span className="shrink-0 rounded bg-green-100 text-green-700 px-3 py-2 text-xs font-medium shadow-sm">
// #{row.rank} · {row.total} pts
// </span>
// <span className="min-w-0 flex-1 text-sm">{row.displayName}</span>
// {medal ? <Medal tier={medal} /> : null}
// </li>
<li
key={row.participantId}
className="flex items-center gap-3 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
className="flex items-center gap-3 border-b border-[var(--theme-card-text-color)]/5 pb-3 pr-6 last:border-0 relative"
>
<span className="shrink-0 rounded bg-green-100 text-green-700 px-3 py-2 text-xs font-medium shadow-sm">
#{row.rank} · {row.total} pts
<div className="flex size-10 items-center justify-center rounded-full bg-[var(--theme-button-color)]/10">
<User className="size-5 text-[var(theme-card-text-color)]/20" strokeWidth={2} />
</div>
<div className="flex flex-col gap-1 grow-1">
<span className="min-w-0 flex-1 text-sm font-semibold">{row.displayName}</span>
<span className="shrink-0 text-green-500 text-xs">
#{row.rank} <span className="text-[var(--theme-card-text-color)]/70 w-[4px] h-[4px] rounded-full inline-block mx-1">·</span> {row.total} pts
</span>
<span className="min-w-0 flex-1 text-sm">{row.displayName}</span>
{medal ? <Medal tier={medal} /> : null}
</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>
);
})}

View File

@ -221,8 +221,8 @@ export function LoginPage() {
if (!ctx) {
return (
<AuthShell>
<div className="max-w-md px-4 text-center text-sm text-white/95">
<AuthShell showLogo={false}>
<div className="max-w-md px-4 text-center card-bg rounded-lg shadow-md text-sm">
<p>Open this app from your event link or QR code to sign in.</p>
</div>
</AuthShell>
@ -237,12 +237,12 @@ export function LoginPage() {
const showInviteField = reg.registrationMode === "Invite Only";
return (
<AuthShell>
<AuthShell showLogo={false}>
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl card-bg">
<div className="w-full">
<div className="rounded-xl card-bg shadow-md">
<div className="px-6 pb-2 pt-6">
<div className="flex h-[103px] items-center justify-center rounded-xl border border-[var(--theme-card-color)]/25 px-4">
<div className="flex h-[103px] items-center justify-center px-4">
{preview?.logoUrl ? (
<img
src={preview.logoUrl}
@ -256,10 +256,11 @@ export function LoginPage() {
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-8 px-6 pb-8 pt-2">
<div className="flex flex-col items-center gap-4 text-center">
<p className="text-base font-semibold text-[var(--theme-card-text-color)]">Log in to</p>
<p className="text-base font-semibold text-[var(--theme-card-text-color)]/60">Log in to</p>
<div className="min-w-0 space-y-1">
<p className="text-[28px] font-bold leading-tight text-[var(--theme-card-text-color)] sm:text-[32px]">
<p className="text-xl font-bold leading-tight text-[var(--theme-card-text-color)] sm:text-2xl">
{headline}
{/* Novel Lung Health Summit 2026 Kochi 22nd & 23rd August 2026 */}
</p>
{preview?.subtitle ? (
<p className="text-sm font-medium text-[var(--theme-card-text-color)]/80">{preview.subtitle}</p>
@ -276,21 +277,21 @@ export function LoginPage() {
</p>
) : null}
{showMeta ? (
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-[var(--theme-card-text-color)]">
<div className="flex flex-col items-start justify-center gap-2 text-xs text-[var(--theme-card-text-color)] border border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/3 rounded-lg p-2">
{schedule ? (
<>
<Calendar className="size-4 shrink-0" strokeWidth={1.75} />
<div className="flex items-center gap-2">
<Calendar className="size-4 shrink-0 text-[var(--theme-card-text-color)]/70" strokeWidth={1.75} />
<span className="">{schedule}</span>
</>
</div>
) : null}
{schedule && preview?.location?.trim() ? (
<span className="hidden h-3 w-px bg-[rgba(36,40,47,0.5)] sm:inline" aria-hidden />
<span className="hidden h-3 w-px bg-[var(--theme-card-color)]/50] sm:inline" aria-hidden />
) : null}
{preview?.location?.trim() ? (
<>
<MapPin className="size-4 shrink-0" strokeWidth={1.75} />
<span className="">{preview.location.trim()}</span>
</>
<div className="flex gap-2">
<MapPin className="size-4 shrink-0 text-[var(--theme-card-text-color)]/70" strokeWidth={1.75} />
<span className="text-left">{preview.location.trim()}</span>
</div>
) : null}
</div>
) : null}
@ -303,7 +304,7 @@ export function LoginPage() {
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
placeholder="Enter your invite code"
className="h-[44px] w-full rounded-lg border border-[rgba(116,112,112,0.3)] bg-[rgba(157,157,172,0.07)] px-4 text-sm text-[var(--theme-card-text-color)] transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
className="h-[44px] w-full rounded-lg border border-[var(--theme-card-text-color)]/10 bg-[var(--theme-card-color)]/40 px-4 text-sm text-[var(--theme-card-text-color)] placeholder:text-[var(--theme-card-text-color)]/60 transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
/>
</label>
) : null}

View File

@ -8,15 +8,14 @@ import {
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Search,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getPhotoSearchMeta,
getVisitorLoginPreview,
@ -40,13 +39,12 @@ import {
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children, wide }: { children: ReactNode; wide?: boolean }) {
function PhotoSearchShell({ children }: { children: ReactNode }) {
return (
<div className={`flex w-full ${wide ? "max-w-[480px]" : "max-w-[354px]"} flex-col items-center`}>
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
<div className="rounded-xl card-bg text-[var(--theme-card-text-color)] shadow-md px-6 py-6">{children}</div>
</div>
</div>
<MobileShell className="relative bg-transparent">
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
{children}
</MobileShell>
);
}
@ -59,62 +57,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
{ key: "whatsapp", label: "WhatsApp" },
];
function ShareIcon({
platform,
className = "h-4 w-4",
}: {
platform: keyof PhotoWallSharePlatforms;
className?: string;
}) {
if (platform === "linkedin") return <Linkedin className={className} />;
if (platform === "facebook") return <Facebook className={className} />;
if (platform === "instagram") return <Instagram className={className} />;
if (platform === "x") {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.253 5.622L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
if (platform === "whatsapp") {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.435 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
);
}
if (platform === "snapchat") {
return (
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden>
<path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.322 4.847a3.753 3.753 0 002.3.718c.386 0 .829-.119.829-.538 0-.364.214-1.28.917-1.28.646 0 1.239.685.839 1.961-.165.456.428 1.47 1.56 1.47.646 0 .839.321.839.538 0 .718-1.239 2.049-4.026 2.119.373 2.627 2.362 3.858 3.687 4.097.272.047.53.315.53.639 0 .593-.61.793-1.239.793-.214 0-.646-.047-1.3-.047-.646 0-1.364.119-2.013.538-1.239.793-2.666 2.134-5.287 2.134-2.7 0-4.026-1.341-5.287-2.134-.646-.419-1.364-.538-2.013-.538-.654 0-1.086.047-1.3.047-.629 0-1.239-.2-1.239-.793 0-.324.258-.592.53-.639 1.325-.239 3.314-1.47 3.687-4.097-2.787-.07-4.026-1.401-4.026-2.119 0-.217.193-.538.839-.538 1.132 0 1.725-1.014 1.56-1.47-.4-1.276.193-1.961.839-1.961.703 0 .917.916.917 1.28 0 .419.443.538.829.538a3.753 3.753 0 002.3-.718c-.081-1.628-.207-3.654.322-4.847C7.859 1.069 11.216.793 12.206.793z" />
</svg>
);
}
return <Share2 className={className} />;
}
function IconActionButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#ddd] bg-white text-[#333] hover:bg-[#fafafa]"
>
{children}
</button>
);
}
function MatchCard({
match,
eventName,
@ -218,8 +160,7 @@ function MatchCard({
};
return (
<div className={`rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]${shareMenuOpen ? " relative z-20" : ""}`}>
<div className="overflow-hidden rounded-t-lg bg-white">
<div className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/10 relative">
<img
ref={imgRef}
crossOrigin="anonymous"
@ -235,47 +176,42 @@ function MatchCard({
}
}}
/>
</div>
<div className="p-2">
<div ref={shareMenuRef} className="flex flex-nowrap items-center justify-center gap-1.5">
<div className="flex items-center justify-end p-2 gap-3 rounded-lg absolute right-[5px] bottom-[5px] bg-[var(--theme-card-color)]/85 backdrop-blur-xs">
{allowDownload ? (
<IconActionButton label="Download" onClick={() => void onDownload()}>
<Download className="h-4 w-4" />
</IconActionButton>
<button
type="button"
onClick={() => void onDownload()}
className="inline-flex h-[34px] w-[34px] items-center justify-center rounded-lg border-0 border-[var(--theme-card-text-color)]/45 bg-[var(--theme-button-color)] text-xs font-semibold text-[var(--theme-card-text-color)]"
>
<Download className="size-5" />
</button>
) : null}
{showShare ? (
<div className="relative">
<IconActionButton
label="Share"
onClick={() => {
if (useNativeShare) {
void onNativeShare();
return;
}
setShareMenuOpen((open) => !open);
}}
useNativeShare ? (
<button
type="button"
onClick={() => void onNativeShare()}
className="inline-flex h-[34px] w-[34px] items-center justify-center rounded-lg border-0 border-[var(--theme-card-text-color)]/45 bg-[var(--theme-card-color)]/80 text-xs font-semibold text-[var(--theme-card-text-color)]"
>
<Share2 className="h-4 w-4" />
</IconActionButton>
{showShare && !useNativeShare && shareMenuOpen ? (
<div className="absolute right-0 top-full z-30 mt-1 min-w-[11.5rem] divide-y divide-[#f0f0f0] overflow-hidden rounded-lg border border-[#e5e5e5] bg-white shadow-[0_8px_24px_rgba(0,0,0,0.16)]">
<Share2 className="size-5" />
</button>
) : (
<div className="flex flex-wrap justify-center gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-semibold text-[#333] hover:bg-[#f7f7f7]"
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)]"
>
<ShareIcon platform={item.key} className="h-4 w-4 shrink-0" />
{item.label}
<SharePlatformIcon platform={item.key} />
{/* {item.label} */}
</button>
))}
</div>
)
) : null}
</div>
) : null}
</div>
</div>
</div>
);
}
@ -326,6 +262,9 @@ export function PhotoSearchPage() {
const showShare = enabledSharePlatforms.length > 0;
const allowDownload = meta?.allowDownload !== false;
const eventId = meta?.eventId || "";
const eventName = meta?.event?.name?.trim() || meta?.eventName || "the event";
const clearFile = () => {
setFile(null);
setPreviewUrl((prev) => {
@ -391,50 +330,49 @@ export function PhotoSearchPage() {
if (!searchToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<PhotoSearchShell>
<main className="flex flex-1 items-center justify-center px-4 py-10">
<p className="text-center text-sm text-red-600">Missing search link</p>
</WhiteCard>
</AuthShell>
</main>
</PhotoSearchShell>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
<PhotoSearchShell>
<main className="flex flex-1 items-center justify-center px-4 py-10">
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
</main>
</PhotoSearchShell>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<PhotoSearchShell>
<main className="flex flex-1 items-center justify-center px-4 py-10">
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
</main>
</PhotoSearchShell>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard wide={Boolean(matches && matches.length > 0)}>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Photo Search"
<MobileShell className="relative bg-transparent">
<PublicEventChrome
event={meta?.event}
eventHeader={meta?.eventHeader}
eventNameFallback={meta?.eventName}
eventId={eventId}
/>
</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">
<div className="flex w-full max-w-none flex-col px-4">
<section className="w-full card-bg rounded-lg shadow-md p-6">
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Find Photos</h2>
<p className="mb-5 text-center text-sm text-[var(--theme-card-text-color)]/80">
Upload a selfie to find photos of you from {meta?.eventName || "the event"}.
Upload a selfie to find photos of you from {eventName}.
</p>
{meta?.accessMode === "login" && !loggedIn ? (
@ -459,7 +397,7 @@ export function PhotoSearchPage() {
className="hidden"
onChange={(e) => onPick(e.target.files?.[0] || null)}
/>
{previewUrl ? (
{previewUrl && !searched ? (
<div className="mb-3 overflow-hidden rounded-lg">
<img src={previewUrl} alt="Your selfie" className="mx-auto max-h-[240px] w-full object-contain" />
</div>
@ -467,38 +405,39 @@ export function PhotoSearchPage() {
<button
type="button"
onClick={() => fileRef.current?.click()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-[var(--theme-card-text-color)]"
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 photo" : "Choose photo"}
{file ? "Change your photo" : "Upload your photo"}
</button>
</div>
{file && !searched ? (
<button
type="button"
disabled={!file || searching}
onClick={() => void onSearch()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[var(--theme-button-color)]/85 disabled:opacity-55"
>
<Search className="h-4 w-4" />
{searching ? "Searching…" : "Find photos"}
</button>
) : null}
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
{searched ? (
<div className="space-y-3 pt-2">
<p className="text-center text-sm font-semibold text-[#333]">
<p className="text-center text-sm font-semibold text-[var(--theme-card-text-color)]">
{matches && matches.length > 0
? `${matches.length} match${matches.length === 1 ? "" : "es"}`
: "No matches found"}
</p>
{matches && matches.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-2">
{matches.map((m) => (
<MatchCard
key={m._id}
match={m}
eventName={meta?.eventName || "the event"}
eventName={eventName}
allowDownload={allowDownload}
showShare={showShare}
useNativeShare={useNativeShare}
@ -511,7 +450,9 @@ export function PhotoSearchPage() {
) : null}
</div>
)}
</WhiteCard>
</AuthShell>
</section>
</div>
</main>
</MobileShell>
);
}

View File

@ -9,16 +9,14 @@ import {
} from "react";
import {
Download,
Facebook,
ImagePlus,
Instagram,
Linkedin,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getPhotoWallUploadMeta,
getVisitorLoginPreview,
@ -79,13 +77,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
{ key: "whatsapp", label: "WhatsApp" },
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
export function PhotoWallUploadPage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
@ -474,7 +465,7 @@ export function PhotoWallUploadPage() {
onClick={() => void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
>
<ShareIcon platform={item.key} />
<SharePlatformIcon platform={item.key} />
{item.label}
</button>
))}

View File

@ -919,7 +919,7 @@ export function QuizPollTakePage() {
}
const footerBtnClass =
"rounded-lg px-3 py-1.5 text-center text-xs font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit";
"rounded-lg p-3 text-center font-semibold shadow-sm disabled:opacity-50 disabled:hover:bg-inherit";
const footerSecondaryClass = `${footerBtnClass} flex-1 border border-zinc-300 bg-zinc-100 text-gray-700`;
return (
@ -989,7 +989,7 @@ export function QuizPollTakePage() {
<label
key={opt._id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
selected ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10 text-[var(--theme-button-color)]" : "bg-[var(--theme-card-color)]/30 border-[var(--theme-card-color)]/15 text-[var(--theme-card-text-color)]"
selected ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10" : "border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/8 text-[var(--theme-card-text-color)]"
}`}
>
<input
@ -1011,7 +1011,7 @@ export function QuizPollTakePage() {
value={typeof answers[q._id] === "string" ? answers[q._id] : ""}
onChange={(e) => setText(q, e.target.value)}
rows={3}
className="mt-3 w-full rounded-md border border-[#e5e5ea] px-3 py-2 text-sm transition-all duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
className="mt-3 w-full rounded-md border border-[var(--theme-card-text-color)]/10 bg-[var(--theme-card-color)]/40 px-3 py-2 text-sm text-[var(--theme-card-text-color)] placeholder:text-[var(--theme-card-text-color)]/60 transition-all duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30 disabled:opacity-60"
placeholder="Your answer"
/>
) : q.type === "rating" ? (
@ -1024,7 +1024,7 @@ export function QuizPollTakePage() {
className={`size-10 rounded-full border text-sm font-semibold ${
answers[q._id] === String(n)
? "border-[var(--theme-button-color)] bg-[var(--theme-button-color] text-white"
: "border-[#e5e5ea] bg-white text-[#24282f]"
: "border-[var(--theme-card-text-color)] text-[var(--theme-card-text-color)]"
}`}
>
{n}
@ -1036,8 +1036,8 @@ export function QuizPollTakePage() {
{q.options.map((opt) => (
<label
key={opt._id}
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm ${
answers[q._id] === opt._id ? "border-[var(--theme-button-color)] bg-[var(--theme-card-color)]/10 text-[var(--theme-button-color)]" : "bg-[var(--theme-card-color)]/30 border-[var(--theme-card-color)]/15 text-[var(--theme-card-text-color)]"
className={`flex cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-sm transition-all duration-300 ease ${
answers[q._id] === opt._id ? "border-[var(--theme-button-color)] bg-[var(--theme-button-color)]/10" : "border-[var(--theme-card-text-color)]/5 bg-[var(--theme-card-text-color)]/8 text-[var(--theme-card-text-color)]"
}`}
>
<input
@ -1045,9 +1045,9 @@ export function QuizPollTakePage() {
name={`q-${q._id}`}
checked={answers[q._id] === opt._id}
onChange={() => setSingle(q, opt._id)}
className="mt-0.5"
className="mt-0.5 opacity-0 invisible w-[1px] h-[1px]"
/>
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 flex-1 flex-col gap-0">
<span>{opt.text}</span>
<QuestionMedia url={opt.imageUrl} alt="" />
</span>
@ -1132,7 +1132,7 @@ export function QuizPollTakePage() {
type="button"
disabled={submitting}
onClick={() => void runSubmit()}
className="w-full rounded-lg theme-btn-bg px-3 py-1.5 text-center text-xs font-semibold text-white shadow-sm disabled:opacity-60 disabled:hover:bg-[var(--theme-button-color)]/50"
className="w-full rounded-lg theme-btn-bg px-3 py-3 text-center text-sm font-semibold text-white shadow-sm disabled:opacity-60 disabled:hover:bg-[var(--theme-button-color)]/50"
>
{submitLabel}
</button>

View File

@ -126,20 +126,21 @@ export function RewardsPage() {
const signed = row.type === "redeemed" ? "-" : "+";
const cls =
row.type === "redeemed"
? "bg-[#fff0f0] text-[#c02424]"
? "bg-[#fff0f0 text-[#c02424]"
: row.type === "earned"
? "bg-[#ebffee] text-[#009951]"
: "bg-[#f3f4f6] text-[#24282f]";
? "bg-[#ebffee text-[#009951]"
: "bg-[#f3f4f6 text-[#24282f]";
return (
<li
key={row._id}
className="flex items-center gap-4 border-b border-[var(--theme-card-text-color)]/5 pb-3 last:border-0"
>
<span className={`shrink-0 rounded px-3 py-2 text-xs font-medium shadow-sm ${cls}`}>
<span className={`shrink-0 px-1 py-1 text-sm font-semibold ${cls}`}>
{signed}
{row.points} pts
</span>
<span className="text-sm leading-snug text-[var(--theme-card-text-color)]/70">
<span className="w-[2px] h-[2px] bg-[var(--theme-card-text-color)]/30 rounded-full"></span>
<span className="text-sm leading-snug text-[var(--theme-card-text-color)]/80">
{historyLabel(row.source, row.description)}
</span>
</li>

View File

@ -8,15 +8,14 @@ import {
} from "react";
import {
Download,
Facebook,
Instagram,
Linkedin,
Share2,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { AuthShell } from "../components/auth/AuthShell";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getSocialShareMeta,
getVisitorLoginPreview,
@ -41,13 +40,29 @@ import {
type EbVisitorContext,
} from "../types/visitorContext";
function WhiteCard({ children }: { children: ReactNode }) {
function SocialShareLayout({
children,
meta,
}: {
children: ReactNode;
meta?: SocialShareMeta | null;
}) {
return (
<div className="flex w-full max-w-[354px] flex-col items-center">
<div className="w-full rounded-xl shadow-md">
<div className="rounded-xl card-bg px-6 py-6">{children}</div>
</div>
</div>
<MobileShell className="relative bg-transparent">
{meta ? (
<PublicEventChrome
event={meta.event}
eventHeader={meta.eventHeader}
eventNameFallback={meta.eventName}
eventId={meta.eventId}
/>
) : (
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
)}
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="flex w-full max-w-none flex-col px-4">{children}</div>
</main>
</MobileShell>
);
}
@ -70,13 +85,6 @@ const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }>
{ key: "whatsapp", label: "WhatsApp" },
];
function ShareIcon({ platform }: { platform: keyof PhotoWallSharePlatforms }) {
if (platform === "linkedin") return <Linkedin className="h-4 w-4" />;
if (platform === "facebook") return <Facebook className="h-4 w-4" />;
if (platform === "instagram") return <Instagram className="h-4 w-4" />;
return <Share2 className="h-4 w-4" />;
}
export function SocialSharePage() {
const { uploadToken } = useParams<{ uploadToken: string }>();
const fileRef = useRef<HTMLInputElement>(null);
@ -90,6 +98,7 @@ export function SocialSharePage() {
const [submitting, setSubmitting] = useState(false);
const [picking, setPicking] = useState(false);
const [done, setDone] = useState<DoneState | null>(null);
const [resultImageReady, setResultImageReady] = useState(false);
const [actionMsg, setActionMsg] = useState<string | null>(null);
const [cropSession, setCropSession] = useState<CropSession | null>(null);
const [shareFile, setShareFile] = useState<File | null>(null);
@ -125,6 +134,15 @@ export function SocialSharePage() {
};
}, [cropSession]);
useEffect(() => {
const src = done?.previewUrl || done?.fileUrl;
if (!done || !src) return;
const img = resultImgRef.current;
if (img?.complete && img.naturalWidth > 0) {
setResultImageReady(true);
}
}, [done]);
useEffect(() => {
if (!done?.fileUrl) return;
let cancelled = false;
@ -223,6 +241,7 @@ export function SocialSharePage() {
try {
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id);
setShareFile(null);
setResultImageReady(false);
setDone({
previewUrl: result.previewUrl,
fileUrl: result.fileUrl,
@ -244,6 +263,7 @@ export function SocialSharePage() {
const resetForNew = () => {
setDone(null);
setResultImageReady(false);
setShareFile(null);
setActionMsg(null);
setErr(null);
@ -346,59 +366,61 @@ export function SocialSharePage() {
if (!uploadToken) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<SocialShareLayout>
<p className="text-center text-sm text-red-600">Missing link</p>
</WhiteCard>
</AuthShell>
</SocialShareLayout>
);
}
if (loading) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<p className="text-center text-sm text-[#666]">Loading…</p>
</WhiteCard>
</AuthShell>
<SocialShareLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
</SocialShareLayout>
);
}
if (err && !meta) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
<SocialShareLayout>
<p className="text-center text-sm text-red-600">{err}</p>
</WhiteCard>
</AuthShell>
</SocialShareLayout>
);
}
if (done) {
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Social Share"
/>
</div>
) : null}
<h1 className="mb-2 text-center text-[22px] font-semibold">Your photo</h1>
<SocialShareLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
<h2 className="mb-2 text-center text-[22px] font-semibold">Your photo</h2>
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
Here's your framed photo. You can download or share it.
</p>
{(done.fileUrl || done.previewUrl) ? (
<div className="mb-4 overflow-hidden rounded-lg">
<div className="relative mb-4 min-h-[220px] overflow-hidden rounded-lg bg-[var(--theme-card-text-color)]/5">
{!resultImageReady ? (
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3"
role="status"
aria-live="polite"
aria-label="Loading generated photo"
>
<div
className="size-10 animate-spin rounded-full border-[3px] border-[var(--theme-button-color)]/25 border-t-[var(--theme-button-color)]"
aria-hidden
/>
<p className="text-xs font-medium text-[var(--theme-card-text-color)]/70">Preparing your photo…</p>
</div>
) : null}
<img
ref={resultImgRef}
crossOrigin="anonymous"
src={done.previewUrl || done.fileUrl || undefined}
alt="Your framed photo"
className="mx-auto max-h-[360px] w-full object-contain"
className={`mx-auto max-h-[360px] w-full object-contain transition-opacity duration-300 ${
resultImageReady ? "opacity-100" : "opacity-0"
}`}
onLoad={() => setResultImageReady(true)}
onError={(e) => {
const img = e.currentTarget;
if (img.crossOrigin) {
@ -408,11 +430,15 @@ export function SocialSharePage() {
}
if (done.fileUrl && img.src !== done.fileUrl) {
img.src = done.fileUrl;
return;
}
setResultImageReady(true);
}}
/>
</div>
) : null}
) : (
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">Photo is ready, but preview is unavailable.</p>
)}
<div className="flex flex-col gap-2">
<button
type="button"
@ -427,7 +453,7 @@ export function SocialSharePage() {
onClick={() => void onDownload()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
>
<Download className="h-4 w-4" />
<Download className="size-5" />
Download photo
</button>
) : null}
@ -443,20 +469,20 @@ export function SocialSharePage() {
onClick={() => void onNativeShare()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-4 text-sm font-semibold text-gray-600"
>
<Share2 className="h-4 w-4" />
<Share2 className="size-5" />
Share
</button>
) : (
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-wrap justify-center gap-2">
{enabledSharePlatforms.map((item) => (
<button
key={item.key}
type="button"
onClick={() => void onShare(item.key)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
className="inline-flex h-10 items-center justify-center w-auto gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-3 text-xs font-semibold text-gray-600"
>
<ShareIcon platform={item.key} />
{item.label}
<SharePlatformIcon platform={item.key} />
{/* {item.label} */}
</button>
))}
</div>
@ -464,27 +490,17 @@ export function SocialSharePage() {
</div>
) : null}
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
</WhiteCard>
</AuthShell>
</section>
</SocialShareLayout>
);
}
return (
<AuthShell showLogo={false}>
<WhiteCard>
{meta?.event ? (
<div className="mb-4">
<PhotoWallEventHeader
event={meta.event}
display={meta.eventHeader || null}
eyebrow="Social Share"
/>
</div>
) : null}
<SocialShareLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h1 className="mb-1 text-center text-[22px] font-semibold">Social Share</h1>
<h2 className="mb-1 text-center text-[22px] font-semibold">Social Share</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create and share your photo.</p>
<button
type="button"
@ -496,7 +512,7 @@ export function SocialSharePage() {
</div>
) : !selectedFrame ? (
<div className="space-y-4">
<h1 className="mb-1 text-center text-[22px] font-semibold">Choose a frame</h1>
<h2 className="mb-1 text-center text-[22px] font-semibold">Choose a frame</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick a frame, then upload your photo.</p>
{!meta?.frames?.length ? (
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">No frames available yet.</p>
@ -507,7 +523,7 @@ export function SocialSharePage() {
key={frame._id}
type="button"
onClick={() => setSelectedFrame(frame)}
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/25"
className="overflow-hidden rounded-lg border border-[var(--theme-card-text-color)]/15 text-left hover:border-[var(--theme-card-text-color)]/25 hover:border-[var(--theme-card-text-color)]/30"
>
<div className="aspect-square w-full">
{frame.frameImageUrl ? (
@ -532,7 +548,7 @@ export function SocialSharePage() {
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
) : (
<div className="space-y-4 text-center">
<div className="space-y-4 text-left">
<button
type="button"
onClick={() => {
@ -540,13 +556,13 @@ export function SocialSharePage() {
clearSelectedPhoto();
setErr(null);
}}
className="text-sm font-semibold theme-btn-link"
className="text-sm font-semibold theme-btn-lin text-[var(--theme-card-text-color)]"
>
← Change frame
</button>
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h1>
<h2 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload photo</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">
Frame: <span className="font-semibold">{selectedFrame.name}</span>
Selected frame: <span className="font-semibold">{selectedFrame.name}</span>
</p>
<input
@ -576,12 +592,12 @@ export function SocialSharePage() {
onClick={() => void onSubmit()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white disabled:opacity-55"
>
{submitting ? "Creating…" : "Create photo"}
{submitting ? "Generating" : "Generate photo"}
</button>
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
</div>
)}
</WhiteCard>
</section>
{cropSession && cropTarget ? (
<PhotoWallCropModal
@ -599,6 +615,6 @@ export function SocialSharePage() {
}}
/>
) : null}
</AuthShell>
</SocialShareLayout>
);
}

View File

@ -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 */
}
}