manual improvement 1
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AccentCard,
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppLink,
|
||||
CountdownTimer,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
InitialsAvatar,
|
||||
Money,
|
||||
SurfaceCard,
|
||||
TrustBadge,
|
||||
@@ -19,78 +20,114 @@ import { useMe } from '@/services/auth';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import { useTodaySessions } from '@/services/bookings';
|
||||
import { useNurseEarningsBalance } from '@/services/payouts';
|
||||
import { useUnreadCount } from '@/services/notifications';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState } from '@/services/verification/types';
|
||||
import { coarseResponseLabel } from '@/services/bookingRequests/format';
|
||||
import DashboardActivationSlot from './DashboardActivationSlot';
|
||||
|
||||
const DASHBOARD_MAX_WIDTH = 960;
|
||||
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
|
||||
const URGENT_THRESHOLD_SECONDS = 30 * 60;
|
||||
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/**
|
||||
* The nurse "امروز" dashboard (ui-phase-7 §3.1) — the operational home replacing the `PlaceholderScreen`.
|
||||
* Pure assembly: every widget reads an already-cached query. Order matters — the pending-requests strip
|
||||
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
|
||||
* The nurse «امروز» home — the first bottom-nav destination.
|
||||
*
|
||||
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
|
||||
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
|
||||
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
|
||||
* important than anything else. This version gives the page one visual hierarchy: a quiet identity
|
||||
* strip, then exactly one hero action (the next visit), then sections introduced by a plain label
|
||||
* with a text link instead of a competing button.
|
||||
*
|
||||
* Composition only — every widget reads a query that is already cached elsewhere in the shell, and
|
||||
* order encodes urgency: a missed request expires, an unread earnings figure does not.
|
||||
*/
|
||||
export default function NurseDashboardScreen() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { data: me, isLoading: meLoading } = useMe();
|
||||
const verification = useVerificationStatus();
|
||||
|
||||
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{meLoading ? (
|
||||
<>
|
||||
<Skeleton variant="circular" width={44} height={44} />
|
||||
<Skeleton variant="text" width={160} height={32} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{(displayName || '؟').charAt(0)}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
|
||||
{t('greeting', { name: displayName })}
|
||||
</Typography>
|
||||
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<GreetingHeader />
|
||||
<NextVisitCard />
|
||||
<RequestsStrip />
|
||||
<EarningsSnapshotCard />
|
||||
<RequestsSection />
|
||||
<EarningsSection />
|
||||
<DashboardActivationSlot />
|
||||
<NotificationsEntryRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
|
||||
/**
|
||||
* A section label + an optional text link. Deliberately not a button: on a 480px row a
|
||||
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
|
||||
*/
|
||||
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{actionLabel && actionTo ? (
|
||||
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
|
||||
{actionLabel}
|
||||
</AppLink>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Greeting + own trust badge. The avatar is initials-based — a nurse photo lives on the profile. */
|
||||
function GreetingHeader() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { data: me, isLoading } = useMe();
|
||||
const verification = useVerificationStatus();
|
||||
|
||||
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Skeleton variant="circular" width={44} height={44} />
|
||||
<Skeleton variant="text" width={180} height={28} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', minWidth: 0 }}>
|
||||
<InitialsAvatar name={displayName} size={44} />
|
||||
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" component="h1" noWrap sx={{ fontWeight: 700 }}>
|
||||
{t('greeting', { name: displayName })}
|
||||
</Typography>
|
||||
{verification.isLoading ? null : (
|
||||
<Box>
|
||||
<TrustBadge state={ownBadgeState(verification.data)} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
|
||||
function NextVisitCard() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useTodaySessions();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={140} />;
|
||||
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
|
||||
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
|
||||
|
||||
if (!next) {
|
||||
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<SectionHeader title={t('next_visit_title')} />
|
||||
<EmptyState icon="visits" title={t('next_visit_empty')} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
@@ -98,48 +135,41 @@ function NextVisitCard() {
|
||||
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="next-visit">
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
<AccentCard tone="secondary" data-widget="next-visit">
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
|
||||
{t('next_visit_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{next.patientName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{timeRangeLabel}
|
||||
</Typography>
|
||||
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
|
||||
</Typography>
|
||||
<MetaLine
|
||||
items={[
|
||||
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{timeRangeLabel}
|
||||
</Box>,
|
||||
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon="check_in"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
|
||||
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
|
||||
{t('next_visit_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
|
||||
function RequestsStrip() {
|
||||
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
|
||||
function RequestsSection() {
|
||||
const t = useTranslations('dashboard');
|
||||
const tb = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={140} />;
|
||||
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
@@ -148,76 +178,70 @@ function RequestsStrip() {
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
if (items.length === 0) {
|
||||
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
|
||||
<EmptyState icon="requests" title={t('requests_strip_empty')} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const mostUrgent = items[0];
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="requests-strip">
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('requests_strip_title', { count: total })}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
|
||||
<SectionHeader
|
||||
title={t('requests_strip_title', { count: total })}
|
||||
actionLabel={t('requests_strip_cta')}
|
||||
actionTo={ROUTES.NURSE_REQUESTS}
|
||||
/>
|
||||
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` — the old
|
||||
layout let the countdown wrap under a long Persian name and collide with the date. */}
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
|
||||
{mostUrgent.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(mostUrgent.requestedDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
|
||||
elapsedText={tb('response_elapsed')}
|
||||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||||
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
variant="text"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
|
||||
fullWidth
|
||||
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
|
||||
>
|
||||
{t('requests_strip_cta')}
|
||||
{t('requests_strip_open')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{mostUrgent.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(mostUrgent.requestedDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CountdownTimer
|
||||
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
|
||||
elapsedText={tb('response_elapsed')}
|
||||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||||
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('requests_strip_open')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</SurfaceCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
|
||||
function EarningsSnapshotCard() {
|
||||
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
|
||||
function EarningsSection() {
|
||||
const t = useTranslations('dashboard');
|
||||
const tp = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={120} />;
|
||||
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
@@ -228,69 +252,48 @@ function EarningsSnapshotCard() {
|
||||
const magnitude = isOwed ? -net : net;
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="earnings-snapshot">
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('earnings_snapshot_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="earnings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
|
||||
>
|
||||
{t('earnings_snapshot_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||||
</Typography>
|
||||
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tp('bucket_eligible')}
|
||||
</Typography>
|
||||
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
|
||||
<SectionHeader
|
||||
title={t('earnings_snapshot_title')}
|
||||
actionLabel={t('earnings_snapshot_cta')}
|
||||
actionTo={ROUTES.NURSE_EARNINGS}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<StatTile
|
||||
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||||
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
|
||||
/>
|
||||
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{value}
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
|
||||
function NotificationsEntryRow() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const unread = useUnreadCount();
|
||||
|
||||
/** Dot-separated secondary facts on one line, skipping the ones that aren't available. */
|
||||
function MetaLine({ items }: { items: Array<ReactNode> }) {
|
||||
const present = items.filter(Boolean);
|
||||
return (
|
||||
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
|
||||
<SurfaceCard data-widget="notifications-entry">
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{t('notifications_entry_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
|
||||
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
|
||||
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
|
||||
>
|
||||
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</AppLink>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{present.map((item, index) => (
|
||||
<Box key={index} component="span">
|
||||
{index > 0 ? ' · ' : null}
|
||||
{item}
|
||||
</Box>
|
||||
))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user