Files
BarevClub/apps/miniapp-client/src/screens/MoveForm.tsx
T
zabudko-ruandClaude Opus 5.5 bbb25d3d0d 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>
2026-10-21 04:08:47 +04:00

152 lines
6.9 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Field } from '@barev/ui';
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
const WHEN = ['month', 'quarter', 'later', 'date'] as const;
const PACKAGES = ['CONSULT', 'TURNKEY', 'CUSTOM'] as const;
const YES_NO = ['flat', 'registration', 'socialCard', 'bankCard', 'residence'] as const;
type When = (typeof WHEN)[number];
type Pkg = (typeof PACKAGES)[number];
/** Конструктор переезда в приложении: когда, какой пакет, что нужно. После отправки — план по шагам */
export function MoveForm() {
const { t, amd } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const me = useQuery(trpc.client.me.queryOptions());
const [when, setWhen] = useState<When | null>(null);
const [date, setDate] = useState('');
const [pkg, setPkg] = useState<Pkg | null>(null);
const [airport, setAirport] = useState<'none' | 'standard' | 'premium'>('standard');
const [stay, setStay] = useState<'none' | 'flat' | 'hotel'>('hotel');
const [budgetFrom, setBudgetFrom] = useState('');
const [budgetTo, setBudgetTo] = useState('');
const [yes, setYes] = useState<Record<(typeof YES_NO)[number], boolean>>({ flat: true, registration: true, socialCard: true, bankCard: false, residence: false });
const [comment, setComment] = useState('');
const [error, setError] = useState<string | null>(null);
const create = useMutation(
trpc.client.relocation.create.mutationOptions({
onSuccess: () => {
haptic.ok();
toast(t('client.move.created'));
void qc.invalidateQueries();
window.scrollTo({ top: 0 });
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
function submit() {
if (!when || (when === 'date' && !date)) return setError(t('client.move.errWhen'));
if (!pkg) return setError(t('client.move.errPackage'));
const any = airport !== 'none' || stay !== 'none' || YES_NO.some((k) => yes[k]);
if (pkg === 'CUSTOM' && !any) return setError(t('client.move.errEmpty'));
setError(null);
const num = (v: string) => (v ? Number(v) : undefined);
create.mutate({
moveWhen: when,
moveDate: when === 'date' ? date : undefined,
package: pkg,
options: pkg === 'CUSTOM' ? { airport, stay, stayBudgetFrom: stay === 'none' ? undefined : num(budgetFrom), stayBudgetTo: stay === 'none' ? undefined : num(budgetTo), ...yes } : undefined,
comment: comment.trim() || undefined,
});
}
const seg = <V extends string>(value: V, options: readonly V[], label: (v: V) => string, onChange: (v: V) => void) => (
<div className="mf-seg" role="radiogroup">
{options.map((o) => (
<button key={o} type="button" role="radio" aria-checked={value === o} onClick={() => onChange(o)}>
{label(o)}
</button>
))}
</div>
);
return (
<main className="app app--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__when">{t('client.move.formTitle')}</div>
<div className="move-hero__progress">{t('client.move.formSub')}</div>
</section>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.move.whenTitle')}</h2>
<div className="mf-choices">
{WHEN.map((w) => (
<button key={w} type="button" className="mf-choice" aria-pressed={when === w} onClick={() => setWhen(w)}>
{t(`landing.quiz.when_${w}`)}
</button>
))}
</div>
{when === 'date' && (
<Field label={t('landing.quiz.date')}>
<input className="bc-input" type="date" min={new Date().toISOString().slice(0, 10)} value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
)}
</Card>
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.move.packageTitle')}</h2>
{PACKAGES.map((p) => (
<button key={p} type="button" className="mf-package" aria-pressed={pkg === p} onClick={() => setPkg(p)}>
<span className="bc-between" style={{ alignItems: 'baseline', gap: 10 }}>
<b>{t(`landing.quiz.package_${p}`)}</b>
{p === 'CONSULT' && me.data && <span className="mf-price">{amd(me.data.plans.consultation)}</span>}
{p === 'TURNKEY' && <span className="mf-price">{t('landing.quiz.package_TURNKEY_price')}</span>}
</span>
<span className="bc-small">{t(`landing.quiz.package_${p}_text`)}</span>
</button>
))}
</Card>
{pkg === 'CUSTOM' && (
<Card className="bc-stack">
<h2 className="bc-h3">{t('client.move.customTitle')}</h2>
<div className="mf-row">
<span>{t('landing.quiz.airport')}</span>
{seg(airport, ['none', 'standard', 'premium'] as const, (v) => t(`landing.quiz.airport_${v}`), setAirport)}
</div>
<div className="mf-row">
<span>{t('landing.quiz.stay')}</span>
{seg(stay, ['none', 'flat', 'hotel'] as const, (v) => t(`landing.quiz.stay_${v}`), setStay)}
</div>
{stay !== 'none' && (
<div className="mf-budget">
<Field label={`${t('landing.quiz.budget')} · ${t('landing.quiz.budgetFrom')}`}>
<input className="bc-input" inputMode="numeric" value={budgetFrom} placeholder="20 000" onChange={(e) => setBudgetFrom(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
<Field label={t('landing.quiz.budgetTo')}>
<input className="bc-input" inputMode="numeric" value={budgetTo} placeholder="40 000" onChange={(e) => setBudgetTo(e.target.value.replace(/\D/g, '').slice(0, 7))} />
</Field>
</div>
)}
{YES_NO.map((k) => (
<div key={k} className="mf-row">
<span>{t(`landing.quiz.${k}`)}</span>
{seg(yes[k] ? 'yes' : 'no', ['no', 'yes'] as const, (v) => t(`landing.quiz.${v}`), (v) => setYes({ ...yes, [k]: v === 'yes' }))}
</div>
))}
</Card>
)}
<Card className="bc-stack">
<Field label={t('client.move.comment')}>
<textarea className="bc-input" style={{ minHeight: 80 }} maxLength={1000} value={comment} placeholder={t('client.move.commentHint')} onChange={(e) => setComment(e.target.value)} />
</Field>
{error && (
<p role="alert" style={{ margin: 0, color: 'var(--bc-danger)', fontWeight: 600 }}>
{error}
</p>
)}
<Button onClick={submit} disabled={create.isPending}>
{create.isPending ? t('landing.quiz.sending') : t('client.move.submit')}
</Button>
</Card>
</main>
);
}