feat(landing): штамп, лента недели, состояния кнопки, липкая CTA

Оттиск штампа при загрузке и при успехе формы, заполнение ленты
недели по IntersectionObserver, мигающие точки при отправке.
Только CSS и transform/opacity; анимации включает скрипт в head,
поэтому без JS и при prefers-reduced-motion всё сразу в финале.
Липкая CTA на мобиле: «Заказать кофе», прячется у формы, при фокусе
в ней и при открытой клавиатуре. Ритм секций плотнее.
This commit is contained in:
2026-10-17 16:56:52 +03:00
parent dc70ebccd8
commit d8d67c036f
5 changed files with 209 additions and 4 deletions
+1 -1
View File
@@ -317,7 +317,7 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
- **Mobile-first.** Брейкпоинты Tailwind по умолчанию: `md` 768 (две колонки), `lg` 1024 (12-колоночная сетка), `xl` 1280. - **Mobile-first.** Брейкпоинты Tailwind по умолчанию: `md` 768 (две колонки), `lg` 1024 (12-колоночная сетка), `xl` 1280.
- **Сетка:** 390 — 4 колонки, гаттер 16px, поля 20px. ≥1024 — 12 колонок, гаттер 24px, поля 48px, контейнер `max-w-page` 1280px по центру (на 1440 поля ≈ 80px). - **Сетка:** 390 — 4 колонки, гаттер 16px, поля 20px. ≥1024 — 12 колонок, гаттер 24px, поля 48px, контейнер `max-w-page` 1280px по центру (на 1440 поля ≈ 80px).
- **Ритм секций:** padding-y 72px (390) → 128px (1440). Отступ над H2 больше, чем под ним: H2 → лид 16px, лид → контент 40px (390) / 64px (1440). - **Ритм секций:** padding-y 48px (390) → 80px (1440), между соседними секциями 96 → 160px. Отступ над H2 больше, чем под ним: H2 → лид 16px, лид → контент 40px (390) / 64px (1440).
- **Разделение секций:** не фоном-плашкой, а линейкой 2px `ink` по ширине контейнера сверху секции + сменой поверхности только у тёмного блока и формы. - **Разделение секций:** не фоном-плашкой, а линейкой 2px `ink` по ширине контейнера сверху секции + сменой поверхности только у тёмного блока и формы.
- **Шкала отступов:** 4, 8, 12, 16, 20, 24, 32, 48, 72, 128. - **Шкала отступов:** 4, 8, 12, 16, 20, 24, 32, 48, 72, 128.
+105 -2
View File
@@ -140,11 +140,11 @@
max-width: var(--container-page); max-width: var(--container-page);
} }
.section-y { .section-y {
padding-block: 72px; padding-block: 48px;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.section-y { .section-y {
padding-block: 128px; padding-block: 80px;
} }
} }
@@ -483,3 +483,106 @@
} }
} }
} }
/* Моушн (DESIGN.md → «Моушн»). Без JS и при reduced-motion — сразу финальное состояние. */
@keyframes stamp-in {
from {
opacity: 0;
transform: rotate(calc(var(--stamp-rotate, -6deg) - 3deg)) scale(1.18);
}
to {
opacity: 0.92;
transform: rotate(var(--stamp-rotate, -6deg)) scale(1);
}
}
@keyframes busy-dot {
0%,
100% {
opacity: 0.3;
}
33% {
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: no-preference) {
[data-motion] .stamp[data-stamp='load'] {
animation: stamp-in 380ms var(--ease-out-expo) 250ms both;
}
.stamp[data-stamp='event'] {
animation: stamp-in 380ms var(--ease-out-expo) 80ms both;
}
.busy-dots i {
animation: busy-dot 480ms steps(1) infinite;
}
.busy-dots i:nth-child(2) {
animation-delay: 160ms;
}
.busy-dots i:nth-child(3) {
animation-delay: 320ms;
}
[data-lead-notice] > * {
animation: fade-in 160ms ease-out;
}
/* лента недели */
[data-motion] .week .week-rule {
transition: transform 600ms var(--ease-out-quart);
}
[data-motion] .week .week-mark {
transition: transform 320ms var(--ease-out-quart) 300ms;
}
[data-motion] .week .week-note {
transition:
opacity 320ms var(--ease-out-quart) 300ms,
transform 320ms var(--ease-out-quart) 300ms;
}
[data-motion] .week .week-day:nth-child(5) .week-mark,
[data-motion] .week .week-day:nth-child(5) .week-note {
transition-delay: 480ms;
}
[data-motion] .week .week-bracket-line {
transition: transform 500ms var(--ease-out-quart) 680ms;
}
[data-motion] .week .week-bracket-note {
transition: opacity 240ms ease-out 1000ms;
}
[data-motion] .week:not([data-inview]) .week-rule,
[data-motion] .week:not([data-inview]) .week-mark {
transform: scaleX(0);
}
[data-motion] .week:not([data-inview]) .week-note {
opacity: 0;
transform: translateY(6px);
}
[data-motion] .week:not([data-inview]) .week-bracket-line {
transform: scaleY(0);
}
[data-motion] .week:not([data-inview]) .week-bracket-note {
opacity: 0;
}
@media (min-width: 1024px) {
[data-motion] .week:not([data-inview]) .week-bracket-line {
transform: scaleX(0);
}
}
/* липкая CTA */
.sticky-cta {
transition: transform 180ms var(--ease-in-quart);
}
.sticky-cta[data-visible] {
transition: transform 240ms var(--ease-out-quart);
}
}
.sticky-cta:not([data-visible]) {
transform: translateY(100%);
visibility: hidden;
transition-property: transform, visibility;
}
+9 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Golos_Text, Martian_Mono } from 'next/font/google'; import { Golos_Text, Martian_Mono } from 'next/font/google';
import { MOTION_BOOT, RevealObserver } from '@/components/RevealObserver';
import { StampInkFilter } from '@/components/Stamp'; import { StampInkFilter } from '@/components/Stamp';
import { StickyCta } from '@/components/StickyCta';
import { TouchRecorder } from '@/components/TouchRecorder'; import { TouchRecorder } from '@/components/TouchRecorder';
import { site } from '@/content/site'; import { site } from '@/content/site';
import './globals.css'; import './globals.css';
@@ -19,11 +21,17 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="ru" className={`${golos.variable} ${martian.variable}`}> <html lang="ru" className={`${golos.variable} ${martian.variable}`} suppressHydrationWarning>
<head>
{/* до первой отрисовки: иначе блоки мигнут в начальном состоянии анимации */}
<script dangerouslySetInnerHTML={{ __html: MOTION_BOOT }} />
</head>
<body> <body>
<StampInkFilter /> <StampInkFilter />
{children} {children}
<StickyCta />
<TouchRecorder /> <TouchRecorder />
<RevealObserver />
</body> </body>
</html> </html>
); );
@@ -0,0 +1,27 @@
'use client';
// Отмечает [data-reveal] атрибутом data-inview, когда блок попал в кадр (один раз).
// Работает только при <html data-motion> — его ставит скрипт в <head>, если движение разрешено.
import { useEffect } from 'react';
export function RevealObserver() {
useEffect(() => {
if (!('motion' in document.documentElement.dataset)) return;
const targets = document.querySelectorAll<HTMLElement>('[data-reveal]:not([data-inview])');
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
(entry.target as HTMLElement).dataset.inview = '';
io.unobserve(entry.target);
}
},
{ threshold: 0.35 },
);
targets.forEach((el) => io.observe(el));
return () => io.disconnect();
}, []);
return null;
}
/** Инлайн-скрипт для <head>: включает анимации только если их можно показать. */
export const MOTION_BOOT = `try{if(!matchMedia('(prefers-reduced-motion: reduce)').matches&&'IntersectionObserver' in window)document.documentElement.dataset.motion=''}catch(e){}`;
+67
View File
@@ -0,0 +1,67 @@
'use client';
// Липкая CTA на мобиле: видна, когда кнопки первого экрана ушли из кадра,
// и прячется у формы, при фокусе в форме и при открытой клавиатуре.
import { useEffect, useState } from 'react';
import { site } from '@/content/site';
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
import { CtaButton } from './CtaButton';
export function StickyCta() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const hero = document.querySelector('[data-hero-cta]');
const lead = document.getElementById(LEAD_SECTION_ID);
if (!hero || !lead) return;
let heroSeen = true;
let leadSeen = false;
let focusInLead = false;
let keyboard = false;
const update = () => setVisible(!heroSeen && !leadSeen && !focusInLead && !keyboard);
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.target === hero) heroSeen = entry.isIntersecting;
if (entry.target === lead) leadSeen = entry.isIntersecting;
}
update();
});
io.observe(hero);
io.observe(lead);
const onFocus = () => {
focusInLead = lead.contains(document.activeElement);
update();
};
const vv = window.visualViewport;
const onResize = () => {
keyboard = vv ? vv.height < window.innerHeight * 0.75 : false;
update();
};
document.addEventListener('focusin', onFocus);
document.addEventListener('focusout', onFocus);
vv?.addEventListener('resize', onResize);
return () => {
io.disconnect();
document.removeEventListener('focusin', onFocus);
document.removeEventListener('focusout', onFocus);
vv?.removeEventListener('resize', onResize);
};
}, []);
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"
data-visible={visible ? '' : undefined}
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}
</CtaButton>
</div>
);
}