Stage 6 (part 2): rest of the client app in the new style, promo code in the order form
Profile with club card, plum theme for buttons/selections and the sticky total, move screens with the full-width hero, services/docs headers, empty docs fix. Promo code field: applies to the quote, shows why a code is rejected, says when the membership discount is bigger. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ff527ad6d2
commit
bbb25d3d0d
12 files changed
+371
-85
No files matched your search
@@ -827,6 +827,126 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───── общий вид: слива вместо чёрного ───── */
|
||||||
|
.bc-btn--dark,
|
||||||
|
.bc-chip[aria-pressed='true'],
|
||||||
|
.day[aria-pressed='true'],
|
||||||
|
.slot[aria-pressed='true'] {
|
||||||
|
background: var(--bc-plum);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.option[aria-pressed='true'] {
|
||||||
|
border-color: var(--bc-plum);
|
||||||
|
background: var(--bc-pink);
|
||||||
|
color: var(--bc-plum);
|
||||||
|
}
|
||||||
|
.extra-toggle[aria-pressed='true'] {
|
||||||
|
background: var(--green);
|
||||||
|
border-color: var(--green);
|
||||||
|
color: var(--green-ink);
|
||||||
|
}
|
||||||
|
.bc-input:focus {
|
||||||
|
border-color: var(--bc-plum);
|
||||||
|
}
|
||||||
|
.app .bc-card {
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
.page-head {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.page-head .bc-h2 {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
/* закреплённый итог в оформлении заказа — сливовый, кнопка розовая */
|
||||||
|
.app .sticky-cta {
|
||||||
|
background: var(--bc-plum);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 12px 30px rgba(74, 16, 40, 0.35) !important;
|
||||||
|
}
|
||||||
|
.app .sticky-cta .bc-muted {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
.app .sticky-cta .bc-btn {
|
||||||
|
background: var(--bc-pink);
|
||||||
|
color: var(--bc-plum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* верх переезда: блок плана внутри сливового верха */
|
||||||
|
.hero.move-hero {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.hero.move-hero .hero__hello {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* промокод */
|
||||||
|
.promo-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.promo-form .pill {
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
.promo-form__error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.promo-applied {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 10px 10px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--bc-perks);
|
||||||
|
color: var(--bc-perks-ink);
|
||||||
|
}
|
||||||
|
.promo-applied > span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.promo-applied b {
|
||||||
|
font-family: var(--bc-font-head);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.promo-applied button {
|
||||||
|
border: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--bc-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ссылки в профиле */
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 4px 16px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--bc-surface);
|
||||||
|
}
|
||||||
|
.links a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 54px;
|
||||||
|
border-top: 1px solid var(--bc-line);
|
||||||
|
color: var(--bc-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.links a:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.links a span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ───── нижнее меню — слива и розовая кнопка чата ───── */
|
/* ───── нижнее меню — слива и розовая кнопка чата ───── */
|
||||||
.bc-bottomnav {
|
.bc-bottomnav {
|
||||||
background: var(--bc-plum);
|
background: var(--bc-plum);
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, Center, Icon, Spinner } from '@barev/ui';
|
import { Card, Center, Icon, Spinner } from "@barev/ui";
|
||||||
import { errorText, haptic, navigate, useI18n, useToast, useTRPC } from '@barev/web';
|
import {
|
||||||
import { itemHint, itemName, PageHead } from '../lib.tsx';
|
errorText,
|
||||||
|
haptic,
|
||||||
|
navigate,
|
||||||
|
useI18n,
|
||||||
|
useToast,
|
||||||
|
useTRPC,
|
||||||
|
} from "@barev/web";
|
||||||
|
import { itemHint, itemName } from "../lib.tsx";
|
||||||
|
|
||||||
/** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */
|
/** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */
|
||||||
export function Docs() {
|
export function Docs() {
|
||||||
@@ -14,8 +21,11 @@ export function Docs() {
|
|||||||
const toggle = useMutation(
|
const toggle = useMutation(
|
||||||
trpc.client.relocation.toggleDoc.mutationOptions({
|
trpc.client.relocation.toggleDoc.mutationOptions({
|
||||||
onMutate: () => haptic.tap(),
|
onMutate: () => haptic.tap(),
|
||||||
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.client.relocation.get.queryKey() }),
|
onSuccess: () =>
|
||||||
onError: (e) => toast(errorText(t, e), 'error'),
|
void qc.invalidateQueries({
|
||||||
|
queryKey: trpc.client.relocation.get.queryKey(),
|
||||||
|
}),
|
||||||
|
onError: (e) => toast(errorText(t, e), "error"),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (!plan.data)
|
if (!plan.data)
|
||||||
@@ -28,41 +38,63 @@ export function Docs() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<PageHead title={t('client.docs.title')} back={false} />
|
<header className="screen-head">
|
||||||
<p className="bc-muted" style={{ margin: 0 }}>
|
<h1 className="screen-head__title">{t("client.docs.title")}</h1>
|
||||||
{t('client.docs.sub')}
|
</header>
|
||||||
|
<p className="bc-muted" style={{ margin: "-6px 0 0" }}>
|
||||||
|
{t("client.docs.sub")}
|
||||||
</p>
|
</p>
|
||||||
{!lead && (
|
{!lead && (
|
||||||
<Card tone="perks" tight>
|
<Card tone="perks" tight>
|
||||||
<a href="#/" className="bc-small" style={{ fontWeight: 600, color: 'inherit' }}>
|
<a
|
||||||
{t('client.docs.needPlan')}
|
href="#/"
|
||||||
|
className="bc-small"
|
||||||
|
style={{ fontWeight: 600, color: "inherit" }}
|
||||||
|
>
|
||||||
|
{t("client.docs.needPlan")}
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
<Card style={{ padding: 8 }}>
|
{lead && lead.docs.length > 0 && (
|
||||||
<ul className="docs">
|
<Card style={{ padding: 8 }}>
|
||||||
{(lead?.docs ?? []).map((d) => {
|
<ul className="docs">
|
||||||
const hint = itemHint(plan.data.names, 'DOC', d, locale);
|
{(lead?.docs ?? []).map((d) => {
|
||||||
return (
|
const hint = itemHint(plan.data.names, "DOC", d, locale);
|
||||||
<li key={d.id}>
|
return (
|
||||||
<button className="doc" aria-pressed={d.checked} disabled={toggle.isPending} onClick={() => toggle.mutate({ docId: d.id, checked: !d.checked })}>
|
<li key={d.id}>
|
||||||
<span className="doc__box" aria-hidden="true">
|
<button
|
||||||
{d.checked && <Icon name="check" size={16} strokeWidth={3} />}
|
className="doc"
|
||||||
</span>
|
aria-pressed={d.checked}
|
||||||
<span>
|
disabled={toggle.isPending}
|
||||||
<b>{itemName(plan.data.names, 'DOC', d, locale)}</b>
|
onClick={() =>
|
||||||
{hint && <span className="bc-small bc-muted">{hint}</span>}
|
toggle.mutate({ docId: d.id, checked: !d.checked })
|
||||||
</span>
|
}
|
||||||
</button>
|
>
|
||||||
</li>
|
<span className="doc__box" aria-hidden="true">
|
||||||
);
|
{d.checked && (
|
||||||
})}
|
<Icon name="check" size={16} strokeWidth={3} />
|
||||||
</ul>
|
)}
|
||||||
</Card>
|
</span>
|
||||||
|
<span>
|
||||||
|
<b>{itemName(plan.data.names, "DOC", d, locale)}</b>
|
||||||
|
{hint && (
|
||||||
|
<span className="bc-small bc-muted">{hint}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{me.data && (
|
{me.data && (
|
||||||
<button className="bc-btn bc-btn--light bc-btn--block" onClick={() => navigate('/chat')}>
|
<button
|
||||||
|
className="bc-btn bc-btn--light bc-btn--block"
|
||||||
|
onClick={() => navigate("/chat")}
|
||||||
|
>
|
||||||
<Icon name="chat" size={18} />
|
<Icon name="chat" size={18} />
|
||||||
{t('client.docs.ask')}
|
{t("client.docs.ask")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -31,16 +31,18 @@ export function Move() {
|
|||||||
const days = lead.moveDate ? Math.ceil((new Date(lead.moveDate).getTime() - Date.now()) / DAY) : null;
|
const days = lead.moveDate ? Math.ceil((new Date(lead.moveDate).getTime() - Date.now()) / DAY) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app app--hero">
|
||||||
<header className="bc-between" style={{ alignItems: 'flex-start' }}>
|
<section className="hero move-hero">
|
||||||
<div>
|
<div className="hero__word" aria-hidden="true">
|
||||||
<div className="bc-small bc-muted">{t('client.home.hello')}</div>
|
Barev
|
||||||
<h1 className="bc-h2">{me.data.firstName ?? t('client.home.guest')}</h1>
|
</div>
|
||||||
|
<div className="bc-between" style={{ alignItems: 'flex-start', position: 'relative' }}>
|
||||||
|
<h1 className="hero__hello">
|
||||||
|
<small>{t('client.home.hello')}</small>
|
||||||
|
<span>{me.data.firstName ?? t('client.home.guest')}</span>
|
||||||
|
</h1>
|
||||||
|
<LevelBadge level={me.data.membership.level as Level} />
|
||||||
</div>
|
</div>
|
||||||
<LevelBadge level={me.data.membership.level as Level} />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="move-hero">
|
|
||||||
<div className="move-hero__label">{t('client.move.title')}</div>
|
<div className="move-hero__label">{t('client.move.title')}</div>
|
||||||
<div className="move-hero__when">
|
<div className="move-hero__when">
|
||||||
{lead.moveWhen === 'date' && lead.moveDate
|
{lead.moveWhen === 'date' && lead.moveDate
|
||||||
@@ -55,7 +57,7 @@ export function Move() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card style={{ padding: '18px 18px 8px' }}>
|
<Card style={{ padding: '18px 18px 8px' }}>
|
||||||
<h2 className="bc-h3" style={{ marginBottom: 10 }}>
|
<h2 className="panel__title">
|
||||||
{t(`landing.quiz.package_${lead.package}`)}
|
{t(`landing.quiz.package_${lead.package}`)}
|
||||||
</h2>
|
</h2>
|
||||||
{steps.length === 0 ? (
|
{steps.length === 0 ? (
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export function MoveForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app app--hero">
|
||||||
<section className="move-hero">
|
<section className="hero move-hero">
|
||||||
<div className="move-hero__label">{me.data?.firstName ? t('client.move.formHello', { name: me.data.firstName }) : t('client.move.title')}</div>
|
<div className="move-hero__label">{me.data?.firstName ? t('client.move.formHello', { name: me.data.firstName }) : t('client.move.title')}</div>
|
||||||
<div className="move-hero__when">{t('client.move.formTitle')}</div>
|
<div className="move-hero__when">{t('client.move.formTitle')}</div>
|
||||||
<div className="move-hero__progress">{t('client.move.formSub')}</div>
|
<div className="move-hero__progress">{t('client.move.formSub')}</div>
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export function OrderForm({ code }: { code: string }) {
|
|||||||
const [address, setAddress] = useState('');
|
const [address, setAddress] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [methodId, setMethodId] = useState('');
|
const [methodId, setMethodId] = useState('');
|
||||||
|
// промокод: что ввёл клиент и что применено к расчёту
|
||||||
|
const [promoInput, setPromoInput] = useState('');
|
||||||
|
const [promo, setPromo] = useState('');
|
||||||
|
const [promoError, setPromoError] = useState<string | null>(null);
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
|
// дефолты услуги, затем — параметры повторяемого заказа (ORD-7)
|
||||||
@@ -50,10 +54,18 @@ export function OrderForm({ code }: { code: string }) {
|
|||||||
}, [days, day]);
|
}, [days, day]);
|
||||||
|
|
||||||
const quote = useQuery({
|
const quote = useQuery({
|
||||||
...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {} }),
|
...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {}, promoCode: promo || undefined }),
|
||||||
enabled: !!params,
|
enabled: !!params,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
retry: false,
|
||||||
});
|
});
|
||||||
|
// неподходящий код — показываем причину и считаем без него
|
||||||
|
useEffect(() => {
|
||||||
|
if (promo && quote.error) {
|
||||||
|
setPromoError(errorText(t, quote.error));
|
||||||
|
setPromo('');
|
||||||
|
}
|
||||||
|
}, [promo, quote.error, t]);
|
||||||
const slots = useQuery({
|
const slots = useQuery({
|
||||||
...trpc.client.slots.queryOptions({ serviceCode: code, params: params ?? {}, dateKey: day ?? '' }),
|
...trpc.client.slots.queryOptions({ serviceCode: code, params: params ?? {}, dateKey: day ?? '' }),
|
||||||
enabled: !!params && !!day,
|
enabled: !!params && !!day,
|
||||||
@@ -119,6 +131,7 @@ export function OrderForm({ code }: { code: string }) {
|
|||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
paymentMethodId: methodId || undefined,
|
paymentMethodId: methodId || undefined,
|
||||||
repeatOfId: repeatId ?? undefined,
|
repeatOfId: repeatId ?? undefined,
|
||||||
|
promoCode: q?.promo ? promo : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +280,7 @@ export function OrderForm({ code }: { code: string }) {
|
|||||||
</div>
|
</div>
|
||||||
{q.discountAmount > 0 ? (
|
{q.discountAmount > 0 ? (
|
||||||
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
<div className="bc-between" style={{ color: 'var(--bc-accent)', fontWeight: 600 }}>
|
||||||
<span>{t('client.order.discount', { pct: q.discountPct })}</span>
|
<span>{q.promo ? t('client.order.promoLine', { code: q.promo.code, pct: q.discountPct }) : t('client.order.discount', { pct: q.discountPct })}</span>
|
||||||
<span>−{amd(q.discountAmount)}</span>
|
<span>−{amd(q.discountAmount)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -281,6 +294,25 @@ export function OrderForm({ code }: { code: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
)}
|
)}
|
||||||
|
<PromoField
|
||||||
|
value={promoInput}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPromoInput(v);
|
||||||
|
setPromoError(null);
|
||||||
|
}}
|
||||||
|
applied={promo}
|
||||||
|
active={!!q?.promo}
|
||||||
|
error={promoError}
|
||||||
|
loading={quote.isFetching}
|
||||||
|
onApply={() => {
|
||||||
|
setPromoError(null);
|
||||||
|
setPromo(promoInput.trim());
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setPromo('');
|
||||||
|
setPromoInput('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||||
{t('client.order.cancelPolicy', { hours: me.data?.freeCancelHours ?? 24, pct: s.lateCancelFeePct })}
|
{t('client.order.cancelPolicy', { hours: me.data?.freeCancelHours ?? 24, pct: s.lateCancelFeePct })}
|
||||||
</p>
|
</p>
|
||||||
@@ -414,3 +446,35 @@ function ExtrasPicker({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Промокод: применённый — плашкой с кнопкой «Убрать»; если скидка подписки больше — так и пишем */
|
||||||
|
function PromoField(p: { value: string; onChange: (v: string) => void; applied: string; active: boolean; error: string | null; loading: boolean; onApply: () => void; onRemove: () => void }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (p.applied && !p.loading)
|
||||||
|
return (
|
||||||
|
<div className="promo-applied">
|
||||||
|
<span>
|
||||||
|
<b>{p.applied.toUpperCase()}</b>
|
||||||
|
{!p.active && <span className="bc-small">{t('client.order.promoWeaker')}</span>}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={p.onRemove}>
|
||||||
|
{t('client.order.promoRemove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="promo-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (p.value.trim().length >= 2) p.onApply();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input className="bc-input" value={p.value} maxLength={40} autoCapitalize="characters" placeholder={t('client.order.promoPlaceholder')} aria-label={t('client.order.promo')} onChange={(e) => p.onChange(e.target.value.toUpperCase())} />
|
||||||
|
<button type="submit" className="pill pill--dark" disabled={p.value.trim().length < 2 || p.loading}>
|
||||||
|
{t('client.order.promoApply')}
|
||||||
|
</button>
|
||||||
|
{p.error && <div className="bc-error promo-form__error">{p.error}</div>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
|
|||||||
import { Button, Card, Chip, Field, Icon, Segmented } from '@barev/ui';
|
import { Button, Card, Chip, Field, Icon, Segmented } from '@barev/ui';
|
||||||
import { errorText, haptic, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
import { errorText, haptic, useI18n, useLocation, useToast, useTRPC } from '@barev/web';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { LEVEL_NAME, LevelBadge, PageHead, type Level } from '../lib.tsx';
|
import { ClubTicket, LEVEL_NAME, type Level } from '../lib.tsx';
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile() {
|
||||||
const { t, dt } = useI18n();
|
const { t, dt } = useI18n();
|
||||||
@@ -14,21 +14,26 @@ export function Profile() {
|
|||||||
const plus = level !== 'BAREV';
|
const plus = level !== 'BAREV';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app app--hero">
|
||||||
<PageHead title={t('client.profile.title')} back={false} />
|
<section className="hero">
|
||||||
<Card className="bc-stack">
|
<h1 className="hero__hello">
|
||||||
<div className="bc-between">
|
<small>{t('client.profile.title')}</small>
|
||||||
<div>
|
<span>{me.data?.firstName}</span>
|
||||||
<div className="bc-h3">{me.data?.firstName}</div>
|
</h1>
|
||||||
<div className="bc-small bc-muted">{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}</div>
|
<p className="hero__note">{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}</p>
|
||||||
</div>
|
<ClubTicket
|
||||||
<LevelBadge level={level} />
|
level={level}
|
||||||
</div>
|
code={me.data?.cardCode}
|
||||||
{plus && me.data?.membership.endsAt && <div className="bc-small">{t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long', year: 'numeric' }) })}</div>}
|
href="#/membership"
|
||||||
<a href="#/membership" className="bc-row" style={{ fontWeight: 600, color: 'var(--bc-accent)', textDecoration: 'none' }}>
|
until={plus && me.data?.membership.endsAt ? t('client.home.levelUntil', { plan: LEVEL_NAME[level], date: dt(me.data.membership.endsAt, { day: 'numeric', month: 'long', year: 'numeric' }) }) : t('client.home.free')}
|
||||||
{t('client.profile.membership')} <Icon name="arrow" size={16} />
|
action={
|
||||||
</a>
|
<>
|
||||||
</Card>
|
<Icon name="star" size={26} />
|
||||||
|
<span>{t('client.card.plans')}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<SegmentSwitch current={me.data?.segment ?? null} />
|
<SegmentSwitch current={me.data?.segment ?? null} />
|
||||||
|
|
||||||
@@ -36,12 +41,23 @@ export function Profile() {
|
|||||||
|
|
||||||
<LanguagePicker />
|
<LanguagePicker />
|
||||||
|
|
||||||
<Card tight>
|
<nav className="links">
|
||||||
<a href="#/orders" className="bc-between" style={{ textDecoration: 'none', minHeight: 44 }}>
|
<a href="#/orders">
|
||||||
<span style={{ fontWeight: 600 }}>{t('client.orders.title')}</span>
|
<Icon name="clock" size={20} />
|
||||||
|
<span>{t('client.orders.title')}</span>
|
||||||
<Icon name="arrow" size={18} />
|
<Icon name="arrow" size={18} />
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
<a href="#/perks">
|
||||||
|
<Icon name="gift" size={20} />
|
||||||
|
<span>{t('client.card.title')}</span>
|
||||||
|
<Icon name="arrow" size={18} />
|
||||||
|
</a>
|
||||||
|
<a href="#/chat">
|
||||||
|
<Icon name="chat" size={20} />
|
||||||
|
<span>{t('client.nav.chat')}</span>
|
||||||
|
<Icon name="arrow" size={18} />
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { CATEGORY_ICON, Center, Icon, Spinner, type CategoryCode } from '@barev/ui';
|
import { CATEGORY_ICON, Center, Icon, Spinner, type CategoryCode } from '@barev/ui';
|
||||||
import { useI18n, useLocation, useTRPC } from '@barev/web';
|
import { useI18n, useLocation, useTRPC } from '@barev/web';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx';
|
import { levelDiscount, toneClass, type Level } from '../lib.tsx';
|
||||||
|
|
||||||
/** Каталог: направления со скидкой по уровню клиента; услуги без онлайн-записи — заявка «перезвоните» */
|
/** Каталог: направления со скидкой по уровню клиента; услуги без онлайн-записи — заявка «перезвоните» */
|
||||||
export function Services() {
|
export function Services() {
|
||||||
@@ -25,7 +25,9 @@ export function Services() {
|
|||||||
const level = catalog.data.level as Level;
|
const level = catalog.data.level as Level;
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<PageHead title={t('client.services.title')} back={false} />
|
<header className="screen-head">
|
||||||
|
<h1 className="screen-head__title">{t('client.services.title')}</h1>
|
||||||
|
</header>
|
||||||
{catalog.data.categories
|
{catalog.data.categories
|
||||||
.filter((c) => c.code !== 'PERKS')
|
.filter((c) => c.code !== 'PERKS')
|
||||||
.map((c) => (
|
.map((c) => (
|
||||||
|
|||||||
@@ -444,7 +444,11 @@
|
|||||||
"OFFER_NOT_AVAILABLE": "This offer isn’t available at the client’s level.",
|
"OFFER_NOT_AVAILABLE": "This offer isn’t available at the client’s level.",
|
||||||
"OFFER_LIMIT_REACHED": "The limit for this offer is already used up.",
|
"OFFER_LIMIT_REACHED": "The limit for this offer is already used up.",
|
||||||
"CONCIERGE_SUBSCRIBERS_ONLY": "The personal concierge is for Barev+ and Barev Elite members. For now, write to support — we will help too.",
|
"CONCIERGE_SUBSCRIBERS_ONLY": "The personal concierge is for Barev+ and Barev Elite members. For now, write to support — we will help too.",
|
||||||
"ASSIGNEE_NO_ACCESS": "This team member has no access to this channel."
|
"ASSIGNEE_NO_ACCESS": "This team member has no access to this channel.",
|
||||||
|
"PROMO_INVALID": "This promo code doesn't exist",
|
||||||
|
"PROMO_EXPIRED": "This promo code is no longer valid",
|
||||||
|
"PROMO_USED": "This promo code has already been used",
|
||||||
|
"PROMO_NOT_FOR_SERVICE": "This promo code doesn't apply to this service"
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -525,7 +529,13 @@
|
|||||||
"cancelPolicy": "Free cancellation and rescheduling up to {{hours}} h before. Later — a {{pct}}% fee.",
|
"cancelPolicy": "Free cancellation and rescheduling up to {{hours}} h before. Later — a {{pct}}% fee.",
|
||||||
"created": "Order #{{number}} created",
|
"created": "Order #{{number}} created",
|
||||||
"extras": "Extras",
|
"extras": "Extras",
|
||||||
"add": "Add"
|
"add": "Add",
|
||||||
|
"promo": "Promo code",
|
||||||
|
"promoPlaceholder": "Promo code, if you have one",
|
||||||
|
"promoApply": "Apply",
|
||||||
|
"promoRemove": "Remove",
|
||||||
|
"promoLine": "Promo {{code}} −{{pct}}%",
|
||||||
|
"promoWeaker": "Your membership discount is bigger, so it applies"
|
||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"title": "My orders",
|
"title": "My orders",
|
||||||
|
|||||||
@@ -444,7 +444,11 @@
|
|||||||
"OFFER_NOT_AVAILABLE": "Esta oferta no está disponible en el nivel del cliente.",
|
"OFFER_NOT_AVAILABLE": "Esta oferta no está disponible en el nivel del cliente.",
|
||||||
"OFFER_LIMIT_REACHED": "Ya se agotó el límite de esta oferta.",
|
"OFFER_LIMIT_REACHED": "Ya se agotó el límite de esta oferta.",
|
||||||
"CONCIERGE_SUBSCRIBERS_ONLY": "El conserje personal es para miembros de Barev+ y Barev Elite. De momento, escribe a soporte: también te ayudaremos.",
|
"CONCIERGE_SUBSCRIBERS_ONLY": "El conserje personal es para miembros de Barev+ y Barev Elite. De momento, escribe a soporte: también te ayudaremos.",
|
||||||
"ASSIGNEE_NO_ACCESS": "Esta persona del equipo no tiene acceso a este canal."
|
"ASSIGNEE_NO_ACCESS": "Esta persona del equipo no tiene acceso a este canal.",
|
||||||
|
"PROMO_INVALID": "Este código no existe",
|
||||||
|
"PROMO_EXPIRED": "El código ya no es válido",
|
||||||
|
"PROMO_USED": "Este código ya se ha usado",
|
||||||
|
"PROMO_NOT_FOR_SERVICE": "El código no es válido para este servicio"
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -525,7 +529,13 @@
|
|||||||
"cancelPolicy": "Cancelación y cambio gratis hasta {{hours}} h antes. Después, se retiene un {{pct}}%.",
|
"cancelPolicy": "Cancelación y cambio gratis hasta {{hours}} h antes. Después, se retiene un {{pct}}%.",
|
||||||
"created": "Pedido n.º {{number}} creado",
|
"created": "Pedido n.º {{number}} creado",
|
||||||
"extras": "Extras",
|
"extras": "Extras",
|
||||||
"add": "Añadir"
|
"add": "Añadir",
|
||||||
|
"promo": "Código promocional",
|
||||||
|
"promoPlaceholder": "Código promocional, si tienes",
|
||||||
|
"promoApply": "Aplicar",
|
||||||
|
"promoRemove": "Quitar",
|
||||||
|
"promoLine": "Código {{code}} −{{pct}}%",
|
||||||
|
"promoWeaker": "El descuento de tu suscripción es mayor y se aplica ese"
|
||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"title": "Mis pedidos",
|
"title": "Mis pedidos",
|
||||||
|
|||||||
@@ -444,7 +444,11 @@
|
|||||||
"OFFER_NOT_AVAILABLE": "Այս առաջարկը հասանելի չէ հաճախորդի մակարդակում։",
|
"OFFER_NOT_AVAILABLE": "Այս առաջարկը հասանելի չէ հաճախորդի մակարդակում։",
|
||||||
"OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։",
|
"OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։",
|
||||||
"CONCIERGE_SUBSCRIBERS_ONLY": "Անձնական կոնսիերժը Barev+ և Barev Elite բաժանորդների համար է։ Առայժմ գրեք աջակցությանը՝ մենք էլ կօգնենք։",
|
"CONCIERGE_SUBSCRIBERS_ONLY": "Անձնական կոնսիերժը Barev+ և Barev Elite բաժանորդների համար է։ Առայժմ գրեք աջակցությանը՝ մենք էլ կօգնենք։",
|
||||||
"ASSIGNEE_NO_ACCESS": "Այս աշխատակիցը մուտք չունի այս ալիքին։"
|
"ASSIGNEE_NO_ACCESS": "Այս աշխատակիցը մուտք չունի այս ալիքին։",
|
||||||
|
"PROMO_INVALID": "Նման պրոմոկոդ չկա",
|
||||||
|
"PROMO_EXPIRED": "Պրոմոկոդն այլևս չի գործում",
|
||||||
|
"PROMO_USED": "Այս պրոմոկոդն արդեն օգտագործված է",
|
||||||
|
"PROMO_NOT_FOR_SERVICE": "Պրոմոկոդը չի գործում այս ծառայության համար"
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -490,7 +494,7 @@
|
|||||||
"cardShow": "Ցույց տալ QR",
|
"cardShow": "Ցույց տալ QR",
|
||||||
"free": "Անվճար մակարդակ",
|
"free": "Անվճար մակարդակ",
|
||||||
"chatTitle": "Ցանկացած հարց՝ չատում",
|
"chatTitle": "Ցանկացած հարց՝ չատում",
|
||||||
"chatText": "Կպատասխանենք ձեր լեզվով"
|
"chatText": "Կպատասխանենք քո լեզվով"
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"title": "Ծառայություններ",
|
"title": "Ծառայություններ",
|
||||||
@@ -525,7 +529,13 @@
|
|||||||
"cancelPolicy": "Անվճար չեղարկում և տեղափոխում՝ ոչ ուշ, քան {{hours}} ժ առաջ։ Ավելի ուշ՝ պահվում է {{pct}}%։",
|
"cancelPolicy": "Անվճար չեղարկում և տեղափոխում՝ ոչ ուշ, քան {{hours}} ժ առաջ։ Ավելի ուշ՝ պահվում է {{pct}}%։",
|
||||||
"created": "Պատվեր #{{number}}-ը ստեղծված է",
|
"created": "Պատվեր #{{number}}-ը ստեղծված է",
|
||||||
"extras": "Լրացուցիչ",
|
"extras": "Լրացուցիչ",
|
||||||
"add": "Ավելացնել"
|
"add": "Ավելացնել",
|
||||||
|
"promo": "Պրոմոկոդ",
|
||||||
|
"promoPlaceholder": "Պրոմոկոդ, եթե ունես",
|
||||||
|
"promoApply": "Կիրառել",
|
||||||
|
"promoRemove": "Հեռացնել",
|
||||||
|
"promoLine": "Պրոմոկոդ {{code}} −{{pct}}%",
|
||||||
|
"promoWeaker": "Բաժանորդագրության զեղչն ավելի մեծ է, գործում է այն"
|
||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"title": "Իմ հայտերը",
|
"title": "Իմ հայտերը",
|
||||||
@@ -693,10 +703,10 @@
|
|||||||
"comment": "Մեկնաբանություն՝ ըստ ցանկության",
|
"comment": "Մեկնաբանություն՝ ըստ ցանկության",
|
||||||
"send": "Գնահատել",
|
"send": "Գնահատել",
|
||||||
"thanks": "Շնորհակալություն գնահատականի համար",
|
"thanks": "Շնորհակալություն գնահատականի համար",
|
||||||
"yours": "Ձեր գնահատականը",
|
"yours": "Քո գնահատականը",
|
||||||
"change": "Փոխել",
|
"change": "Փոխել",
|
||||||
"star": "{{count}}՝ 5-ից",
|
"star": "{{count}}՝ 5-ից",
|
||||||
"prompt": "Գնահատեք պատվերը"
|
"prompt": "Գնահատիր պատվերը"
|
||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"title": "Ակումբային քարտ",
|
"title": "Ակումբային քարտ",
|
||||||
|
|||||||
@@ -444,7 +444,11 @@
|
|||||||
"OFFER_NOT_AVAILABLE": "Это предложение недоступно на уровне клиента.",
|
"OFFER_NOT_AVAILABLE": "Это предложение недоступно на уровне клиента.",
|
||||||
"OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан.",
|
"OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан.",
|
||||||
"CONCIERGE_SUBSCRIBERS_ONLY": "Личный консьерж — для подписчиков Barev+ и Barev Elite. Пока пишите в поддержку — мы тоже поможем.",
|
"CONCIERGE_SUBSCRIBERS_ONLY": "Личный консьерж — для подписчиков Barev+ и Barev Elite. Пока пишите в поддержку — мы тоже поможем.",
|
||||||
"ASSIGNEE_NO_ACCESS": "У этого сотрудника нет доступа к этому каналу."
|
"ASSIGNEE_NO_ACCESS": "У этого сотрудника нет доступа к этому каналу.",
|
||||||
|
"PROMO_INVALID": "Такого промокода нет",
|
||||||
|
"PROMO_EXPIRED": "Промокод больше не действует",
|
||||||
|
"PROMO_USED": "Этот промокод уже использован",
|
||||||
|
"PROMO_NOT_FOR_SERVICE": "Промокод не действует на эту услугу"
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -490,7 +494,7 @@
|
|||||||
"cardShow": "Показать QR",
|
"cardShow": "Показать QR",
|
||||||
"free": "Бесплатный уровень",
|
"free": "Бесплатный уровень",
|
||||||
"chatTitle": "Любой вопрос — в чат",
|
"chatTitle": "Любой вопрос — в чат",
|
||||||
"chatText": "Ответим на вашем языке"
|
"chatText": "Ответим на твоём языке"
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"title": "Услуги",
|
"title": "Услуги",
|
||||||
@@ -525,7 +529,13 @@
|
|||||||
"cancelPolicy": "Бесплатная отмена и перенос — не позднее чем за {{hours}} ч. Позже — удержание {{pct}}%.",
|
"cancelPolicy": "Бесплатная отмена и перенос — не позднее чем за {{hours}} ч. Позже — удержание {{pct}}%.",
|
||||||
"created": "Заказ #{{number}} создан",
|
"created": "Заказ #{{number}} создан",
|
||||||
"extras": "Дополнительно",
|
"extras": "Дополнительно",
|
||||||
"add": "Добавить"
|
"add": "Добавить",
|
||||||
|
"promo": "Промокод",
|
||||||
|
"promoPlaceholder": "Промокод, если есть",
|
||||||
|
"promoApply": "Применить",
|
||||||
|
"promoRemove": "Убрать",
|
||||||
|
"promoLine": "Промокод {{code}} −{{pct}}%",
|
||||||
|
"promoWeaker": "Скидка по подписке больше — действует она"
|
||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"title": "Мои заявки",
|
"title": "Мои заявки",
|
||||||
@@ -693,10 +703,10 @@
|
|||||||
"comment": "Комментарий — по желанию",
|
"comment": "Комментарий — по желанию",
|
||||||
"send": "Оценить",
|
"send": "Оценить",
|
||||||
"thanks": "Спасибо за оценку!",
|
"thanks": "Спасибо за оценку!",
|
||||||
"yours": "Ваша оценка",
|
"yours": "Твоя оценка",
|
||||||
"change": "Изменить",
|
"change": "Изменить",
|
||||||
"star": "{{count}} из 5",
|
"star": "{{count}} из 5",
|
||||||
"prompt": "Оцените заказ"
|
"prompt": "Оцени заказ"
|
||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"title": "Клубная карта",
|
"title": "Клубная карта",
|
||||||
|
|||||||
@@ -444,7 +444,11 @@
|
|||||||
"OFFER_NOT_AVAILABLE": "该优惠不适用于客户当前等级。",
|
"OFFER_NOT_AVAILABLE": "该优惠不适用于客户当前等级。",
|
||||||
"OFFER_LIMIT_REACHED": "该优惠次数已用完。",
|
"OFFER_LIMIT_REACHED": "该优惠次数已用完。",
|
||||||
"CONCIERGE_SUBSCRIBERS_ONLY": "专属礼宾仅面向 Barev+ 和 Barev Elite 会员。目前请联系客服,我们同样会帮你。",
|
"CONCIERGE_SUBSCRIBERS_ONLY": "专属礼宾仅面向 Barev+ 和 Barev Elite 会员。目前请联系客服,我们同样会帮你。",
|
||||||
"ASSIGNEE_NO_ACCESS": "该成员无权访问此频道。"
|
"ASSIGNEE_NO_ACCESS": "该成员无权访问此频道。",
|
||||||
|
"PROMO_INVALID": "优惠码不存在",
|
||||||
|
"PROMO_EXPIRED": "优惠码已失效",
|
||||||
|
"PROMO_USED": "该优惠码已使用",
|
||||||
|
"PROMO_NOT_FOR_SERVICE": "该优惠码不适用于此服务"
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"nav": {
|
"nav": {
|
||||||
@@ -525,7 +529,13 @@
|
|||||||
"cancelPolicy": "开始前 {{hours}} 小时以上可免费取消或改期,之后取消将扣除 {{pct}}%。",
|
"cancelPolicy": "开始前 {{hours}} 小时以上可免费取消或改期,之后取消将扣除 {{pct}}%。",
|
||||||
"created": "订单 #{{number}} 已创建",
|
"created": "订单 #{{number}} 已创建",
|
||||||
"extras": "附加服务",
|
"extras": "附加服务",
|
||||||
"add": "添加"
|
"add": "添加",
|
||||||
|
"promo": "优惠码",
|
||||||
|
"promoPlaceholder": "如有优惠码请输入",
|
||||||
|
"promoApply": "使用",
|
||||||
|
"promoRemove": "移除",
|
||||||
|
"promoLine": "优惠码 {{code}} −{{pct}}%",
|
||||||
|
"promoWeaker": "会员折扣更大,将使用会员折扣"
|
||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"title": "我的订单",
|
"title": "我的订单",
|
||||||
|
|||||||
Reference in new issue
Block a user