ui phase 13
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { getLocale, getTranslations } from 'next-intl/server';
|
||||
import { Box, Container, Divider, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLink, CategoryTile, EscrowNotice, SurfaceCard } from '@/components';
|
||||
import BrandMark from '@/components/auth/BrandMark';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
const CATEGORY_ICON_KEYS = ['elderly', 'post_surgery', 'infant', 'chronic', 'companionship'] as const;
|
||||
|
||||
const HOW_IT_WORKS_STEPS = [
|
||||
{ icon: 'search', titleKey: 'step1_title', bodyKey: 'step1_body' },
|
||||
{ icon: 'lock', titleKey: 'step2_title', bodyKey: null },
|
||||
{ icon: 'verified', titleKey: 'step3_title', bodyKey: 'step3_body' },
|
||||
] as const;
|
||||
|
||||
const TRUST_ROWS = [
|
||||
{ icon: 'identity', titleKey: 'trust_identity_title', bodyKey: 'trust_identity_body' },
|
||||
{ icon: 'license', titleKey: 'trust_license_title', bodyKey: 'trust_license_body' },
|
||||
{ icon: 'verification', titleKey: 'trust_ino_title', bodyKey: 'trust_ino_body' },
|
||||
{ icon: 'bank', titleKey: 'trust_bank_title', bodyKey: 'trust_bank_body' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The public front door (ui-phase-13) — a marketing landing served AT `/` for a guest visitor via
|
||||
* a middleware rewrite (see `middleware.ts`; the URL/canonical stays `/`, never `/welcome`).
|
||||
* Deliberately a Server Component with no query hooks anywhere in the tree: first paint is the
|
||||
* whole product here, so the page must never wait on an API call (§3.5 — RSC-first, zero
|
||||
* client-side data fetching). The only client leaves it composes (`CategoryTile`, `EscrowNotice`,
|
||||
* `BrandMark`, `AppLink`/`AppButton`) are static-content components that fetch nothing.
|
||||
* @component WelcomeScreen
|
||||
*/
|
||||
export default async function WelcomeScreen() {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations('welcome');
|
||||
const tLegal = await getTranslations('legal');
|
||||
|
||||
const loginHref = `/${locale}${ROUTES.LOGIN}`;
|
||||
const nurseLoginHref = `/${locale}${ROUTES.LOGIN}?role=nurse`;
|
||||
const termsHref = `/${locale}${ROUTES.TERMS}`;
|
||||
const privacyHref = `/${locale}${ROUTES.PRIVACY}`;
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm" sx={{ py: { xs: 4, md: 6 } }}>
|
||||
<Stack sx={{ gap: { xs: 6, md: 8 } }}>
|
||||
{/* Hero */}
|
||||
<Stack component="header" sx={{ alignItems: 'center', textAlign: 'center', gap: 2 }}>
|
||||
<BrandMark withTagline />
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 420 }}>
|
||||
{t('hero_subtitle')}
|
||||
</Typography>
|
||||
<AppButton to={loginHref} color="primary" variant="contained" size="large" sx={{ mt: 1, px: 4 }}>
|
||||
{t('hero_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{/* Category grid — static i18n content, not the live catalog (§3.1 tier a+b decision) */}
|
||||
<Stack component="section" sx={{ gap: 2 }}>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
|
||||
{t('categories_title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{CATEGORY_ICON_KEYS.map((key) => (
|
||||
<CategoryTile key={key} label={t(`category_${key}`)} iconKey={key} href={loginHref} />
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* How it works */}
|
||||
<Stack component="section" sx={{ gap: 2.5 }}>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
|
||||
{t('how_it_works_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
{HOW_IT_WORKS_STEPS.map((step, index) => (
|
||||
<Stack key={step.titleKey} direction="row" sx={{ gap: 2, alignItems: 'flex-start' }}>
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--bal-primary-soft)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={step.icon} size={22} color="var(--bal-primary)" aria-hidden="true" />
|
||||
</Box>
|
||||
<Stack sx={{ gap: 0.75, flex: 1, pt: 0.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{index + 1}. {t(step.titleKey)}
|
||||
</Typography>
|
||||
{step.bodyKey ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(step.bodyKey)}
|
||||
</Typography>
|
||||
) : (
|
||||
// Step 2 (escrow) reuses the product-mandated verbatim EscrowNotice copy —
|
||||
// never a paraphrase (§3.2).
|
||||
<EscrowNotice />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Trust / verification explainer */}
|
||||
<Stack component="section" sx={{ gap: 2 }}>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
|
||||
{t('trust_title')}
|
||||
</Typography>
|
||||
<SurfaceCard padding="lg">
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('trust_intro')}
|
||||
</Typography>
|
||||
{TRUST_ROWS.map((row) => (
|
||||
<Stack key={row.titleKey} direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon
|
||||
icon={row.icon}
|
||||
size={20}
|
||||
color="var(--bal-trust)"
|
||||
style={{ flexShrink: 0, marginTop: 2 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t(row.titleKey)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(row.bodyKey)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</Stack>
|
||||
|
||||
{/* Nurse recruitment */}
|
||||
<SurfaceCard padding="lg" sx={{ bgcolor: 'var(--bal-primary-soft)', borderColor: 'var(--bal-primary)' }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
|
||||
{t('nurse_cta_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('nurse_cta_body')}
|
||||
</Typography>
|
||||
<AppButton to={nurseLoginHref} color="primary" variant="outlined">
|
||||
{t('nurse_cta_button')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
|
||||
{/* Footer */}
|
||||
<Stack component="footer" sx={{ gap: 2 }}>
|
||||
<Divider />
|
||||
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
|
||||
<AppLink to={termsHref} color="text.secondary" underline="hover">
|
||||
{tLegal('terms_title')}
|
||||
</AppLink>
|
||||
<AppLink to={privacyHref} color="text.secondary" underline="hover">
|
||||
{tLegal('privacy_title')}
|
||||
</AppLink>
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||
{t('footer_contact_title')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('footer_contact_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('footer_copyright', { year: new Date().getFullYear() })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { BRAND } from '@/theme/colors';
|
||||
|
||||
export const alt = 'Balinyaar — trust-first home nursing marketplace';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
/**
|
||||
* The landing's og:image (ui-phase-13) — a static composition built from the phase-0 brand mark
|
||||
* (the rounded-square lockup + terracotta accent dot from `public/img/logo.svg`, reproduced in
|
||||
* plain divs since Satori/`ImageResponse` renders a constrained CSS subset, not arbitrary SVG) on
|
||||
* the brand teal/cream. Kept Latin-only (no Persian glyphs) — `ImageResponse`'s bundled fallback
|
||||
* font only covers Latin; rendering the Persian tagline here would need an embedded Mikhak font
|
||||
* buffer, deliberately left for a follow-up once that's verified in a running server.
|
||||
*/
|
||||
export default function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 40,
|
||||
backgroundColor: BRAND.teal,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 168,
|
||||
height: 168,
|
||||
borderRadius: 42,
|
||||
backgroundColor: BRAND.tealDeep,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 26,
|
||||
height: 92,
|
||||
borderRadius: 13,
|
||||
backgroundColor: BRAND.creamSoft,
|
||||
position: 'absolute',
|
||||
left: 54,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 62,
|
||||
height: 62,
|
||||
borderRadius: '50%',
|
||||
border: `13px solid ${BRAND.creamSoft}`,
|
||||
position: 'absolute',
|
||||
right: 40,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: BRAND.terracotta,
|
||||
position: 'absolute',
|
||||
right: 34,
|
||||
top: 34,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 76, fontWeight: 700, color: BRAND.creamSoft }}>
|
||||
Balinyaar
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 32, color: BRAND.tealOnDarkLight }}>
|
||||
Trust-first home nursing
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import WelcomeScreen from './WelcomeScreen';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'welcome' });
|
||||
const tCommon = await getTranslations({ locale, namespace: 'common' });
|
||||
|
||||
// The middleware REWRITES an unauthenticated '/' to this route (never a redirect), so this is
|
||||
// the content served at both '/{locale}' and '/{locale}/welcome' — canonicalize on the former,
|
||||
// the URL a guest and every crawler actually sees.
|
||||
const canonicalPath = `/${locale}`;
|
||||
|
||||
return {
|
||||
description: t('meta_description'),
|
||||
alternates: { canonical: canonicalPath },
|
||||
openGraph: {
|
||||
title: tCommon('brand'),
|
||||
description: t('meta_description'),
|
||||
type: 'website',
|
||||
locale: locale === 'fa' ? 'fa_IR' : 'en_US',
|
||||
url: canonicalPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { BRAND } from '@/theme/colors';
|
||||
import { NotistackProvider } from '@/lib/toast';
|
||||
import { QueryProvider } from '@/lib/query/QueryProvider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { SITE_URL } from '@/config';
|
||||
import '../globals.css';
|
||||
import '@/theme/tokens.css';
|
||||
|
||||
@@ -80,6 +81,9 @@ export async function generateMetadata({
|
||||
const t = await getTranslations({ locale: safeLocale, namespace: 'common' });
|
||||
|
||||
return {
|
||||
// Absolute origin for every relative URL in child metadata (OG images, canonicals) — sourced
|
||||
// from an env constant, never hard-coded, so it's correct per deployment (ui-phase-13).
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
template: safeLocale === 'fa' ? '%s | بالینیار' : '%s | Balinyaar',
|
||||
default: safeLocale === 'fa' ? 'بالینیار' : 'Balinyaar',
|
||||
|
||||
Reference in New Issue
Block a user