ui phase 13

This commit is contained in:
hamid
2026-07-20 01:35:15 +03:30
parent d33568bf31
commit 12ce7fa7de
21 changed files with 868 additions and 80 deletions
@@ -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 />;
}
+4
View File
@@ -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',
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from 'next';
import { SITE_URL } from '@/config';
/**
* Every top-level route segment that sits behind the auth middleware, for every actor (customer/
* nurse/admin/partner). Kept in sync by hand with `src/constants/routes.ts` — there is no single
* "private roots" export there (only a `PUBLIC_PATHS` allow-list), and deriving one automatically
* would need to walk every `ROUTES.*` entry down to its first segment, which is more machinery
* than this short, reviewable list (ui-phase-13).
*/
const PRIVATE_ROOT_SEGMENTS = [
'select-role',
'onboarding',
'search',
'bookings',
'patients',
'addresses',
'wallet',
'profile',
'support',
'notifications',
'nurse',
'admin',
'partner',
];
/**
* Replaces the starter `public/robots.txt` (two contradictory `User-agent: *` blocks) with a real
* policy: allow the public marketing/auth surface, disallow every private root for every locale.
* @route /robots.txt
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: PRIVATE_ROOT_SEGMENTS.map((segment) => `/*/${segment}`),
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next';
import { routing } from '@/i18n/routing';
import { ROUTES } from '@/constants';
import { SITE_URL } from '@/config';
/** Public, unauthenticated pages only (ui-phase-13) — mirrors `PUBLIC_PATHS` plus the locale root
* itself (the guest landing an unauthenticated `/` rewrites to). */
const PUBLIC_SITEMAP_PATHS: string[] = ['', ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY];
/** @route /sitemap.xml */
export default function sitemap(): MetadataRoute.Sitemap {
return PUBLIC_SITEMAP_PATHS.map((path) => ({
url: `${SITE_URL}/${routing.defaultLocale}${path}`,
alternates: {
languages: Object.fromEntries(
routing.locales.map((locale) => [locale, `${SITE_URL}/${locale}${path}`]),
),
},
}));
}
@@ -1,8 +1,17 @@
import { FunctionComponent } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import mockRouter from 'next-router-mock';
import { ThemeProvider } from '../../theme';
import CategoryTile, { CategoryTileProps } from './CategoryTile';
// CategoryTile renders as an AppLink (not a button) when `href` is given (ui-phase-13's landing
// tiles) — AppLink reads next/navigation's usePathname, so it needs the same local mock AppLink's
// own test file uses.
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
usePathname: () => mockRouter.asPath,
}));
const ComponentToTest: FunctionComponent<CategoryTileProps> = (props) => (
<ThemeProvider>
<CategoryTile {...props} />
@@ -36,4 +45,11 @@ describe('<CategoryTile/> component', () => {
render(<ComponentToTest label="Elderly Care" iconKey="elderly" selected />);
expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true');
});
it('renders as a link (not a button) when href is given, for the public landing', () => {
render(<ComponentToTest label="Elderly Care" iconKey="elderly" href="/fa/login" />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/fa/login');
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { FunctionComponent } from 'react';
import { Box, ButtonBase, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import AppLink from '../common/AppLink';
/**
* Service-category `iconKey`s we render with a dedicated icon. Any other key (or a missing one from
@@ -22,59 +23,71 @@ export interface CategoryTileProps {
onClick?: () => void;
/** Selected state (the nurse builder's category step); the Home grid leaves it unset. */
selected?: boolean;
/**
* Renders the tile as a link to this internal path instead of a click handler — the ui-phase-13
* static landing, whose tiles carry intent straight to `/login` rather than firing a search
* action. Mutually exclusive with `onClick`/`selected` (a link tile is never a toggle).
*/
href?: string;
}
/**
* A tappable tile for one service category — the customer Home grid (data-driven, one per
* `service_category`) and the nurse builder's category step (with `selected`). Icon in a soft-teal
* disc over the localised label; the whole tile is a button so it's keyboard- and screen-reader-
* accessible. RTL-safe (no directional hard-coding).
* `service_category`), the nurse builder's category step (with `selected`), and the public landing
* (with `href`, linking straight to login). Icon in a soft-teal disc over the localised label; the
* whole tile is a single interactive element (a button, or — via `href` — the anchor itself
* through `ButtonBase`'s `component` swap, never a button nested inside a link) so it stays
* keyboard- and screen-reader-accessible. RTL-safe (no directional hard-coding).
* @component CategoryTile
*/
const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false }) => (
<ButtonBase
focusRipple
onClick={onClick}
aria-pressed={selected}
data-selected={selected}
data-category-icon={resolveIcon(iconKey)}
sx={{
width: '100%',
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
textAlign: 'center',
transition: 'border-color 120ms ease, transform 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
'&:active': { transform: 'scale(0.98)' },
}}
>
<Box
const CategoryTile: FunctionComponent<CategoryTileProps> = ({ label, iconKey, onClick, selected = false, href }) => {
const interactionProps = href
? { component: AppLink, to: href }
: { onClick, 'aria-pressed': selected, 'data-selected': selected };
return (
<ButtonBase
focusRipple
data-category-icon={resolveIcon(iconKey)}
{...interactionProps}
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: 'var(--bal-primary-soft)',
width: '100%',
height: '100%',
minHeight: 116,
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: selected ? 'var(--bal-primary)' : 'divider',
bgcolor: selected ? 'var(--bal-primary-soft)' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
textAlign: 'center',
transition: 'border-color 120ms ease, transform 120ms ease',
'&:hover': { borderColor: 'var(--bal-primary)' },
'&:active': { transform: 'scale(0.98)' },
}}
>
<AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" />
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{label}
</Typography>
</ButtonBase>
);
<Box
sx={{
width: 48,
height: 48,
borderRadius: '50%',
bgcolor: 'var(--bal-primary-soft)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AppIcon icon={resolveIcon(iconKey)} size={28} color="var(--bal-primary)" />
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.3 }}>
{label}
</Typography>
</ButtonBase>
);
};
export default CategoryTile;
+7
View File
@@ -9,6 +9,13 @@ export const PUBLIC_URL = process.env.NEXT_PUBLIC_PUBLIC_URL; // Variant 2: .env
export const API_URL = envRequired(process.env.NEXT_PUBLIC_API_URL);
/**
* The public web origin (no trailing slash), used only for absolute-URL metadata (OG tags,
* `metadataBase`, `robots.ts`/`sitemap.ts`) — never for API calls. Falls back to localhost so
* dev/CI keep working without the env var set; set it for real in production.
*/
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
/**
* The Neshan **web** key (client-embeddable maps/search), separate from the server's
* `NeshanGeocoder` server key (refinement phase 8). Optional — `AddressMapPicker` falls back to
+10 -2
View File
@@ -8,6 +8,10 @@ export const ROUTES = {
// Customer (family) app — mobile-first, bottom-tab nav
HOME: '/',
// Public marketing landing (ui-phase-13) — served AT '/' for a guest via a middleware rewrite
// (never a redirect, so the URL/SEO canonical stays '/'). An authenticated hit on this path
// itself redirects to '/'. Never add ROUTES.HOME ('/') to PUBLIC_PATHS — see the note there.
WELCOME: '/welcome',
// First-login "who is care for?" flow (A3→A4); re-enterable from the patient list.
ONBOARDING: '/onboarding',
// Search & discovery (f6) — C1 filter screen; the Home search bar + category tiles navigate here.
@@ -179,8 +183,12 @@ export const notificationsPath = (role: 'customer' | 'nurse' | 'admin'): string
return ROUTES.NOTIFICATIONS;
};
/** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY];
/**
* Paths (without locale prefix) that bypass auth in middleware. **Never add `ROUTES.HOME` ('/')
* here** — the middleware matches with `startsWith`, so `'/'` would silently make every route
* public. The guest-facing root is handled by an exact-match rewrite in `middleware.ts` instead.
*/
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN, ROUTES.TERMS, ROUTES.PRIVACY, ROUTES.WELCOME];
/**
* Query param the middleware appends when it redirects an unauthenticated deep link to login