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>
152 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|