refactor: simplify — мёртвые поля контента, общие хелперы api и тестов, липкая CTA без site в бандле
landing: - site.ts: убраны неиспользуемые roast, brew, priceDisplay, hero.eyebrow, footer.phone (Footer берёт CONTACT_PHONE) - form.ts: убраны FORM_TEXT.title/lead, CTAS, CtaPreset - StickyCta получает тексты пропсами от layout: клиентский чанк больше не тащит все тексты страницы - LeadForm/useLeadForm: без неиспользуемой подмены sendLead и className; leadPrefill без счётчика seq - Stamp без мёртвого tone; убраны data-interest, data-hot, класс lead-form (ничем не читались) - одно LEAD_FIELDS на sendLead и leadFormModel; регэксп typo вынесен из вызова; дубль фона body api: - changedFields и Version общие для leads и outbox; countLeads и leadStamps без копий SQL - errorMessage в system.ts вместо четырёх копий; take() для ожидающих подтверждений - backup: один Intl.DateTimeFormat на модуль, без невызываемых параметров timeZone/hour - без мёртвых LOOPBACK '::ffff:127.0.0.1', client.call, Pick<BackupCommands> со всеми ключами - testing.ts: validLead, addLead, count, leadIds вместо копий в тестах deploy.sh: deployed_field и цикл по REPOS вместо повторов имён
This commit is contained in:
@@ -80,7 +80,6 @@
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.55;
|
||||
background: var(--color-cream);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<body>
|
||||
<StampInkFilter />
|
||||
{children}
|
||||
<StickyCta />
|
||||
<StickyCta {...site.stickyCta} />
|
||||
<TouchRecorder />
|
||||
<RevealObserver />
|
||||
</body>
|
||||
|
||||
@@ -19,7 +19,6 @@ export function CtaButton({ interest, comment, source, className, children }: Ct
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-interest={interest}
|
||||
onClick={(e) => {
|
||||
const from = source ?? e.currentTarget.parentElement?.closest('[id]')?.id;
|
||||
prefillLead({ interest, comment, source: from || undefined });
|
||||
|
||||
@@ -6,15 +6,9 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { CONTACT_PHONE, FORM_TEXT } from '@/content/form';
|
||||
import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill';
|
||||
import { Stamp } from './Stamp';
|
||||
import type { SendLead, SubmitNotice } from '@/lib/sendLead';
|
||||
import type { SubmitNotice } from '@/lib/sendLead';
|
||||
import { useLeadForm } from '@/lib/useLeadForm';
|
||||
|
||||
export type LeadFormProps = {
|
||||
/** подмена отправки — для тестов и витрины состояний */
|
||||
sendLead?: SendLead;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ids = {
|
||||
name: LEAD_NAME_INPUT_ID,
|
||||
phone: 'lead-phone',
|
||||
@@ -94,8 +88,8 @@ function Notice({ notice }: { notice: SubmitNotice | null }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
const form = useLeadForm({ sendLead });
|
||||
export function LeadForm() {
|
||||
const form = useLeadForm();
|
||||
const { values, errors, status, notice } = form;
|
||||
const submitting = status === 'submitting';
|
||||
const sendAnotherRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -131,7 +125,7 @@ export function LeadForm({ sendLead, className }: LeadFormProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`lead-form relative flex flex-col gap-6 border-2 border-ink bg-paper p-5 lg:p-10 ${className ?? ''}`}
|
||||
className="relative flex flex-col gap-6 border-2 border-ink bg-paper p-5 lg:p-10"
|
||||
data-state={status}
|
||||
data-notice={notice ?? undefined}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,6 @@ export type StampProps = {
|
||||
width?: number | string;
|
||||
/** поворот, градусы */
|
||||
rotate?: number;
|
||||
tone?: 'cherry' | 'cherry-on-dark';
|
||||
/** 'load' — оттиск при загрузке, 'event' — когда появился в DOM (успех формы), иначе статичен */
|
||||
animate?: 'load' | 'event';
|
||||
/** бумажная подложка — когда штамп лежит на фото */
|
||||
@@ -25,11 +24,11 @@ const LINE_H = { sm: 26, lg: 46 } as const;
|
||||
const FONT = { sm: 15, lg: 34 } as const;
|
||||
const PAD = 16;
|
||||
|
||||
export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animate, backing = false, offsetX = 0, className }: StampProps) {
|
||||
export function Stamp({ lines, width = 168, rotate = -6, animate, backing = false, offsetX = 0, className }: StampProps) {
|
||||
const heights = lines.map((l) => LINE_H[l.size ?? 'sm']);
|
||||
const H = PAD * 2 + heights.reduce((a, b) => a + b, 0);
|
||||
const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
|
||||
const color = `var(--color-${tone})`;
|
||||
const color = 'var(--color-cherry)';
|
||||
// maxWidth: на 320 этикетка уже штампа — штамп сжимается, а не выталкивает страницу вбок
|
||||
const style = {
|
||||
'--stamp-rotate': `${rotate}deg`,
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// Липкая CTA на мобиле: видна, когда кнопки первого экрана ушли из кадра,
|
||||
// и прячется у формы, при фокусе в форме и при открытой клавиатуре.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { site } from '@/content/site';
|
||||
import type { Interest } from '@kvarta/shared/lead';
|
||||
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
|
||||
import { CtaButton } from './CtaButton';
|
||||
|
||||
export function StickyCta() {
|
||||
// тексты приходят пропсами от серверного layout: импорт site утянул бы в клиентский бандл все тексты страницы
|
||||
export function StickyCta({ label, note, interest }: { label: string; note: string; interest: Interest }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +51,6 @@ export function StickyCta() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { stickyCta } = site;
|
||||
return (
|
||||
<div
|
||||
className="sticky-cta fixed inset-x-0 bottom-0 z-40 flex items-center gap-4 border-t border-ink bg-cream px-5 pt-2 pb-[calc(8px+env(safe-area-inset-bottom))] md:hidden"
|
||||
@@ -58,9 +58,9 @@ export function StickyCta() {
|
||||
aria-hidden={visible ? undefined : true}
|
||||
inert={!visible}
|
||||
>
|
||||
<p className="min-w-0 flex-1 text-small font-medium text-muted">{stickyCta.note}</p>
|
||||
<CtaButton interest={stickyCta.interest} source="sticky" className="btn btn-primary flex-none">
|
||||
{stickyCta.label}
|
||||
<p className="min-w-0 flex-1 text-small font-medium text-muted">{note}</p>
|
||||
<CtaButton interest={interest} source="sticky" className="btn btn-primary flex-none">
|
||||
{label}
|
||||
</CtaButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Лента недели: дни «буква + число», ЧТ (обжарка) и ПТ (доставка) красным, скобка ПТ–ВС «1–3 день — в чашке».
|
||||
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (WeekStripObserver).
|
||||
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (RevealObserver).
|
||||
import { site } from '@/content/site';
|
||||
import type { RoastWeek } from '@/lib/roastWeek';
|
||||
import { typo } from '@/lib/typo';
|
||||
@@ -15,7 +15,7 @@ export function WeekStrip({ week }: { week: RoastWeek }) {
|
||||
const note = NOTES[i];
|
||||
const hot = note !== undefined;
|
||||
return (
|
||||
<li key={day.iso} className="week-day" data-hot={hot ? '' : undefined}>
|
||||
<li key={day.iso} className="week-day">
|
||||
<div className="week-head">
|
||||
<span className={`week-letter ${hot ? 'text-cherry' : 'text-muted'}`}>{day.letter}</span>
|
||||
<time dateTime={day.iso} className={`week-date mono-narrow ${hot ? 'text-cherry' : 'text-ink'}`}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CONTACT_PHONE } from '@/content/form';
|
||||
import { site } from '@/content/site';
|
||||
import { typo } from '@/lib/typo';
|
||||
import { Logo } from '../Logo';
|
||||
@@ -16,8 +17,8 @@ export function Footer() {
|
||||
<p className="mt-1 text-muted-on-dark">{footer.hours}</p>
|
||||
</div>
|
||||
<div className="text-small">
|
||||
<a href={footer.phone.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
|
||||
{footer.phone.display}
|
||||
<a href={CONTACT_PHONE.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
|
||||
{CONTACT_PHONE.display}
|
||||
</a>
|
||||
<p className="text-muted-on-dark">{typo(footer.phoneNote)}</p>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Subscription() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Две пачки: силуэты с клапаном, на второй — знак. */
|
||||
/** Две пачки: силуэты с клапаном и знаком. */
|
||||
function Packs() {
|
||||
const pack = (x: number) => (
|
||||
<g transform={`translate(${x} 0)`}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Все тексты формы заявки и CTA в одном месте — для вычитки.
|
||||
import type { Interest } from '@kvarta/shared/lead';
|
||||
// Все тексты формы заявки в одном месте — для вычитки.
|
||||
|
||||
export const CONTACT_PHONE = {
|
||||
display: '+375 29 000-00-00',
|
||||
@@ -7,8 +6,6 @@ export const CONTACT_PHONE = {
|
||||
} as const;
|
||||
|
||||
export const FORM_TEXT = {
|
||||
title: 'Оставьте заявку',
|
||||
lead: 'Перезвоним в рабочее время и всё уточним.',
|
||||
name: { label: 'Имя', placeholder: 'Как к вам обращаться' },
|
||||
phone: { label: 'Телефон', placeholder: '+375 29 123-45-67' },
|
||||
interest: { legend: 'Что вас интересует' },
|
||||
@@ -26,11 +23,3 @@ export const FORM_TEXT = {
|
||||
errorTag: 'Не отправлено',
|
||||
stamp: { created: 'Принято', clarified: 'Дописали' },
|
||||
} as const;
|
||||
|
||||
export type CtaPreset = { label: string; interest: Interest; comment?: string };
|
||||
|
||||
export const CTAS = {
|
||||
subscription: { label: 'Оформить подписку', interest: 'subscription' },
|
||||
ethiopia: { label: 'Заказать пачку Эфиопии', interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' },
|
||||
question: { label: 'Задать вопрос', interest: 'question' },
|
||||
} as const satisfies Record<string, CtaPreset>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Все тексты и факты лендинга по блокам — для вычитки. Факты только из «Фактов из ТЗ» (docs/plan.md).
|
||||
// Тексты формы — в ./form.ts.
|
||||
import type { Interest } from '@kvarta/shared/lead';
|
||||
import { CONTACT_PHONE } from './form';
|
||||
|
||||
type Cta = { label: string; interest: Interest; comment?: string };
|
||||
|
||||
@@ -31,7 +30,6 @@ export const site = {
|
||||
|
||||
hero: {
|
||||
stamp: 'ЧТ · обжарка',
|
||||
eyebrow: 'Обжарка кофе в Минске',
|
||||
title: 'Кофе, обжаренный в четверг',
|
||||
subtitle:
|
||||
'В пятницу привозим по Минску, и пачка попадает к вам на 1–3 день после обжарки.',
|
||||
@@ -61,49 +59,37 @@ export const site = {
|
||||
id: 'ethiopia',
|
||||
name: 'Эфиопия Иргачеффе',
|
||||
notes: ['цветочный', 'бергамот'],
|
||||
roast: 'светлая обжарка',
|
||||
brew: null,
|
||||
// поле этикетки (итерация критика 1, нужна вычитка автора)
|
||||
spec: { label: 'Обжарка', value: 'светлая' },
|
||||
size: '250 г',
|
||||
price: 32,
|
||||
priceDisplay: '32 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Эфиопия Иргачеффе, 250 г' } satisfies Cta,
|
||||
},
|
||||
{
|
||||
id: 'colombia',
|
||||
name: 'Колумбия Уила',
|
||||
notes: ['карамель', 'красное яблоко'],
|
||||
roast: null,
|
||||
brew: null,
|
||||
spec: null,
|
||||
size: '250 г',
|
||||
price: 29,
|
||||
priceDisplay: '29 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Колумбия Уила, 250 г' } satisfies Cta,
|
||||
},
|
||||
{
|
||||
id: 'brazil',
|
||||
name: 'Бразилия Серрадо',
|
||||
notes: ['орех', 'шоколад'],
|
||||
roast: null,
|
||||
brew: 'под эспрессо',
|
||||
spec: { label: 'Для', value: 'эспрессо' },
|
||||
size: '250 г',
|
||||
price: 26,
|
||||
priceDisplay: '26 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Бразилия Серрадо, 250 г' } satisfies Cta,
|
||||
},
|
||||
{
|
||||
id: 'drip',
|
||||
name: 'Дрип-пакеты ассорти',
|
||||
notes: [],
|
||||
roast: null,
|
||||
brew: 'заваривается прямо в чашке',
|
||||
spec: { label: 'Как', value: 'заваривается прямо в чашке' },
|
||||
size: '10 шт',
|
||||
price: 18,
|
||||
priceDisplay: '18 BYN',
|
||||
cta: { label: 'Заказать', interest: 'one_time', comment: 'Дрип-пакеты ассорти, 10 шт' } satisfies Cta,
|
||||
},
|
||||
],
|
||||
@@ -113,7 +99,6 @@ export const site = {
|
||||
title: 'Две пачки каждый месяц',
|
||||
text: 'Раз в месяц привозим две пачки по 250 г. Доставка бесплатно.',
|
||||
price: 54,
|
||||
priceDisplay: '54 BYN',
|
||||
priceUnit: 'в месяц',
|
||||
note: 'Подробности менеджер расскажет по телефону.',
|
||||
fields: [
|
||||
@@ -176,7 +161,6 @@ export const site = {
|
||||
tagline: 'Обжарка кофе в Минске с 2019 года',
|
||||
address: 'Минск, ул. Короля, 9',
|
||||
hours: 'Пн–пт 8:00–20:00, сб–вс 10:00–18:00',
|
||||
phone: CONTACT_PHONE,
|
||||
phoneNote: 'Номер демонстрационный.',
|
||||
photoCredit: 'Фото: Mona Jain, Tina Guina, Scott Soltys-Curry / Unsplash',
|
||||
disclaimer: 'Учебный проект. Обжарка «Кварта» вымышленная.',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Чистая модель формы заявки: значения, видимые ошибки, тело запроса.
|
||||
import { validateLeadFields, type FieldErrors, type Interest, type LeadField, type LeadRequest, type Touch } from '@kvarta/shared/lead';
|
||||
import { LEAD_FIELDS } from './sendLead';
|
||||
|
||||
export type LeadFormValues = {
|
||||
name: string;
|
||||
@@ -26,7 +27,7 @@ export function visibleErrors(
|
||||
): FieldErrors {
|
||||
const client = validateLeadFields(values);
|
||||
const out: FieldErrors = {};
|
||||
for (const field of ['name', 'phone', 'interest', 'comment'] as const) {
|
||||
for (const field of LEAD_FIELDS) {
|
||||
const message = submitAttempted || touched[field] ? client[field] : undefined;
|
||||
const shown = message ?? serverErrors[field];
|
||||
if (shown) out[field] = shown;
|
||||
|
||||
@@ -13,14 +13,12 @@ export type LeadPrefillInput = {
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type LeadPrefill = LeadPrefillInput & { seq: number };
|
||||
type Listener = (prefill: LeadPrefillInput) => void;
|
||||
|
||||
type Listener = (prefill: LeadPrefill) => void;
|
||||
|
||||
let current: LeadPrefill | null = null;
|
||||
let current: LeadPrefillInput | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function getLeadPrefill(): LeadPrefill | null {
|
||||
export function getLeadPrefill(): LeadPrefillInput | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -33,8 +31,8 @@ export function subscribeLeadPrefill(listener: Listener): () => void {
|
||||
|
||||
/** Выставить значения формы, плавно доскроллить до #lead и поставить фокус в поле имени. */
|
||||
export function prefillLead(input: LeadPrefillInput): void {
|
||||
current = { ...input, seq: (current?.seq ?? 0) + 1 };
|
||||
for (const listener of listeners) listener(current);
|
||||
current = input;
|
||||
for (const listener of listeners) listener(input);
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
|
||||
@@ -15,7 +15,7 @@ export type SubmitResult = {
|
||||
|
||||
export type SendLead = (body: LeadRequest) => Promise<SubmitResult>;
|
||||
|
||||
const FIELDS: readonly LeadField[] = ['name', 'phone', 'interest', 'comment'];
|
||||
export const LEAD_FIELDS = ['name', 'phone', 'interest', 'comment'] as const satisfies readonly LeadField[];
|
||||
|
||||
const success = (notice: 'created' | 'clarified'): SubmitResult => ({ status: 'success', notice, fieldErrors: {} });
|
||||
const error = (notice: SubmitNotice, fieldErrors: FieldErrors = {}): SubmitResult => ({
|
||||
@@ -31,7 +31,7 @@ function pickFieldErrors(value: unknown): FieldErrors {
|
||||
if (!value || typeof value !== 'object') return {};
|
||||
const src = value as Record<string, unknown>;
|
||||
const out: FieldErrors = {};
|
||||
for (const field of FIELDS) {
|
||||
for (const field of LEAD_FIELDS) {
|
||||
const message = src[field];
|
||||
if (typeof message === 'string' && message !== '') out[field] = message;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const NBSP = ' ';
|
||||
const SHORT = 'а|в|во|и|к|ко|на|не|но|о|об|от|до|по|с|со|у|за|из|для|как|или';
|
||||
const SHORT_RE = new RegExp(`(^|[\\s(«—–-])(${SHORT}) `, 'giu');
|
||||
const SHORT_AFTER_NBSP_RE = new RegExp(`(${NBSP})(${SHORT}) `, 'giu');
|
||||
const UNIT_RE = /(\d) (кг|г|шт|BYN|мин|дн\.?|день|дня)(?=$|[\s.,:;!?)»])/gu;
|
||||
const NAME_HYPHEN_RE = /([A-Z][a-z]+)-([A-Z][a-z]+)/g;
|
||||
// имя и фамилия латиницей не разрываются: «Scott Soltys-Curry»
|
||||
@@ -11,7 +12,7 @@ const NAME_PAIR_RE = /\b([A-Z][a-z]+) (?=[A-Z][a-z]+)/g;
|
||||
export function typo(text: string): string {
|
||||
// дважды: «и в Минске» — после замены первого слова второе стоит уже после NBSP
|
||||
let out = text.replace(SHORT_RE, `$1$2${NBSP}`);
|
||||
out = out.replace(new RegExp(`(${NBSP})(${SHORT}) `, 'giu'), `$1$2${NBSP}`);
|
||||
out = out.replace(SHORT_AFTER_NBSP_RE, `$1$2${NBSP}`);
|
||||
return out
|
||||
.replace(UNIT_RE, `$1${NBSP}$2`)
|
||||
.replace(NAME_HYPHEN_RE, '$1\u2011$2')
|
||||
|
||||
@@ -5,14 +5,14 @@ import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/sh
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { getBrowserTouches } from './attribution';
|
||||
import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel';
|
||||
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill';
|
||||
import { FAILED, sendLead as defaultSendLead, type SendLead, type SubmitNotice } from './sendLead';
|
||||
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill } from './leadPrefill';
|
||||
import { FAILED, sendLead, type SubmitNotice } from './sendLead';
|
||||
|
||||
export type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
|
||||
type LeadFormStatus = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
type FormState = { values: LeadFormValues; prefilledComment: string | null };
|
||||
|
||||
export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLead } = {}) {
|
||||
export function useLeadForm() {
|
||||
// prefilledComment живёт рядом со значениями: updater должен быть чистым (StrictMode вызывает его дважды)
|
||||
const [form, setForm] = useState<FormState>(() => {
|
||||
const prefill = getLeadPrefill();
|
||||
@@ -32,7 +32,7 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeLeadPrefill((prefill: LeadPrefillInput) => {
|
||||
subscribeLeadPrefill((prefill) => {
|
||||
source.current = prefill.source;
|
||||
setForm((prev) => applyPrefill(prev.values, prefill, prev.prefilledComment));
|
||||
// CTA после подтверждения — посетитель хочет новую заявку: возвращаем форму
|
||||
@@ -105,5 +105,3 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
|
||||
|
||||
return { values, errors, status, notice, setField, blurField, submit, reset };
|
||||
}
|
||||
|
||||
export type LeadFormController = ReturnType<typeof useLeadForm>;
|
||||
|
||||
Reference in New Issue
Block a user