diff --git a/apps/miniapp-client/src/redesign.css b/apps/miniapp-client/src/redesign.css index 981b8b5..dab8d1a 100644 --- a/apps/miniapp-client/src/redesign.css +++ b/apps/miniapp-client/src/redesign.css @@ -827,6 +827,126 @@ 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 { background: var(--bc-plum); diff --git a/apps/miniapp-client/src/screens/Docs.tsx b/apps/miniapp-client/src/screens/Docs.tsx index 8d66bd5..3b28f97 100644 --- a/apps/miniapp-client/src/screens/Docs.tsx +++ b/apps/miniapp-client/src/screens/Docs.tsx @@ -1,7 +1,14 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Card, Center, Icon, Spinner } from '@barev/ui'; -import { errorText, haptic, navigate, useI18n, useToast, useTRPC } from '@barev/web'; -import { itemHint, itemName, PageHead } from '../lib.tsx'; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Card, Center, Icon, Spinner } from "@barev/ui"; +import { + errorText, + haptic, + navigate, + useI18n, + useToast, + useTRPC, +} from "@barev/web"; +import { itemHint, itemName } from "../lib.tsx"; /** Чек-лист документов, которые лучше собрать до переезда; отметки сохраняются в заявке на переезд */ export function Docs() { @@ -14,8 +21,11 @@ export function Docs() { const toggle = useMutation( trpc.client.relocation.toggleDoc.mutationOptions({ onMutate: () => haptic.tap(), - onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.client.relocation.get.queryKey() }), - onError: (e) => toast(errorText(t, e), 'error'), + onSuccess: () => + void qc.invalidateQueries({ + queryKey: trpc.client.relocation.get.queryKey(), + }), + onError: (e) => toast(errorText(t, e), "error"), }), ); if (!plan.data) @@ -28,41 +38,63 @@ export function Docs() { return (
- -

- {t('client.docs.sub')} +

+

{t("client.docs.title")}

+
+

+ {t("client.docs.sub")}

{!lead && ( - - {t('client.docs.needPlan')} + + {t("client.docs.needPlan")} )} - -
    - {(lead?.docs ?? []).map((d) => { - const hint = itemHint(plan.data.names, 'DOC', d, locale); - return ( -
  • - -
  • - ); - })} -
-
+ {lead && lead.docs.length > 0 && ( + +
    + {(lead?.docs ?? []).map((d) => { + const hint = itemHint(plan.data.names, "DOC", d, locale); + return ( +
  • + +
  • + ); + })} +
+
+ )} {me.data && ( - )}
diff --git a/apps/miniapp-client/src/screens/Move.tsx b/apps/miniapp-client/src/screens/Move.tsx index ed15d20..ce9a9f8 100644 --- a/apps/miniapp-client/src/screens/Move.tsx +++ b/apps/miniapp-client/src/screens/Move.tsx @@ -31,16 +31,18 @@ export function Move() { const days = lead.moveDate ? Math.ceil((new Date(lead.moveDate).getTime() - Date.now()) / DAY) : null; return ( -
-
-
-
{t('client.home.hello')}
-

{me.data.firstName ?? t('client.home.guest')}

+
+
+ +
+

+ {t('client.home.hello')} + {me.data.firstName ?? t('client.home.guest')} +

+
- -
- -
{t('client.move.title')}
{lead.moveWhen === 'date' && lead.moveDate @@ -55,7 +57,7 @@ export function Move() {
-

+

{t(`landing.quiz.package_${lead.package}`)}

{steps.length === 0 ? ( diff --git a/apps/miniapp-client/src/screens/MoveForm.tsx b/apps/miniapp-client/src/screens/MoveForm.tsx index 50885c8..58565b8 100644 --- a/apps/miniapp-client/src/screens/MoveForm.tsx +++ b/apps/miniapp-client/src/screens/MoveForm.tsx @@ -66,8 +66,8 @@ export function MoveForm() { ); return ( -
-
+
+
{me.data?.firstName ? t('client.move.formHello', { name: me.data.firstName }) : t('client.move.title')}
{t('client.move.formTitle')}
{t('client.move.formSub')}
diff --git a/apps/miniapp-client/src/screens/OrderForm.tsx b/apps/miniapp-client/src/screens/OrderForm.tsx index cb93bdb..071a278 100644 --- a/apps/miniapp-client/src/screens/OrderForm.tsx +++ b/apps/miniapp-client/src/screens/OrderForm.tsx @@ -27,6 +27,10 @@ export function OrderForm({ code }: { code: string }) { const [address, setAddress] = useState(''); const [note, setNote] = useState(''); const [methodId, setMethodId] = useState(''); + // промокод: что ввёл клиент и что применено к расчёту + const [promoInput, setPromoInput] = useState(''); + const [promo, setPromo] = useState(''); + const [promoError, setPromoError] = useState(null); const [touched, setTouched] = useState(false); // дефолты услуги, затем — параметры повторяемого заказа (ORD-7) @@ -50,10 +54,18 @@ export function OrderForm({ code }: { code: string }) { }, [days, day]); const quote = useQuery({ - ...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {} }), + ...trpc.client.quote.queryOptions({ serviceCode: code, params: params ?? {}, promoCode: promo || undefined }), enabled: !!params, placeholderData: keepPreviousData, + retry: false, }); + // неподходящий код — показываем причину и считаем без него + useEffect(() => { + if (promo && quote.error) { + setPromoError(errorText(t, quote.error)); + setPromo(''); + } + }, [promo, quote.error, t]); const slots = useQuery({ ...trpc.client.slots.queryOptions({ serviceCode: code, params: params ?? {}, dateKey: day ?? '' }), enabled: !!params && !!day, @@ -119,6 +131,7 @@ export function OrderForm({ code }: { code: string }) { note: note.trim() || undefined, paymentMethodId: methodId || undefined, repeatOfId: repeatId ?? undefined, + promoCode: q?.promo ? promo : undefined, }); } @@ -267,7 +280,7 @@ export function OrderForm({ code }: { code: string }) { {q.discountAmount > 0 ? (
- {t('client.order.discount', { pct: q.discountPct })} + {q.promo ? t('client.order.promoLine', { code: q.promo.code, pct: q.discountPct }) : t('client.order.discount', { pct: q.discountPct })} −{amd(q.discountAmount)}
) : ( @@ -281,6 +294,25 @@ export function OrderForm({ code }: { code: string }) { ) : ( )} + { + setPromoInput(v); + setPromoError(null); + }} + applied={promo} + active={!!q?.promo} + error={promoError} + loading={quote.isFetching} + onApply={() => { + setPromoError(null); + setPromo(promoInput.trim()); + }} + onRemove={() => { + setPromo(''); + setPromoInput(''); + }} + />

{t('client.order.cancelPolicy', { hours: me.data?.freeCancelHours ?? 24, pct: s.lateCancelFeePct })}

@@ -414,3 +446,35 @@ function ExtrasPicker({ ); } + +/** Промокод: применённый — плашкой с кнопкой «Убрать»; если скидка подписки больше — так и пишем */ +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 ( +
+ + {p.applied.toUpperCase()} + {!p.active && {t('client.order.promoWeaker')}} + + +
+ ); + return ( +
{ + e.preventDefault(); + if (p.value.trim().length >= 2) p.onApply(); + }} + > + p.onChange(e.target.value.toUpperCase())} /> + + {p.error &&
{p.error}
} +
+ ); +} diff --git a/apps/miniapp-client/src/screens/Profile.tsx b/apps/miniapp-client/src/screens/Profile.tsx index c38f64b..f304d91 100644 --- a/apps/miniapp-client/src/screens/Profile.tsx +++ b/apps/miniapp-client/src/screens/Profile.tsx @@ -3,7 +3,7 @@ import { LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n'; import { Button, Card, Chip, Field, Icon, Segmented } from '@barev/ui'; import { errorText, haptic, useI18n, useLocation, useToast, useTRPC } from '@barev/web'; 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() { const { t, dt } = useI18n(); @@ -14,21 +14,26 @@ export function Profile() { const plus = level !== 'BAREV'; return ( -
- - -
-
-
{me.data?.firstName}
-
{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}
-
- -
- {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.profile.membership')} - -
+
+
+

+ {t('client.profile.title')} + {me.data?.firstName} +

+

{me.data?.agencyName ? t('client.profile.fromAgency', { agency: me.data.agencyName }) : t('client.profile.noAgency')}

+ + + {t('client.card.plans')} + + } + /> +
@@ -36,12 +41,23 @@ export function Profile() { - - - {t('client.orders.title')} +
); } diff --git a/apps/miniapp-client/src/screens/Services.tsx b/apps/miniapp-client/src/screens/Services.tsx index b3f283e..4489222 100644 --- a/apps/miniapp-client/src/screens/Services.tsx +++ b/apps/miniapp-client/src/screens/Services.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { CATEGORY_ICON, Center, Icon, Spinner, type CategoryCode } from '@barev/ui'; import { useI18n, useLocation, useTRPC } from '@barev/web'; import { useEffect } from 'react'; -import { levelDiscount, PageHead, toneClass, type Level } from '../lib.tsx'; +import { levelDiscount, toneClass, type Level } from '../lib.tsx'; /** Каталог: направления со скидкой по уровню клиента; услуги без онлайн-записи — заявка «перезвоните» */ export function Services() { @@ -25,7 +25,9 @@ export function Services() { const level = catalog.data.level as Level; return (
- +
+

{t('client.services.title')}

+
{catalog.data.categories .filter((c) => c.code !== 'PERKS') .map((c) => ( diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index f6bd19b..ae65e6f 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -444,7 +444,11 @@ "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.", "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": { "nav": { @@ -525,7 +529,13 @@ "cancelPolicy": "Free cancellation and rescheduling up to {{hours}} h before. Later — a {{pct}}% fee.", "created": "Order #{{number}} created", "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": { "title": "My orders", diff --git a/packages/i18n/locales/es.json b/packages/i18n/locales/es.json index bc583aa..f31f94f 100644 --- a/packages/i18n/locales/es.json +++ b/packages/i18n/locales/es.json @@ -444,7 +444,11 @@ "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.", "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": { "nav": { @@ -525,7 +529,13 @@ "cancelPolicy": "Cancelación y cambio gratis hasta {{hours}} h antes. Después, se retiene un {{pct}}%.", "created": "Pedido n.º {{number}} creado", "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": { "title": "Mis pedidos", diff --git a/packages/i18n/locales/hy.json b/packages/i18n/locales/hy.json index e588a33..048d20e 100644 --- a/packages/i18n/locales/hy.json +++ b/packages/i18n/locales/hy.json @@ -444,7 +444,11 @@ "OFFER_NOT_AVAILABLE": "Այս առաջարկը հասանելի չէ հաճախորդի մակարդակում։", "OFFER_LIMIT_REACHED": "Այս առաջարկի սահմանաչափն արդեն սպառված է։", "CONCIERGE_SUBSCRIBERS_ONLY": "Անձնական կոնսիերժը Barev+ և Barev Elite բաժանորդների համար է։ Առայժմ գրեք աջակցությանը՝ մենք էլ կօգնենք։", - "ASSIGNEE_NO_ACCESS": "Այս աշխատակիցը մուտք չունի այս ալիքին։" + "ASSIGNEE_NO_ACCESS": "Այս աշխատակիցը մուտք չունի այս ալիքին։", + "PROMO_INVALID": "Նման պրոմոկոդ չկա", + "PROMO_EXPIRED": "Պրոմոկոդն այլևս չի գործում", + "PROMO_USED": "Այս պրոմոկոդն արդեն օգտագործված է", + "PROMO_NOT_FOR_SERVICE": "Պրոմոկոդը չի գործում այս ծառայության համար" }, "client": { "nav": { @@ -490,7 +494,7 @@ "cardShow": "Ցույց տալ QR", "free": "Անվճար մակարդակ", "chatTitle": "Ցանկացած հարց՝ չատում", - "chatText": "Կպատասխանենք ձեր լեզվով" + "chatText": "Կպատասխանենք քո լեզվով" }, "services": { "title": "Ծառայություններ", @@ -525,7 +529,13 @@ "cancelPolicy": "Անվճար չեղարկում և տեղափոխում՝ ոչ ուշ, քան {{hours}} ժ առաջ։ Ավելի ուշ՝ պահվում է {{pct}}%։", "created": "Պատվեր #{{number}}-ը ստեղծված է", "extras": "Լրացուցիչ", - "add": "Ավելացնել" + "add": "Ավելացնել", + "promo": "Պրոմոկոդ", + "promoPlaceholder": "Պրոմոկոդ, եթե ունես", + "promoApply": "Կիրառել", + "promoRemove": "Հեռացնել", + "promoLine": "Պրոմոկոդ {{code}} −{{pct}}%", + "promoWeaker": "Բաժանորդագրության զեղչն ավելի մեծ է, գործում է այն" }, "orders": { "title": "Իմ հայտերը", @@ -693,10 +703,10 @@ "comment": "Մեկնաբանություն՝ ըստ ցանկության", "send": "Գնահատել", "thanks": "Շնորհակալություն գնահատականի համար", - "yours": "Ձեր գնահատականը", + "yours": "Քո գնահատականը", "change": "Փոխել", "star": "{{count}}՝ 5-ից", - "prompt": "Գնահատեք պատվերը" + "prompt": "Գնահատիր պատվերը" }, "card": { "title": "Ակումբային քարտ", diff --git a/packages/i18n/locales/ru.json b/packages/i18n/locales/ru.json index 331809d..24cda26 100644 --- a/packages/i18n/locales/ru.json +++ b/packages/i18n/locales/ru.json @@ -444,7 +444,11 @@ "OFFER_NOT_AVAILABLE": "Это предложение недоступно на уровне клиента.", "OFFER_LIMIT_REACHED": "Лимит по этому предложению уже исчерпан.", "CONCIERGE_SUBSCRIBERS_ONLY": "Личный консьерж — для подписчиков Barev+ и Barev Elite. Пока пишите в поддержку — мы тоже поможем.", - "ASSIGNEE_NO_ACCESS": "У этого сотрудника нет доступа к этому каналу." + "ASSIGNEE_NO_ACCESS": "У этого сотрудника нет доступа к этому каналу.", + "PROMO_INVALID": "Такого промокода нет", + "PROMO_EXPIRED": "Промокод больше не действует", + "PROMO_USED": "Этот промокод уже использован", + "PROMO_NOT_FOR_SERVICE": "Промокод не действует на эту услугу" }, "client": { "nav": { @@ -490,7 +494,7 @@ "cardShow": "Показать QR", "free": "Бесплатный уровень", "chatTitle": "Любой вопрос — в чат", - "chatText": "Ответим на вашем языке" + "chatText": "Ответим на твоём языке" }, "services": { "title": "Услуги", @@ -525,7 +529,13 @@ "cancelPolicy": "Бесплатная отмена и перенос — не позднее чем за {{hours}} ч. Позже — удержание {{pct}}%.", "created": "Заказ #{{number}} создан", "extras": "Дополнительно", - "add": "Добавить" + "add": "Добавить", + "promo": "Промокод", + "promoPlaceholder": "Промокод, если есть", + "promoApply": "Применить", + "promoRemove": "Убрать", + "promoLine": "Промокод {{code}} −{{pct}}%", + "promoWeaker": "Скидка по подписке больше — действует она" }, "orders": { "title": "Мои заявки", @@ -693,10 +703,10 @@ "comment": "Комментарий — по желанию", "send": "Оценить", "thanks": "Спасибо за оценку!", - "yours": "Ваша оценка", + "yours": "Твоя оценка", "change": "Изменить", "star": "{{count}} из 5", - "prompt": "Оцените заказ" + "prompt": "Оцени заказ" }, "card": { "title": "Клубная карта", diff --git a/packages/i18n/locales/zh.json b/packages/i18n/locales/zh.json index a1d8f42..f13126f 100644 --- a/packages/i18n/locales/zh.json +++ b/packages/i18n/locales/zh.json @@ -444,7 +444,11 @@ "OFFER_NOT_AVAILABLE": "该优惠不适用于客户当前等级。", "OFFER_LIMIT_REACHED": "该优惠次数已用完。", "CONCIERGE_SUBSCRIBERS_ONLY": "专属礼宾仅面向 Barev+ 和 Barev Elite 会员。目前请联系客服,我们同样会帮你。", - "ASSIGNEE_NO_ACCESS": "该成员无权访问此频道。" + "ASSIGNEE_NO_ACCESS": "该成员无权访问此频道。", + "PROMO_INVALID": "优惠码不存在", + "PROMO_EXPIRED": "优惠码已失效", + "PROMO_USED": "该优惠码已使用", + "PROMO_NOT_FOR_SERVICE": "该优惠码不适用于此服务" }, "client": { "nav": { @@ -525,7 +529,13 @@ "cancelPolicy": "开始前 {{hours}} 小时以上可免费取消或改期,之后取消将扣除 {{pct}}%。", "created": "订单 #{{number}} 已创建", "extras": "附加服务", - "add": "添加" + "add": "添加", + "promo": "优惠码", + "promoPlaceholder": "如有优惠码请输入", + "promoApply": "使用", + "promoRemove": "移除", + "promoLine": "优惠码 {{code}} −{{pct}}%", + "promoWeaker": "会员折扣更大,将使用会员折扣" }, "orders": { "title": "我的订单",