feat(landing): штамп, лента недели, состояния кнопки, липкая CTA
Оттиск штампа при загрузке и при успехе формы, заполнение ленты недели по IntersectionObserver, мигающие точки при отправке. Только CSS и transform/opacity; анимации включает скрипт в head, поэтому без JS и при prefers-reduced-motion всё сразу в финале. Липкая CTA на мобиле: «Заказать кофе», прячется у формы, при фокусе в ней и при открытой клавиатуре. Ритм секций плотнее.
This commit is contained in:
@@ -317,7 +317,7 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
|
||||
|
||||
- **Mobile-first.** Брейкпоинты Tailwind по умолчанию: `md` 768 (две колонки), `lg` 1024 (12-колоночная сетка), `xl` 1280.
|
||||
- **Сетка:** 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` по ширине контейнера сверху секции + сменой поверхности только у тёмного блока и формы.
|
||||
- **Шкала отступов:** 4, 8, 12, 16, 20, 24, 32, 48, 72, 128.
|
||||
|
||||
|
||||
@@ -140,11 +140,11 @@
|
||||
max-width: var(--container-page);
|
||||
}
|
||||
.section-y {
|
||||
padding-block: 72px;
|
||||
padding-block: 48px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Golos_Text, Martian_Mono } from 'next/font/google';
|
||||
import { MOTION_BOOT, RevealObserver } from '@/components/RevealObserver';
|
||||
import { StampInkFilter } from '@/components/Stamp';
|
||||
import { StickyCta } from '@/components/StickyCta';
|
||||
import { TouchRecorder } from '@/components/TouchRecorder';
|
||||
import { site } from '@/content/site';
|
||||
import './globals.css';
|
||||
@@ -19,11 +21,17 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
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>
|
||||
<StampInkFilter />
|
||||
{children}
|
||||
<StickyCta />
|
||||
<TouchRecorder />
|
||||
<RevealObserver />
|
||||
</body>
|
||||
</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){}`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user