fix(signup): устранён React #418 (hydration) + дефолтная конфигурация «Бизнес»

- ?plan= и параметры конфигуратора читаются в useEffect ПОСЛЕ гидрации, поэтому
  первый клиентский рендер совпадает с SSR-разметкой — ошибка React #418
  (hydration mismatch) на /signup?plan=business&shops=… больше не возникает.
- plan=business без параметров → выбирается «Бизнес» с дефолтной конфигурацией
  1/1/1/5 = 10 000 ₸; plan=network → «Сеть»; неизвестный план → «Старт».
- Клик по плитке тарифа обновляет URL (history.replaceState), ссылку можно
  сохранить/поделиться.
- Плитка «Старт» теперь «5 000 ₸/мес»; цены плиток без переноса (whitespace-nowrap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-11 01:21:58 +05:00
parent 330032b13a
commit 5f2188a7f9

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { validateEmail, validatePassword, validatePhone } from '@/lib/validation' import { validateEmail, validatePassword, validatePhone } from '@/lib/validation'
import { PhoneInput } from '@/components/PhoneInput' import { PhoneInput } from '@/components/PhoneInput'
import { PRICING, businessPrice, clamp, fmtKzt, type BusinessConfig } from '@/lib/pricing' import { PRICING, businessPrice, clamp, fmtKzt, type BusinessConfig } from '@/lib/pricing'
@ -42,16 +42,44 @@ interface Props {
type FieldErrors = Partial<Record<'email' | 'password' | 'orgName' | 'phone' | 'agree', string>> type FieldErrors = Partial<Record<'email' | 'password' | 'orgName' | 'phone' | 'agree', string>>
const DEFAULT_CONFIG: BusinessConfig = {
shops: PRICING.shops.min, cashes: PRICING.cashes.min,
warehouses: PRICING.warehouses.min, employees: PRICING.employees.min,
}
export default function SignupForm({ defaultPlan = 'start' }: Props) { export default function SignupForm({ defaultPlan = 'start' }: Props) {
// Разбор ?plan= и конфигурации из URL — один раз при инициализации (клиент). // Первый клиентский рендер ДОЛЖЕН совпасть с SSR-разметкой, иначе React кидает
const [sel] = useState(() => readUrlSelection(defaultPlan)) // #418 (hydration mismatch). Поэтому стартуем из props/дефолтов, а ?plan= и
const [config] = useState<BusinessConfig>(sel.config) // конфигуратор читаем из URL уже в useEffect — после гидрации.
const hasConfig = sel.hasConfig const initialPlan: PlanId = (VALID_PLANS as readonly string[]).includes(defaultPlan) ? (defaultPlan as PlanId) : 'start'
const [plan, setPlan] = useState<PlanId>(initialPlan)
const [config, setConfig] = useState<BusinessConfig>(DEFAULT_CONFIG)
useEffect(() => {
const sel = readUrlSelection(defaultPlan)
setPlan(sel.plan)
// Если план «Бизнес» без параметров конфигуратора — показываем дефолтную
// конфигурацию (1/1/1/5 = 10 000 ₸), а не пустой тариф.
setConfig(sel.hasConfig ? sel.config : DEFAULT_CONFIG)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Клик по плитке тарифа — обновляем URL (?plan=…) без перезагрузки, чтобы
// ссылку можно было сохранить/поделиться.
const selectPlan = (id: PlanId) => {
setPlan(id)
if (typeof window !== 'undefined') {
const q = new URLSearchParams(window.location.search)
q.set('plan', id)
const qs = q.toString()
history.replaceState(null, '', `${window.location.pathname}${qs ? '?' + qs : ''}`)
}
}
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [orgName, setOrgName] = useState('') const [orgName, setOrgName] = useState('')
const [phone, setPhone] = useState('') const [phone, setPhone] = useState('')
const [plan, setPlan] = useState<string>(sel.plan)
const businessTotal = businessPrice(config) const businessTotal = businessPrice(config)
const [agree, setAgree] = useState(false) const [agree, setAgree] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -83,7 +111,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
// принимать ли — отдельно, см. отчёт site-fixes). // принимать ли — отдельно, см. отчёт site-fixes).
body: JSON.stringify({ body: JSON.stringify({
email, password, organizationName: orgName, phone: phone || null, plan, email, password, organizationName: orgName, phone: phone || null, plan,
...(plan === 'business' && hasConfig ...(plan === 'business'
? { shops: config.shops, cashes: config.cashes, warehouses: config.warehouses, employees: config.employees } ? { shops: config.shops, cashes: config.cashes, warehouses: config.warehouses, employees: config.employees }
: {}), : {}),
}), }),
@ -195,18 +223,18 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
<div className="text-sm font-medium mb-2">Тариф</div> <div className="text-sm font-medium mb-2">Тариф</div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{[ {[
{ id: 'start', name: 'Старт', price: fmtKzt(PRICING.startPrice) }, { id: 'start', name: 'Старт', price: `${fmtKzt(PRICING.startPrice)}/мес` },
{ id: 'business', name: 'Бизнес', price: hasConfig ? `${fmtKzt(businessTotal)}/мес` : 'от 10 000 ₸' }, { id: 'business', name: 'Бизнес', price: `${fmtKzt(businessTotal)}/мес` },
{ id: 'network', name: 'Сеть', price: 'по запросу' }, { id: 'network', name: 'Сеть', price: 'по запросу' },
].map((p) => ( ].map((p) => (
<label key={p.id} className={`p-2.5 rounded-md border cursor-pointer text-center text-sm ${plan === p.id ? 'border-brand bg-brand/5' : 'border-slate-200'}`}> <label key={p.id} className={`p-2.5 rounded-md border cursor-pointer text-center text-sm ${plan === p.id ? 'border-brand bg-brand/5' : 'border-slate-200'}`}>
<input type="radio" name="plan" className="hidden" checked={plan === p.id} onChange={() => setPlan(p.id)} /> <input type="radio" name="plan" className="hidden" checked={plan === p.id} onChange={() => selectPlan(p.id as PlanId)} />
<div className="font-semibold">{p.name}</div> <div className="font-semibold">{p.name}</div>
<div className="text-xs text-slate-500">{p.price}</div> <div className="text-xs text-slate-500 whitespace-nowrap">{p.price}</div>
</label> </label>
))} ))}
</div> </div>
{plan === 'business' && hasConfig && ( {plan === 'business' && (
<div className="mt-3 rounded-md border border-brand/30 bg-brand/5 p-3 text-sm"> <div className="mt-3 rounded-md border border-brand/30 bg-brand/5 p-3 text-sm">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<span className="font-medium">Ваша конфигурация «Бизнес»</span> <span className="font-medium">Ваша конфигурация «Бизнес»</span>