Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -0,0 +1,151 @@
|
||||
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">
|
||||
<section className="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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user