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:
parent
330032b13a
commit
5f2188a7f9
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue