feat(landing): первое и последнее касание
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { TouchRecorder } from '@/components/TouchRecorder';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -9,7 +10,10 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
{children}
|
||||||
|
<TouchRecorder />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
// Фиксирует касание при загрузке страницы. Ничего не рисует.
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { recordBrowserVisit } from '@/lib/attribution';
|
||||||
|
|
||||||
|
export function TouchRecorder() {
|
||||||
|
useEffect(() => {
|
||||||
|
recordBrowserVisit();
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
FIRST_TOUCH_KEY,
|
||||||
|
LAST_TOUCH_KEY,
|
||||||
|
recordVisit,
|
||||||
|
readTouch,
|
||||||
|
touchFromVisit,
|
||||||
|
touchesForSubmit,
|
||||||
|
withMemoryFallback,
|
||||||
|
type StorageLike,
|
||||||
|
type TouchStores,
|
||||||
|
} from './attribution';
|
||||||
|
|
||||||
|
const SITE = 'https://kvarta.unuchak.com';
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
const t0 = new Date('2026-10-01T10:00:00.000Z');
|
||||||
|
const later = (days: number) => new Date(t0.getTime() + days * DAY);
|
||||||
|
|
||||||
|
class FakeStorage implements StorageLike {
|
||||||
|
data = new Map<string, string>();
|
||||||
|
getItem(key: string) {
|
||||||
|
return this.data.get(key) ?? null;
|
||||||
|
}
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
this.data.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const throwing: StorageLike = {
|
||||||
|
getItem() {
|
||||||
|
throw new DOMException('denied', 'SecurityError');
|
||||||
|
},
|
||||||
|
setItem() {
|
||||||
|
throw new DOMException('quota', 'QuotaExceededError');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function stores(): TouchStores & { session: FakeStorage; local: FakeStorage } {
|
||||||
|
return { session: new FakeStorage(), local: new FakeStorage() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ad = `${SITE}/?utm_source=yandex&utm_medium=cpc&utm_campaign=osen&yclid=123`;
|
||||||
|
|
||||||
|
describe('touchFromVisit', () => {
|
||||||
|
it('собирает метки, страницу входа и время', () => {
|
||||||
|
const touch = touchFromVisit({
|
||||||
|
url: `${SITE}/beans?utm_source=instagram&utm_medium=social&utm_campaign=autumn&utm_content=reel&utm_term=coffee&fbclid=FB1&foo=bar#lead`,
|
||||||
|
referrer: '',
|
||||||
|
now: t0,
|
||||||
|
});
|
||||||
|
expect(touch).toEqual({
|
||||||
|
utm_source: 'instagram',
|
||||||
|
utm_medium: 'social',
|
||||||
|
utm_campaign: 'autumn',
|
||||||
|
utm_content: 'reel',
|
||||||
|
utm_term: 'coffee',
|
||||||
|
fbclid: 'FB1',
|
||||||
|
landingPage: '/beans?utm_source=instagram&utm_medium=social&utm_campaign=autumn&utm_content=reel&utm_term=coffee&fbclid=FB1&foo=bar',
|
||||||
|
at: '2026-10-01T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустые метки не пишет, длинные обрезает до 500', () => {
|
||||||
|
const touch = touchFromVisit({ url: `${SITE}/?utm_source=&utm_campaign=${'x'.repeat(600)}`, referrer: '', now: t0 });
|
||||||
|
expect(touch.utm_source).toBeUndefined();
|
||||||
|
expect(touch.utm_campaign).toHaveLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('внешний referrer сохраняет', () => {
|
||||||
|
const touch = touchFromVisit({ url: `${SITE}/`, referrer: 'https://www.instagram.com/', now: t0 });
|
||||||
|
expect(touch.referrer).toBe('https://www.instagram.com/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('свой домен referrer\'ом не считает', () => {
|
||||||
|
const touch = touchFromVisit({ url: `${SITE}/`, referrer: `${SITE}/beans`, now: t0 });
|
||||||
|
expect(touch.referrer).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('битый referrer отбрасывает', () => {
|
||||||
|
expect(touchFromVisit({ url: `${SITE}/`, referrer: 'not a url', now: t0 }).referrer).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('последнее касание', () => {
|
||||||
|
it('пишется при первом заходе в сессию', () => {
|
||||||
|
const s = stores();
|
||||||
|
const { lastTouch } = recordVisit({ url: ad, referrer: 'https://yandex.by/', now: t0 }, s);
|
||||||
|
expect(lastTouch.utm_source).toBe('yandex');
|
||||||
|
expect(readTouch(s.session, LAST_TOUCH_KEY)).toEqual(lastTouch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переход без меток в той же сессии его не меняет', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
const { lastTouch } = recordVisit({ url: `${SITE}/`, referrer: `${SITE}/`, now: later(0.01) }, s);
|
||||||
|
expect(lastTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('новая ссылка с другими метками в той же сессии — обновляет', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
const { lastTouch } = recordVisit({ url: `${SITE}/?utm_source=instagram`, referrer: '', now: later(0.01) }, s);
|
||||||
|
expect(lastTouch.utm_source).toBe('instagram');
|
||||||
|
expect(lastTouch.utm_campaign).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('перезагрузка с теми же метками не сдвигает время', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
const { lastTouch } = recordVisit({ url: ad, referrer: '', now: later(0.01) }, s);
|
||||||
|
expect(lastTouch.at).toBe(t0.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при отправке получает submitPage, первое касание — нет', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
const touches = touchesForSubmit({ url: `${SITE}/#lead`, referrer: '', now: later(0.01) }, s, '/#hero');
|
||||||
|
expect(touches.lastTouch).toMatchObject({ utm_source: 'yandex', submitPage: '/#hero' });
|
||||||
|
expect(touches.firstTouch?.submitPage).toBeUndefined();
|
||||||
|
expect(readTouch(s.session, LAST_TOUCH_KEY)?.submitPage).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('первое касание', () => {
|
||||||
|
it('прямой визит без меток тоже фиксируется', () => {
|
||||||
|
const s = stores();
|
||||||
|
const { firstTouch } = recordVisit({ url: `${SITE}/`, referrer: '', now: t0 }, s);
|
||||||
|
expect(firstTouch).toEqual({ landingPage: '/', at: t0.toISOString() });
|
||||||
|
expect(readTouch(s.local, FIRST_TOUCH_KEY)).toEqual(firstTouch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('прямой визит не перезаписывается рекламным', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: `${SITE}/`, referrer: '', now: t0 }, s);
|
||||||
|
const { firstTouch, lastTouch } = recordVisit({ url: ad, referrer: '', now: later(3) }, s);
|
||||||
|
expect(firstTouch).toEqual({ landingPage: '/', at: t0.toISOString() });
|
||||||
|
expect(lastTouch.utm_source).toBe('yandex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('на 29-й день ещё живо', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
s.session.data.clear(); // новая сессия
|
||||||
|
const { firstTouch } = recordVisit({ url: `${SITE}/?utm_source=instagram`, referrer: '', now: later(29) }, s);
|
||||||
|
expect(firstTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('на 31-й день заменяется текущим визитом', () => {
|
||||||
|
const s = stores();
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
s.session.data.clear();
|
||||||
|
const { firstTouch } = recordVisit({ url: `${SITE}/?utm_source=instagram`, referrer: '', now: later(31) }, s);
|
||||||
|
expect(firstTouch).toMatchObject({ utm_source: 'instagram', at: later(31).toISOString() });
|
||||||
|
expect(readTouch(s.local, FIRST_TOUCH_KEY)?.utm_source).toBe('instagram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('битая запись считается отсутствующей', () => {
|
||||||
|
const s = stores();
|
||||||
|
s.local.setItem(FIRST_TOUCH_KEY, '{oops');
|
||||||
|
const { firstTouch } = recordVisit({ url: ad, referrer: '', now: t0 }, s);
|
||||||
|
expect(firstTouch.utm_source).toBe('yandex');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('хранилище бросает исключение', () => {
|
||||||
|
it('касания всё равно собираются из текущего визита', () => {
|
||||||
|
const touches = touchesForSubmit(
|
||||||
|
{ url: ad, referrer: 'https://yandex.by/', now: t0 },
|
||||||
|
{ session: throwing, local: throwing },
|
||||||
|
'/',
|
||||||
|
);
|
||||||
|
expect(touches.lastTouch).toMatchObject({ utm_source: 'yandex', referrer: 'https://yandex.by/', submitPage: '/' });
|
||||||
|
expect(touches.firstTouch?.utm_source).toBe('yandex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('хранилищ нет совсем', () => {
|
||||||
|
expect(() => recordVisit({ url: ad, referrer: '', now: t0 }, { session: null, local: null })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('запасная память держит касание, пока страница открыта', () => {
|
||||||
|
const session = withMemoryFallback(() => throwing);
|
||||||
|
const local = withMemoryFallback(() => {
|
||||||
|
throw new DOMException('denied', 'SecurityError');
|
||||||
|
});
|
||||||
|
recordVisit({ url: ad, referrer: '', now: t0 }, { session, local });
|
||||||
|
const touches = touchesForSubmit({ url: `${SITE}/`, referrer: '', now: later(0.01) }, { session, local }, '/');
|
||||||
|
expect(touches.lastTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
|
||||||
|
expect(touches.firstTouch).toMatchObject({ utm_source: 'yandex', at: t0.toISOString() });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Первое и последнее касание (см. GLOSSARY.md, docs/plan.md «Касания»).
|
||||||
|
// Чистые функции принимают URL, referrer, время и хранилища параметрами — тестируются без DOM.
|
||||||
|
// Внизу — тонкая обёртка для браузера.
|
||||||
|
import { LIMITS, type Touch } from '@kvarta/shared/lead';
|
||||||
|
|
||||||
|
export const FIRST_TOUCH_KEY = 'kvarta:first-touch';
|
||||||
|
export const LAST_TOUCH_KEY = 'kvarta:last-touch';
|
||||||
|
export const FIRST_TOUCH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const MARK_KEYS = [
|
||||||
|
'utm_source',
|
||||||
|
'utm_medium',
|
||||||
|
'utm_campaign',
|
||||||
|
'utm_content',
|
||||||
|
'utm_term',
|
||||||
|
'yclid',
|
||||||
|
'fbclid',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
||||||
|
export type TouchStores = { session: StorageLike | null; local: StorageLike | null };
|
||||||
|
export type Visit = { url: string; referrer: string; now: Date };
|
||||||
|
export type Touches = { firstTouch: Touch | null; lastTouch: Touch };
|
||||||
|
|
||||||
|
const clip = (s: string) => s.slice(0, LIMITS.touchValue);
|
||||||
|
|
||||||
|
/** Касание текущего визита: метки из URL, внешний referrer, страница входа. */
|
||||||
|
export function touchFromVisit({ url, referrer, now }: Visit): Touch {
|
||||||
|
const page = new URL(url);
|
||||||
|
const touch: Touch = { landingPage: clip(page.pathname + page.search), at: now.toISOString() };
|
||||||
|
for (const key of MARK_KEYS) {
|
||||||
|
const value = page.searchParams.get(key)?.trim();
|
||||||
|
if (value) touch[key] = clip(value);
|
||||||
|
}
|
||||||
|
const ref = externalReferrer(referrer, page.host);
|
||||||
|
if (ref) touch.referrer = clip(ref);
|
||||||
|
return touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Свой домен referrer'ом не считаем: это переход внутри лендинга, а не источник. */
|
||||||
|
function externalReferrer(referrer: string, ownHost: string): string | null {
|
||||||
|
if (!referrer) return null;
|
||||||
|
try {
|
||||||
|
return new URL(referrer).host === ownHost ? null : referrer;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMarks(touch: Touch): boolean {
|
||||||
|
return MARK_KEYS.some((key) => touch[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameMarks = (a: Touch, b: Touch) => MARK_KEYS.every((key) => a[key] === b[key]);
|
||||||
|
|
||||||
|
/** Последнее касание сессии: первое за сессию, либо новый заход по ссылке с другими метками. */
|
||||||
|
export function nextLastTouch(stored: Touch | null, visit: Touch): Touch {
|
||||||
|
if (!stored) return visit;
|
||||||
|
return hasMarks(visit) && !sameMarks(stored, visit) ? visit : stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Первое касание: живёт 30 дней и до истечения не перезаписывается — даже рекламным визитом. */
|
||||||
|
export function nextFirstTouch(stored: Touch | null, visit: Touch, now: Date): Touch {
|
||||||
|
if (!stored) return visit;
|
||||||
|
const age = now.getTime() - Date.parse(stored.at);
|
||||||
|
return Number.isFinite(age) && age < FIRST_TOUCH_TTL_MS ? stored : visit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTouch(storage: StorageLike | null, key: string): Touch | null {
|
||||||
|
try {
|
||||||
|
const raw = storage?.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
const touch = parsed as Partial<Touch>;
|
||||||
|
return typeof touch.landingPage === 'string' && typeof touch.at === 'string' ? (touch as Touch) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTouch(storage: StorageLike | null, key: string, touch: Touch): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(key, JSON.stringify(touch));
|
||||||
|
} catch {
|
||||||
|
// хранилище недоступно или переполнено — касание просто не запомнится
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Зафиксировать визит в хранилищах; вернуть актуальные первое и последнее касания. */
|
||||||
|
export function recordVisit(visit: Visit, stores: TouchStores): { firstTouch: Touch; lastTouch: Touch } {
|
||||||
|
const current = touchFromVisit(visit);
|
||||||
|
|
||||||
|
const lastTouch = nextLastTouch(readTouch(stores.session, LAST_TOUCH_KEY), current);
|
||||||
|
if (lastTouch === current) writeTouch(stores.session, LAST_TOUCH_KEY, lastTouch);
|
||||||
|
|
||||||
|
const firstTouch = nextFirstTouch(readTouch(stores.local, FIRST_TOUCH_KEY), current, visit.now);
|
||||||
|
if (firstTouch === current) writeTouch(stores.local, FIRST_TOUCH_KEY, firstTouch);
|
||||||
|
|
||||||
|
return { firstTouch, lastTouch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Касания для тела заявки: последнее — со страницей отправки. */
|
||||||
|
export function touchesForSubmit(visit: Visit, stores: TouchStores, submitPage: string): Touches {
|
||||||
|
const { firstTouch, lastTouch } = recordVisit(visit, stores);
|
||||||
|
return { firstTouch, lastTouch: { ...lastTouch, submitPage: clip(submitPage) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище с запасной памятью: если настоящее бросает (приватный режим, запрет cookies, квота),
|
||||||
|
* касания живут хотя бы до перезагрузки страницы.
|
||||||
|
*/
|
||||||
|
export function withMemoryFallback(getStorage: () => StorageLike | null): StorageLike {
|
||||||
|
const memory = new Map<string, string>();
|
||||||
|
const storage = () => {
|
||||||
|
try {
|
||||||
|
return getStorage();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
try {
|
||||||
|
const value = storage()?.getItem(key);
|
||||||
|
if (value != null) return value;
|
||||||
|
} catch {
|
||||||
|
// падаем на память
|
||||||
|
}
|
||||||
|
return memory.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
memory.set(key, value);
|
||||||
|
try {
|
||||||
|
storage()?.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// память уже записана
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— браузер ———
|
||||||
|
|
||||||
|
let browserStores: TouchStores | null = null;
|
||||||
|
|
||||||
|
function getBrowserStores(): TouchStores {
|
||||||
|
browserStores ??= {
|
||||||
|
session: withMemoryFallback(() => window.sessionStorage),
|
||||||
|
local: withMemoryFallback(() => window.localStorage),
|
||||||
|
};
|
||||||
|
return browserStores;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentVisit(): Visit {
|
||||||
|
return { url: window.location.href, referrer: document.referrer, now: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вызвать при загрузке страницы. */
|
||||||
|
export function recordBrowserVisit(): void {
|
||||||
|
recordVisit(currentVisit(), getBrowserStores());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Касания для отправки; section — id секции, откуда пришла CTA, если известно. */
|
||||||
|
export function getBrowserTouches(section?: string): Touches {
|
||||||
|
const submitPage = window.location.pathname + (section ? `#${section}` : '');
|
||||||
|
return touchesForSubmit(currentVisit(), getBrowserStores(), submitPage);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { formatBelarusPhone } from '@kvarta/shared/phone';
|
import { formatBelarusPhone } from '@kvarta/shared/phone';
|
||||||
import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead';
|
import { validateLeadFields, type FieldErrors, type LeadField } from '@kvarta/shared/lead';
|
||||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import { getBrowserTouches } from './attribution';
|
||||||
import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel';
|
import { EMPTY_VALUES, buildLeadRequest, visibleErrors, type LeadFormValues, type Touched } from './leadFormModel';
|
||||||
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill';
|
import { applyPrefill, getLeadPrefill, subscribeLeadPrefill, type LeadPrefillInput } from './leadPrefill';
|
||||||
import { sendLead as defaultSendLead, type SendLead, type SubmitOutcome } from './sendLead';
|
import { sendLead as defaultSendLead, type SendLead, type SubmitOutcome } from './sendLead';
|
||||||
@@ -61,14 +62,7 @@ export function useLeadForm({ sendLead = defaultSendLead }: { sendLead?: SendLea
|
|||||||
if (Object.keys(validateLeadFields(values)).length > 0) return;
|
if (Object.keys(validateLeadFields(values)).length > 0) return;
|
||||||
|
|
||||||
setStatus('submitting');
|
setStatus('submitting');
|
||||||
const body = buildLeadRequest(values, {
|
const body = buildLeadRequest(values, getBrowserTouches(source.current));
|
||||||
firstTouch: null,
|
|
||||||
lastTouch: {
|
|
||||||
landingPage: window.location.pathname + window.location.search,
|
|
||||||
submitPage: window.location.pathname + (source.current ? `#${source.current}` : ''),
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const outcome: SubmitOutcome = await sendLead(body);
|
const outcome: SubmitOutcome = await sendLead(body);
|
||||||
if (outcome.kind === 'invalid') {
|
if (outcome.kind === 'invalid') {
|
||||||
setServerErrors(outcome.fieldErrors);
|
setServerErrors(outcome.fieldErrors);
|
||||||
|
|||||||
Reference in New Issue
Block a user