diff --git a/src/app/(site)/help/[category]/page.tsx b/src/app/(site)/help/[category]/page.tsx index 9044c79f..4f3826a7 100644 --- a/src/app/(site)/help/[category]/page.tsx +++ b/src/app/(site)/help/[category]/page.tsx @@ -6,6 +6,7 @@ import { ContentCard } from "@/components/public/ui"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; import { db, WebsiteHelpCenterCategories } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; +import { publicAssetExists } from "@/lib/public-asset"; import { sanitize } from "@/lib/sanitize"; // Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/'). @@ -99,7 +100,10 @@ export default async function HelpCategoryPage({ const title = withHotel(cat.name, hotelName); const content = withHotel(cat.content, hotelName); const buttonText = withHotel(cat.buttonText, hotelName); - const imageSrc = cat.imageUrl ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` : ""; + const imageSrc = + cat.imageUrl && publicAssetExists(`${HELP_IMAGE_BASE}/${cat.imageUrl}`) + ? `${HELP_IMAGE_BASE}/${cat.imageUrl}` + : ""; const hasButton = Boolean(cat.buttonText && cat.buttonText.trim() !== ""); return ( diff --git a/src/app/(site)/help/page.tsx b/src/app/(site)/help/page.tsx index 22576be7..ff0ecdb6 100644 --- a/src/app/(site)/help/page.tsx +++ b/src/app/(site)/help/page.tsx @@ -23,6 +23,10 @@ import { WebsiteRuleCategories, WebsiteRules, } from "@/lib/db"; +import { publicAssetExists } from "@/lib/public-asset"; + +// Asset path matches AtomCMS's Blade view: asset('/assets/images/help-center/'). +const HELP_IMAGE_BASE = "/assets/images/help-center"; type HelpCategory = { id: bigint; @@ -149,66 +153,71 @@ export default async function HelpCenterPage() { {t("topicsEmpty")} ) : (
- {categories.map((c) => ( -
-
- {c.imageUrl ? ( - - ) : null} -
-

- {c.name} -

-

- {c.content} -

- {c.buttonUrl && c.buttonText ? ( -
- {isExternal(c.buttonUrl) ? ( - - {c.buttonText} - - ) : ( - - {c.buttonText} - - )} -
+ {categories.map((c) => { + const imageSrc = c.imageUrl + ? `${HELP_IMAGE_BASE}/${c.imageUrl}` + : ""; + return ( +
+
+ {imageSrc && publicAssetExists(imageSrc) ? ( + ) : null} +
+

+ {c.name} +

+

+ {c.content} +

+ {c.buttonUrl && c.buttonText ? ( +
+ {isExternal(c.buttonUrl) ? ( + + {c.buttonText} + + ) : ( + + {c.buttonText} + + )} +
+ ) : null} +
-
-
- ))} + + ); + })}
)} diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 004dd5d2..c1fdfd11 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -40,6 +40,7 @@ import { ICON_FRIENDS, ICON_NAV_GOODY, ICON_NAV_ME, + ICON_STAR, } from "@/lib/site-icons"; export async function generateMetadata(): Promise { @@ -54,26 +55,90 @@ export async function generateMetadata(): Promise { }; } -// ── Local visual language (theme-aware, no big white blocks) ────────── -const PANEL_BG = - "linear-gradient(160deg, color-mix(in srgb, var(--color-primary) 12%, transparent) 0%, color-mix(in srgb, var(--color-surface) 58%, transparent) 52%, color-mix(in srgb, var(--color-accent) 9%, transparent) 100%)"; +/* ── Local visual language ──────────────────────────────────────────── + The landing panels used to carry their own translucent washes + (surface 44–60 % + a backdrop blur each). Four different milky smears over + the sky gradient, and a backdrop snapshot per panel on every scroll. The + shared `.panel*` classes in globals.css now own the surface recipe, so the + boxes read as one system and cost nothing to paint. */ -const PANEL_STYLE: CSSProperties = { - background: PANEL_BG, - backdropFilter: "blur(18px)", - WebkitBackdropFilter: "blur(18px)", - borderColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)", - boxShadow: - "0 0 0 0.5px color-mix(in srgb, var(--color-text-muted) 6%, transparent), 0 24px 60px -30px color-mix(in srgb, #000 28%, transparent)", -}; +// The hero scrim. A photo always needs a dark wash for the copy to stay +// readable, so this one stays a fixed dark gradient in both themes. +const HERO_SCRIM = + "linear-gradient(180deg, rgba(8,11,24,0.80) 0%, rgba(8,11,24,0.58) 38%, rgba(8,11,24,0.42) 62%, rgba(8,11,24,0.60) 85%, color-mix(in srgb, var(--color-background) 92%, transparent) 100%)"; + +const HERO_COPY = "rgba(255,255,255,0.80)"; +const HERO_COPY_STRONG = "rgba(255,255,255,0.96)"; const DARK_BAND = "linear-gradient(120deg, color-mix(in srgb, var(--color-primary) 30%, #0a0e1e) 0%, color-mix(in srgb, var(--color-accent) 26%, #0a0e1e) 52%, color-mix(in srgb, var(--color-primary) 20%, #0a0e1e) 100%)"; -const GLASS_TILE_BG = - "linear-gradient(155deg, color-mix(in srgb, var(--color-surface) 60%, transparent) 0%, color-mix(in srgb, var(--color-surface) 44%, transparent) 60%, color-mix(in srgb, var(--color-accent) 6%, transparent) 100%)"; +/** Accent used for the nth surface of a repeating set. */ +function tintFor(index: number): string { + return index % 2 === 0 ? "var(--color-primary)" : "var(--color-accent)"; +} -interface GlassPanelProps { +interface PanelHeaderProps { + title: string; + icon?: string; + actionHref?: string; + actionLabel?: string; +} + +/** + * The header row every landing panel shares: tinted icon chip, title and an + * optional "see everything" link. + */ +function PanelHeader({ + title, + icon, + actionHref, + actionLabel, +}: PanelHeaderProps) { + return ( +
+
+ {icon && ( +
+ +
+ )} +

+ {title} +

+
+ {actionHref && actionLabel && ( + + {actionLabel} + + )} +
+ ); +} + +interface PanelProps { children: ReactNode; title?: string; icon?: string; @@ -83,7 +148,8 @@ interface GlassPanelProps { className?: string; } -function GlassPanel({ +/** A themed surface with an optional header. */ +function Panel({ children, title, icon, @@ -91,71 +157,22 @@ function GlassPanel({ actionLabel, bodyClassName, className, -}: GlassPanelProps) { +}: PanelProps) { const hasHeader = Boolean(title || icon); return (
{hasHeader && ( -
-
- {icon && ( -
- -
- )} - {title && ( -

- {title} -

- )} -
- {actionHref && actionLabel && ( - - {actionLabel} - - )} -
+ )} {hasHeader ? (
{children}
@@ -166,6 +183,56 @@ function GlassPanel({ ); } +/** + * Cover image for a news article. Articles without an upload used to render a + * broken , so they fall back to a themed plate instead. + */ +function ArticleCover({ + src, + alt, + featured, +}: { + src: string; + alt: string; + featured: boolean; +}) { + if (!src) { + return ( +
+ +
+ ); + } + + return ( + {alt} + ); +} + async function getHotelData() { const [ hotelName, @@ -297,79 +364,80 @@ async function Home() { desc: tp("featuresCommunityDesc"), icon: ICON_NAV_GOODY, }, + { + title: tp("featuresGames"), + desc: tp("featuresGamesDesc"), + icon: ICON_STAR, + }, ]; type NewsArticle = NonNullable[number]; - const renderArticleCard = (a: NewsArticle, featured = false) => ( - -
- {a.title} -
- { + const published = a.createdAt ? formatDate(a.createdAt, "date", "") : ""; + + return ( + +
+ +
-
-
- {formatDate(a.createdAt, "date", "")} -
-
- {featured && ( -

- {tp("latestArticle")} -

+ + +
+ {published && ( +
+ {published} +
)} -

- {a.title} -

+ {featured && ( +

+ {tp("latestArticle")} +

+ )} +

+ {a.title} +

+
-
- - ); + + ); + }; const exploreLinks = [ { href: "/community", label: tn("community") }, @@ -380,24 +448,17 @@ async function Home() { { href: "/rankings", label: tn("rankings") }, ]; - const statChips = [ - { value: users, label: tp("statsCitizens"), color: "var(--color-primary)" }, - { value: rooms, label: tp("statsRooms"), color: "var(--color-accent)" }, - { - value: totalPhotos, - label: tp("statsPhotos"), - color: "var(--color-primary)", - }, - { - value: articleCount, - label: tp("statsArticles"), - color: "var(--color-accent)", - }, - ].filter((s) => s.value !== null) as Array<{ - value: number; - label: string; - color: string; - }>; + // One source of truth for the four headline counters. They used to be + // rendered twice — as four small chips in the hero and again as a full + // panel further down — which made the page feel padded and repeated. + // A failed read stays visible as "unavailable" instead of silently + // shrinking the row, so a broken counter can never be mistaken for zero. + const statItems = [ + { value: users, label: tp("statsCitizens"), icon: ICON_FRIENDS }, + { value: rooms, label: tp("statsRooms"), icon: ICON_CATALOG }, + { value: totalPhotos, label: tp("statsPhotos"), icon: ICON_CAMERA }, + { value: articleCount, label: tp("statsArticles"), icon: ICON_ARTICLE }, + ]; return (
@@ -405,175 +466,211 @@ async function Home() {
- {/* ── Cinematic hero ── */} - -
- -
- {/* Drifting brand-tinted aurora behind the copy */} -