diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4282ec3..e54a1c3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { CookieConsent } from "@/widgets/cookie-consent"; import { Cursor } from "@/widgets/cursor"; import { Preloader } from "@/widgets/preloader"; import { ScrollCounter } from "@/widgets/scroll-counter"; +import { YandexMetrika } from "@/widgets/yandex-metrika"; import "@/static/globals.css"; const geistSans = Geist({ @@ -31,6 +33,8 @@ const RootLayout = ({ children }: LayoutProps<"/">) => { {children} + + ); diff --git a/src/static/cookie-consent/cookie-consent.module.css b/src/static/cookie-consent/cookie-consent.module.css new file mode 100644 index 0000000..10e34c7 --- /dev/null +++ b/src/static/cookie-consent/cookie-consent.module.css @@ -0,0 +1,115 @@ +.banner { + position: fixed; + right: var(--container-padding); + bottom: var(--space-md); + left: var(--container-padding); + z-index: 9980; + + display: flex; + justify-content: center; + + pointer-events: none; +} + +.content { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + + width: min(720px, 100%); + padding: 18px; + + border: 1px solid var(--color-line); + border-radius: 20px; + + background: rgba(10, 10, 10, 0.92); + color: var(--color-text); + + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + + pointer-events: auto; + + animation: bannerIn 0.35s ease; +} + +.text { + display: flex; + flex-direction: column; + gap: 4px; +} + +.title { + margin: 0; + + font-size: var(--fs-small); + font-weight: 700; + line-height: 1.2; +} + +.description { + max-width: 52ch; + margin: 0; + + color: var(--color-muted); + + font-size: var(--fs-small); + line-height: 1.5; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + + min-height: 44px; + padding: 0 22px; + + flex-shrink: 0; + + border: 1px solid rgba(255, 255, 255, 0.86); + border-radius: 100px; + + background: var(--color-text); + color: var(--color-bg); + + font: inherit; + font-size: var(--fs-small); + font-weight: 700; + + cursor: pointer; + + transition: + transform 0.25s ease, + opacity 0.25s ease; +} + +.button:hover { + opacity: 0.86; + transform: translateY(-1px); +} + +@keyframes bannerIn { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (max-width: 640px) { + .content { + align-items: stretch; + flex-direction: column; + } + + .button { + width: 100%; + } +} diff --git a/src/widgets/cookie-consent/index.ts b/src/widgets/cookie-consent/index.ts new file mode 100644 index 0000000..4ba019d --- /dev/null +++ b/src/widgets/cookie-consent/index.ts @@ -0,0 +1,5 @@ +export { + COOKIE_CONSENT_EVENT, + COOKIE_CONSENT_KEY, + default as CookieConsent, +} from "./ui/CookieConsent"; diff --git a/src/widgets/cookie-consent/ui/CookieConsent.tsx b/src/widgets/cookie-consent/ui/CookieConsent.tsx new file mode 100644 index 0000000..cf633e1 --- /dev/null +++ b/src/widgets/cookie-consent/ui/CookieConsent.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import styles from "@/static/cookie-consent/cookie-consent.module.css"; + +export const COOKIE_CONSENT_KEY = "autli-cookie-consent"; +export const COOKIE_CONSENT_EVENT = "autli-cookie-consent-accepted"; + +const CookieConsent = () => { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const timer = window.setTimeout(() => { + setVisible(window.localStorage.getItem(COOKIE_CONSENT_KEY) !== "accepted"); + }, 0); + + return () => window.clearTimeout(timer); + }, []); + + const acceptCookies = () => { + window.localStorage.setItem(COOKIE_CONSENT_KEY, "accepted"); + window.dispatchEvent(new Event(COOKIE_CONSENT_EVENT)); + setVisible(false); + }; + + if (!visible) return null; + + return ( +
+
+
+

Cookie-файлы

+

+ Я использую cookie и Яндекс Метрику, чтобы понимать, как работает + сайт, и улучшать его для посетителей. +

+
+ + +
+
+ ); +}; + +export default CookieConsent; diff --git a/src/widgets/yandex-metrika/index.ts b/src/widgets/yandex-metrika/index.ts new file mode 100644 index 0000000..d8249a6 --- /dev/null +++ b/src/widgets/yandex-metrika/index.ts @@ -0,0 +1 @@ +export { default as YandexMetrika } from "./ui/YandexMetrika"; diff --git a/src/widgets/yandex-metrika/ui/YandexMetrika.tsx b/src/widgets/yandex-metrika/ui/YandexMetrika.tsx new file mode 100644 index 0000000..0edfe1a --- /dev/null +++ b/src/widgets/yandex-metrika/ui/YandexMetrika.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePathname } from "next/navigation"; +import Script from "next/script"; + +import { + COOKIE_CONSENT_EVENT, + COOKIE_CONSENT_KEY, +} from "@/widgets/cookie-consent"; + +declare global { + interface Window { + ym?: ( + counterId: number, + action: "init" | "hit", + options?: Record | string + ) => void; + } +} + +const counterId = Number(process.env.NEXT_PUBLIC_YANDEX_METRIKA_ID); + +const YandexMetrika = () => { + const pathname = usePathname(); + const [accepted, setAccepted] = useState(false); + const previousUrl = useRef(null); + + useEffect(() => { + const timer = window.setTimeout(() => { + setAccepted(window.localStorage.getItem(COOKIE_CONSENT_KEY) === "accepted"); + }, 0); + const onAccepted = () => setAccepted(true); + + window.addEventListener(COOKIE_CONSENT_EVENT, onAccepted); + + return () => { + window.clearTimeout(timer); + window.removeEventListener(COOKIE_CONSENT_EVENT, onAccepted); + }; + }, []); + + useEffect(() => { + if (!accepted || !counterId || !window.ym) return; + + const url = window.location.href; + + if (previousUrl.current === null) { + previousUrl.current = url; + return; + } + + if (previousUrl.current === url) return; + + previousUrl.current = url; + window.ym(counterId, "hit", url); + }, [accepted, pathname]); + + if (!accepted || !counterId) return null; + + return ( +