ui phase 5
This commit is contained in:
@@ -1,14 +1,25 @@
|
||||
'use client';
|
||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||
import { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components';
|
||||
import { Avatar, Box, ButtonBase, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppIconButton,
|
||||
AppLoading,
|
||||
CategoryTile,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
SurfaceCard,
|
||||
} from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { usePatients } from '@/services/patients';
|
||||
import { useServiceCategories } from '@/services/catalog';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import { useBookingDetail, useBookingList } from '@/services/bookings';
|
||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||
|
||||
interface NudgeCardProps {
|
||||
icon: string;
|
||||
@@ -16,17 +27,20 @@ interface NudgeCardProps {
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
to: string;
|
||||
/** Optional dismiss affordance (session-scoped) — omit for the always-relevant profile nudge. */
|
||||
onDismiss?: () => void;
|
||||
dismissLabel?: string;
|
||||
}
|
||||
|
||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, pr: onDismiss ? 4 : 0 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
@@ -37,14 +51,28 @@ const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLa
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
{onDismiss ? (
|
||||
<AppIconButton
|
||||
icon="close"
|
||||
title={dismissLabel}
|
||||
onClick={onDismiss}
|
||||
size="small"
|
||||
sx={{ position: 'absolute', insetInlineEnd: 8, insetBlockStart: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
// Session-scoped dismiss: a plain module variable (not a cookie/localStorage — this is ephemeral UI
|
||||
// state, not app/auth state) survives client-side navigation within the same page load and resets on a
|
||||
// hard reload, matching "dismissible for this session, not permanently".
|
||||
let patientNudgeDismissedInSession = false;
|
||||
|
||||
/**
|
||||
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
|
||||
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
|
||||
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
|
||||
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
|
||||
* A5 — the family Home: the front door of the app. Greeting + avatar, a compact ambient trust strip, a
|
||||
* tappable search entry point (routes to C1 — see `HomeSearchBar`), the **data-driven** service-category
|
||||
* grid (from the cached `services/catalog` reference data), a completeness-gated patient-record nudge,
|
||||
* and a "رزرو دوباره" (rebook) shortcut row sourced from recent bookings.
|
||||
*
|
||||
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
|
||||
* a settled list so a post-create refetch never bounces the user back to onboarding.
|
||||
@@ -57,6 +85,7 @@ export default function HomeScreen() {
|
||||
|
||||
const { data: me } = useMe();
|
||||
const { data, isError, refetch } = usePatients();
|
||||
const [nudgeDismissed, setNudgeDismissed] = useState(patientNudgeDismissedInSession);
|
||||
|
||||
const isEmpty = data?.total === 0;
|
||||
|
||||
@@ -78,6 +107,16 @@ export default function HomeScreen() {
|
||||
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
|
||||
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
|
||||
|
||||
// Completeness signal derived from the cached patients data (no extra fetch): a patient with no
|
||||
// conditions recorded yet is an incomplete record — never a forever-nudge once every record is filled.
|
||||
const hasIncompletePatient = data.items.some((patient) => patient.conditions.length === 0);
|
||||
const showPatientNudge = hasIncompletePatient && !nudgeDismissed;
|
||||
|
||||
const dismissPatientNudge = () => {
|
||||
patientNudgeDismissedInSession = true;
|
||||
setNudgeDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
@@ -94,17 +133,25 @@ export default function HomeScreen() {
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<TrustStrip />
|
||||
|
||||
<HomeSearchBar />
|
||||
|
||||
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
|
||||
|
||||
<NudgeCard
|
||||
icon="patients"
|
||||
title={t('nudge_patient_title')}
|
||||
body={t('nudge_patient_body')}
|
||||
ctaLabel={t('nudge_patient_cta')}
|
||||
to={href(ROUTES.PATIENTS)}
|
||||
/>
|
||||
<RebookRow />
|
||||
|
||||
{showPatientNudge ? (
|
||||
<NudgeCard
|
||||
icon="patients"
|
||||
title={t('nudge_patient_title')}
|
||||
body={t('nudge_patient_body')}
|
||||
ctaLabel={t('nudge_patient_cta')}
|
||||
to={href(ROUTES.PATIENTS)}
|
||||
onDismiss={dismissPatientNudge}
|
||||
dismissLabel={tc('close')}
|
||||
/>
|
||||
) : null}
|
||||
{!profileComplete ? (
|
||||
<NudgeCard
|
||||
icon="profile"
|
||||
@@ -119,41 +166,63 @@ export default function HomeScreen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
|
||||
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
|
||||
* → frontend-phase-6-b7.
|
||||
* Quiet, one-line ambient reassurance under the greeting — not a hero. Three icon+label items: escrow
|
||||
* payment, verified nurses, support. Purely presentational; tokens only.
|
||||
*/
|
||||
const TrustStrip: FunctionComponent = () => {
|
||||
const t = useTranslations('home');
|
||||
const items: Array<{ icon: string; label: string }> = [
|
||||
{ icon: 'lock', label: t('trust_escrow') },
|
||||
{ icon: 'verification', label: t('trust_verified_nurses') },
|
||||
{ icon: 'support', label: t('trust_support') },
|
||||
];
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
|
||||
{items.map((item) => (
|
||||
<Stack key={item.icon} direction="row" sx={{ gap: 0.5, alignItems: 'center', minWidth: 0 }}>
|
||||
<AppIcon icon={item.icon} size={16} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The Home search entry point — a tappable faux-input (never a half-working free-text field: the search
|
||||
* index has no text column, variant names aren't client-queryable, and the only matchable dataset — 5–6
|
||||
* cached category names — is already better served by the category grid directly below). Routes straight
|
||||
* to C1 (`/search`). **Upgrade path**: once the backend serves a `q` param on `search/nurses` (REQ-041,
|
||||
* matching nurse/variant/category names), this can become a real typeahead — the placeholder copy is
|
||||
* already written for that future, so only the tap target need change, not the copy/i18n keys.
|
||||
*/
|
||||
const HomeSearchBar: FunctionComponent = () => {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const q = query.trim();
|
||||
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={submit} role="search">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
aria-label={t('search_action')}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ButtonBase
|
||||
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
||||
aria-label={t('search_action')}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
width: '100%',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body1">{t('search_placeholder')}</Typography>
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -196,3 +265,72 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The "رزرو دوباره" shortcut row — repeat care is the dominant pattern in home nursing. Sourced from the
|
||||
* existing `useBookingList('customer')` cache (no extra list fetch); renders up to 2 cards, deduplicated
|
||||
* by nurse, deep-linking to the nurse's C3 profile. Renders nothing (no empty state) when there is no
|
||||
* past-bookings history.
|
||||
*/
|
||||
const RebookRow: FunctionComponent = () => {
|
||||
const { data, isLoading, isError } = useBookingList('customer', { pageSize: 5 });
|
||||
const items = data?.items ?? [];
|
||||
|
||||
if (isLoading || isError || items.length === 0) return null;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const candidates: BookingListItemDto[] = [];
|
||||
for (const item of items) {
|
||||
if (seen.has(item.counterpartyName)) continue;
|
||||
seen.add(item.counterpartyName);
|
||||
candidates.push(item);
|
||||
if (candidates.length === 2) break;
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{candidates.map((booking) => (
|
||||
<RebookCard key={booking.id} booking={booking} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
/** One rebook card — resolves the booking's `nurseId` (not on the list row) via the cached booking
|
||||
* detail, then deep-links to the nurse's C3 profile. Renders nothing while resolving. */
|
||||
const RebookCard: FunctionComponent<{ booking: BookingListItemDto }> = ({ booking }) => {
|
||||
const t = useTranslations('home');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const { data: detail } = useBookingDetail(booking.id, 'customer');
|
||||
|
||||
if (!detail) return null;
|
||||
|
||||
const open = () => router.push(`/${locale}${ROUTES.SEARCH_NURSE}/${detail.nurseId}`);
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
padding="sm"
|
||||
onClick={open}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
}}
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, cursor: 'pointer' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
|
||||
<AppIcon icon="history" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
|
||||
{t('rebook_with', { name: booking.counterpartyName })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" />
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user