This commit is contained in:
2026-05-12 16:19:46 +05:00
parent 11500a9ec3
commit d77fdd0e2c
58 changed files with 2008 additions and 156 deletions
+3
View File
@@ -5,6 +5,7 @@
"name": "dyagilev",
"dependencies": {
"@number-flow/react": "^0.6.0",
"@yandex/ymaps3-types": "^1.0.19487230",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"next": "16.2.4",
@@ -262,6 +263,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@yandex/ymaps3-types": ["@yandex/ymaps3-types@1.0.19487230", "", { "peerDependencies": { "@types/react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@vue/runtime-core": "3" }, "optionalPeers": ["@types/react", "@types/react-dom", "@vue/runtime-core"] }, "sha512-jgryA+TzR/yJ6o3mXHOmalDKEefzi8w/VSAbSzk4uVrn9JzDxXq5i5OKnCVixQbpoxB1lbGJDpvU4fdImRaNww=="],
"acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+158 -3
View File
@@ -8,9 +8,14 @@
"name": "dyagilev",
"version": "0.1.0",
"dependencies": {
"@number-flow/react": "^0.6.0",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-pageflip": "^2.0.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@types/node": "^20",
@@ -1300,6 +1305,20 @@
"node": ">=12.4.0"
}
},
"node_modules/@number-flow/react": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.0.tgz",
"integrity": "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4",
"number-flow": "0.6.0"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1362,7 +1381,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2558,6 +2577,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2634,7 +2662,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -3367,6 +3395,12 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -3586,6 +3620,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4661,6 +4722,47 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.38.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4838,6 +4940,15 @@
"node": ">=0.10.0"
}
},
"node_modules/number-flow": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.0.tgz",
"integrity": "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5039,6 +5150,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/page-flip": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/page-flip/-/page-flip-2.0.7.tgz",
"integrity": "sha512-96lQFUUz7r/LZzEUZJ3yBIMEKU9+m8HMFDzTvTdD6P7Ag/wXINjp9n0W7b4wanwnDbQETo4uNUoL3zMqpFxwGA==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5394,6 +5511,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-pageflip": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/react-pageflip/-/react-pageflip-2.0.3.tgz",
"integrity": "sha512-k81mHhRvUM52y8jyzTCh5t4O0lepkLhp+XGSUzq2C3uD+iW99Cv0jfRlqFCjZbD5N3jKkIFr7/3giucoXKDP3Q==",
"license": "MIT",
"dependencies": {
"page-flip": "latest"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -6686,6 +6812,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@number-flow/react": "^0.6.0",
"@yandex/ymaps3-types": "^1.0.19487230",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"next": "16.2.4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+8
View File
@@ -19,6 +19,14 @@ body {
color: var(--foreground);
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@layer utilities {
/* Typography */
.line-xl {
+1 -1
View File
@@ -20,7 +20,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`h-full antialiased scroll-smooth text-white ${manrope.className}`}
className={`h-full no-scrollbar antialiased scroll-smooth text-white ${manrope.className}`}
>
<body className="min-h-full">{children}</body>
</html>
+17 -1
View File
@@ -7,10 +7,18 @@ import MenuSidebar from "@/components/ui/MenuSidebar";
import Overview from "@/components/pages/Overview";
import ScreenOverlay from "@/components/ui/ScreenOverlay";
import Premiere from "@/components/pages/Premiere";
import Architecture from "@/components/pages/Architecture";
import Location from "@/components/pages/Location";
import MapPage from "@/components/pages/MapPage";
import ResidentialForm from "@/components/pages/ResidentialForm";
import Compromises from "@/components/pages/Compromises";
import LifeAndWork from "@/components/pages/LifeAndWork";
import Residents from "@/components/pages/Residents";
import Collection from "@/components/pages/Collection/Collection";
export default function Root() {
return (
<main className="relative overflow-hidden ">
<main className="relative">
<LoadingScreen />
{/* Основной UI */}
@@ -22,6 +30,14 @@ export default function Root() {
<Hero />
<Overview />
<Premiere />
<Architecture />
<Location />
<MapPage />
<ResidentialForm />
<Compromises />
<LifeAndWork />
<Residents />
<Collection />
</main>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { YMapZoomControl } from "@yandex/ymaps3-types/packages/controls";
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
declare global {
interface Window {
ymaps3: any;
}
}
export default function Map() {
const [mapComponents, setMapComponents] = useState<any>(null);
useEffect(() => {
const initMap = async () => {
try {
if (!window.ymaps3) {
const script = document.createElement("script");
script.src =
"https://api-maps.yandex.ru/v3/?apikey=be3b1d29-3c2b-408b-8653-2cb448b80da1&lang=ru_RU";
script.async = true;
document.body.appendChild(script);
await new Promise((resolve) => {
script.onload = resolve;
});
}
await window.ymaps3.ready;
const ymaps3React = await window.ymaps3.import(
"@yandex/ymaps3-reactify"
);
const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
const components = reactify.module(window.ymaps3);
console.log("components", components);
setMapComponents(components);
} catch (e) {
console.error(e);
}
};
initMap();
}, []);
if (!mapComponents) {
return null;
}
const {
YMap,
YMapDefaultSchemeLayer,
YMapDefaultFeaturesLayer,
YMapScaleControl,
} = mapComponents;
return (
<YMap
style={{ width: "100%", height: "100%" }}
location={{
center: [47.243739, 56.137059],
zoom: 15,
}}
behaviors={["drag", "dblClick"]}
>
<YMapDefaultSchemeLayer />
<YMapDefaultFeaturesLayer />
<YMapScaleControl />
</YMap>
);
}
+40
View File
@@ -0,0 +1,40 @@
/* eslint-disable @next/next/no-img-element */
import Hint from "../ui/Hint";
import Section from "../ui/Section";
export default function Architecture() {
return (
<Section
id="architecture"
className="bg-[url('/img/bg/sky.png')]"
headerColorScheme="Light"
>
<h2 className="text-[5.556vw] leading-[1] text-white w-full font-light flex flex-col gap-[0.556vw] select-none z-[2] relative">
<span>
Архитектура, говорящая <br /> на языке города
</span>
</h2>
<div className="lg:size-[2.778vw] absolute top-[33.1vw] left-[48.1vw] z-[2]">
<Hint
openByDefault
direction="right"
title={"Скругленные\u00A0углы"}
subtitle={
"Развивающее\u00A0пространство\nс\u00A0безопасными\u00A0материалами"
}
/>
</div>
<div className="lg:size-[2.778vw] absolute top-[23.1vw] left-[68.1vw] z-[2]">
<Hint title={"Еще какой-то угол"} subtitle={"Смотри - как настоящий"} />
</div>
<img
src="/img/mocks/building2.png"
alt="Architecture"
className="absolute bottom-0 left-0 w-full h-full object-cover z-[1]"
/>
</Section>
);
}
@@ -0,0 +1,75 @@
import { AnimatePresence, motion } from "framer-motion";
import React from "react";
type AppartamentDescriptionProps = {
title: string;
label: string;
tags: string[];
};
export default function AppartamentDescription({
title,
label,
tags,
}: AppartamentDescriptionProps) {
const animationKey = `${label}-${title}-${tags.join("-")}`;
const animationDuration = 0.25;
return (
<div className="w-full h-full">
<AnimatePresence mode="wait">
<motion.div
key={animationKey}
initial="hidden"
animate="visible"
exit="hidden"
variants={{
hidden: {},
visible: {
transition: {
staggerChildren: 0.06,
},
},
}}
>
<motion.span
className="caption-m text-[#242424]/40"
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
>
{label}
</motion.span>
<motion.h2
className="title-l whitespace-pre-line text-[#262626] mt-[0.833vw] mb-[2.778vw]"
variants={{
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
>
{title}
</motion.h2>
<div className="flex flex-col gap-[0.556vw]">
{tags.map((tag, index) => (
<motion.span
className="text-m text-[#242424]/40 py-[0.833vw] px-[1.389vw] border border-[#262626]/6 rounded-full w-max "
key={`${tag}-${index}`}
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
>
{tag}
</motion.span>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,242 @@
import Section from "../../ui/Section";
import { ANIMATION_DURATION, SwipeState } from "./helpers";
import CursorButtonWrapper from "../../ui/CursorButtonWrapper";
import {
useScroll,
useMotionValueEvent,
motion,
AnimatePresence,
} from "framer-motion";
import { useEffect, useRef, useState } from "react";
import TinderSlotCard from "./TinderSlotCard";
import CollectionCard from "./CollectionCard";
import { useCollectionScrollCardMotion } from "./useCollectionScrollCardMotion";
import AppartamentDescription from "./AppartamentDescription";
import PlanDescription from "./PlanDescription";
import { COLLECTION_DATA, getInitialCollectionSlotIndices } from "./data";
const DECK_LENGTH = COLLECTION_DATA.length;
function rotateIndicesRight(prev: number[]) {
if (DECK_LENGTH <= 0) return prev;
return prev.map((i) => (i - 1 + DECK_LENGTH) % DECK_LENGTH);
}
function rotateIndicesLeft(prev: number[]) {
if (DECK_LENGTH <= 0) return prev;
return prev.map((i) => (i + 1) % DECK_LENGTH);
}
export default function Collection() {
// Индекс изображения которое считается активным по умолчанию.
// Указано X - значит imagesOrder[X] - активный
const COLLECTION_ACTIVE_SLOT_INDEX = 1;
// Состояния
const isAnimating = useRef(false);
const [isTinder, setIsTinder] = useState(false);
// Отслеживание скролла
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end end"],
});
useMotionValueEvent(scrollYProgress, "change", (latest) => {
setIsTinder(latest >= 0.8);
});
// по сути, порядок изображений, которые сдивгается влево или вправо при листании,
// в результате чего обновялются отображаемые фото.
// Нужен для сохранения порядка изображения после выхода из состояния тиндера.
const [imagesOrder, setImagesOrder] = useState(
getInitialCollectionSlotIndices
);
// Состояние анимации свайпа. Меняется между idle и animating.
// idle - нет анимации, animating - анимация свайпа.
const [swipe, setSwipe] = useState<SwipeState>({ phase: "idle" });
// Индекс активного изображения (по середине). Меняется сразу при клике,
// а imagesOrder обновляется позже, после завершения анимации карточек.
const [activeRecordIndex, setActiveRecordIndex] = useState(() => {
if (DECK_LENGTH === 0) return 0;
return imagesOrder[COLLECTION_ACTIVE_SLOT_INDEX] % DECK_LENGTH;
});
// Активный элемент коллекции.
// Содержит информацию о квартире
const activeItem =
DECK_LENGTH > 0 ? COLLECTION_DATA[activeRecordIndex] : null;
// Анимация свайпа.
// Спустя ANIMATION_DURATION меняем изображения путем шифта индексов в imagesOrder
// Нужен таймер для того, чтобы анимация свайпа
useEffect(() => {
if (swipe.phase !== "animating") return;
const timer = setTimeout(() => {
requestAnimationFrame(() => {
setImagesOrder((prev) =>
swipe.dir === "right"
? rotateIndicesRight(prev)
: rotateIndicesLeft(prev)
);
setSwipe({ phase: "idle" });
isAnimating.current = false;
});
}, ANIMATION_DURATION * 1000);
return () => clearTimeout(timer);
}, [swipe]);
// Motion-значения в `vw` для трёх карточек коллекции при скролле.
const scrollCards = useCollectionScrollCardMotion(scrollYProgress);
const handleSliderClick = (direction: "right" | "left") => {
if (!isTinder || isAnimating.current || DECK_LENGTH === 0) return;
const nextOrder =
direction === "right"
? rotateIndicesRight(imagesOrder)
: rotateIndicesLeft(imagesOrder);
setActiveRecordIndex(nextOrder[COLLECTION_ACTIVE_SLOT_INDEX] % DECK_LENGTH);
isAnimating.current = true;
setSwipe({ phase: "animating", dir: direction });
};
const getImgSrc = (slotIndex: number) => {
if (DECK_LENGTH === 0) return "";
return COLLECTION_DATA[imagesOrder[slotIndex]].src;
};
const getTinderImgSrc = (slotIndex: number) => {
if (DECK_LENGTH === 0) return "";
// фиксит баг с мерцающей фотографией что
if (
swipe.phase === "animating" &&
swipe.dir === "right" &&
slotIndex === 3
) {
return COLLECTION_DATA[rotateIndicesRight(imagesOrder)[0]].src;
}
return getImgSrc(slotIndex);
};
if (DECK_LENGTH === 0) {
return (
<Section id="collection" headerColorScheme="Dark">
<div
ref={ref}
className="relative min-h-[30vh] px-[calc(15.042vw-1.667vw)]"
>
<p className="text-s text-white/60">Нет данных коллекции</p>
</div>
</Section>
);
}
return (
<Section id="collection" headerColorScheme="Dark">
<h2 className="text-[5.556vw] lg:px-[calc(15.042vw-1.667vw)] lg:mb-[2.778vw] leading-[1] w-full font-light flex flex-col gap-[0.556vw] select-none">
<span className="block">Коллекция авторских</span>
<span className="block ml-auto">резиденций</span>
</h2>
<div ref={ref} className="relative h-[200vh]">
<div className="sticky top-0 w-full h-[100vh] ">
<AnimatePresence mode="wait">
{isTinder && activeItem && (
<motion.div
key="description"
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -150 }}
className="lg:pt-[10vw] lg:pb-[5.417vw] h-full w-[22.917vw] absolute left-0"
>
<AppartamentDescription
title={activeItem.title}
label={activeItem.label}
tags={activeItem.tags}
/>
<span className="title-l text-[#242424]/40 absolute bottom-[5.417vw] right-0">
{activeRecordIndex + 1}/{DECK_LENGTH}
</span>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence mode="wait">
{isTinder && activeItem && (
<motion.div
key="plan"
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 150 }}
className="lg:pt-[10vw] lg:pb-[5.417vw] h-full w-[22.917vw] absolute right-0"
>
<PlanDescription
planSrc={activeItem.planSrc}
description={activeItem.planDescription}
onExploreClick={() => {}}
onShowVariantsClick={() => {}}
/>
</motion.div>
)}
</AnimatePresence>
{isTinder ? (
<>
{[0, 1, 2, 3].map((index) => (
<TinderSlotCard
key={index}
indexInDeck={index}
swipe={swipe}
src={getTinderImgSrc(index)}
className="-translate-y-1/2"
/>
))}
</>
) : (
<>
<CollectionCard
zIndex={2}
w={scrollCards[0].widthVw}
h={scrollCards[0].heightVw}
l={scrollCards[0].leftVw}
src={getImgSrc(0)}
className="-translate-y-1/2"
/>
<CollectionCard
zIndex={3}
w={scrollCards[1].widthVw}
h={scrollCards[1].heightVw}
l={scrollCards[1].leftVw}
src={getImgSrc(1)}
className="-translate-y-1/2"
/>
<CollectionCard
zIndex={2}
w={scrollCards[2].widthVw}
h={scrollCards[2].heightVw}
l={scrollCards[2].leftVw}
src={getImgSrc(2)}
className="-translate-y-1/2"
/>
</>
)}
{isTinder && (
<CursorButtonWrapper
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[31.111vw] h-[41.667vw] z-[4]"
onRightClick={() => handleSliderClick("left")}
onLeftClick={() => handleSliderClick("right")}
>
<div className="size-full" />
</CursorButtonWrapper>
)}
</div>
</div>
</Section>
);
}
@@ -0,0 +1,38 @@
/* eslint-disable @next/next/no-img-element */
import { motion, MotionValue } from "motion/react";
import { ANIMATION_DURATION } from "./helpers";
import clsx from "clsx";
export default function CollectionCard({
w,
h,
l,
src,
className,
zIndex,
}: {
w: MotionValue<string> | string;
h: MotionValue<string> | string;
l: MotionValue<string> | string;
src: string;
className?: string;
zIndex?: number;
}) {
return (
<motion.div
style={{
width: w,
height: h,
left: l,
zIndex: zIndex,
}}
transition={{
duration: ANIMATION_DURATION,
ease: "easeInOut",
}}
className={clsx("absolute top-1/2", className)}
>
<img src={src} alt="" className="size-full object-cover" />
</motion.div>
);
}
@@ -0,0 +1,95 @@
import ButtonRound from "@/components/ui/Buttons/ButtonRound";
import Tour3D from "@/components/icons/Tour3D";
import Search from "@/components/icons/Search";
import { AnimatePresence, motion } from "framer-motion";
type PlanDescriptionProps = {
planSrc: string;
description: string;
onExploreClick: () => void;
onShowVariantsClick: () => void;
};
export default function PlanDescription({
planSrc,
description,
onExploreClick,
onShowVariantsClick,
}: PlanDescriptionProps) {
const animationKey = `${planSrc}-${description}`;
const animationDuration = 0.25;
return (
<div className="flex flex-col items-start gap-2 w-full h-full">
<AnimatePresence mode="wait">
<motion.div
key={animationKey}
className="flex flex-col items-start gap-2 w-full h-full"
initial="hidden"
animate="visible"
exit="hidden"
variants={{
hidden: {},
visible: {
transition: {
staggerChildren: 0.06,
},
},
}}
>
<div className="flex flex-col items-start gap-2 ">
<motion.div
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
>
<ButtonRound
type="Dark"
text={"Исследовать\nпространство"}
onClick={onExploreClick}
icon={<Tour3D />}
/>
</motion.div>
<motion.div
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
>
<ButtonRound
type="Dark"
text={"Показать\nварианты"}
onClick={onShowVariantsClick}
icon={<Search />}
/>
</motion.div>
</div>
<motion.img
src={planSrc}
alt="plan"
className="size-[15vw] object-cover mb-[2.083vw] mt-auto"
variants={{
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
/>
<motion.p
className="text-s text-[#262626] w-[85%]"
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: animationDuration, ease: "easeOut" }}
>
{description}
</motion.p>
</motion.div>
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,133 @@
/* eslint-disable @next/next/no-img-element */
import { useLayoutEffect, useRef, useState } from "react";
import { ANIMATION_DURATION, SwipeState } from "./helpers";
import clsx from "clsx";
import { motion } from "motion/react";
type CardRole = "middle" | "right" | "left" | "under";
const BASE_ROLES: CardRole[] = ["left", "middle", "right", "under"]; // Базовое положение
const TARGET_RIGHT: CardRole[] = ["middle", "right", "under", "left"]; // Положение после свайпа вправо
const TARGET_LEFT: CardRole[] = ["under", "left", "middle", "right"]; // Положение после свайпа вправо
// Стили положения для разных состояний карточки
const CARDS_STATE = {
right: {
width: "24.797vw",
height: "32.528vw",
left: "40vw",
zIndex: 2,
},
middle: {
width: "27.275vw",
height: "35.139vw",
left: "34.8vw",
zIndex: 3,
},
left: {
width: "24.797vw",
height: "32.528vw",
left: "32.222vw",
zIndex: 2,
},
under: {
width: "14.931vw",
height: "19.583vw",
left: "40.708vw",
zIndex: 1,
},
} as const;
const Z_INDEX_DELAY_MS = (ANIMATION_DURATION * 1000) / 2;
export default function TinderSlotCard({
indexInDeck,
swipe,
src,
className,
}: {
indexInDeck: number;
swipe: SwipeState;
src: string;
className?: string;
}) {
function cardPosition(slotIndex: number, swipe: SwipeState) {
if (swipe.phase !== "animating") return CARDS_STATE[BASE_ROLES[slotIndex]];
const keys = swipe.dir === "right" ? TARGET_RIGHT : TARGET_LEFT;
return CARDS_STATE[keys[slotIndex]];
}
// Переход из состояния
const baseGeo = cardPosition(indexInDeck, { phase: "idle" });
// в состояние
const geo = cardPosition(indexInDeck, swipe);
const swipeDir = swipe.phase === "animating" ? swipe.dir : undefined;
/** В idle z-index из геометрии слота — без кадра с «чужим» delayedZ */
const [delayedZ, setDelayedZ] = useState(() => baseGeo.zIndex);
const zForMotion = swipe.phase === "idle" ? geo.zIndex : delayedZ;
const zHalfTimerRef = useRef<number | undefined>(undefined);
useLayoutEffect(() => {
if (swipe.phase !== "animating") return;
const fromZ = baseGeo.zIndex;
const toZ = geo.zIndex;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setDelayedZ(fromZ);
zHalfTimerRef.current = window.setTimeout(() => {
if (!cancelled) setDelayedZ(toZ);
zHalfTimerRef.current = undefined;
}, Z_INDEX_DELAY_MS);
});
return () => {
cancelled = true;
if (zHalfTimerRef.current !== undefined) {
clearTimeout(zHalfTimerRef.current);
zHalfTimerRef.current = undefined;
}
};
}, [swipe.phase, swipeDir, indexInDeck, geo.zIndex, baseGeo.zIndex]);
const transition =
swipe.phase === "animating"
? {
duration: ANIMATION_DURATION,
ease: "easeInOut" as const,
zIndex: { duration: 0 },
}
: { duration: 0 };
return (
<motion.div
initial={false}
animate={{
width: geo.width,
height: geo.height,
left: geo.left,
zIndex: zForMotion,
}}
transition={transition}
className={clsx(
"absolute top-1/2",
swipe.phase === "animating" &&
"will-change-[transform,width,height,left]",
className
)}
>
<img
src={src}
alt=""
decoding="async"
draggable={false}
className="size-full object-cover pointer-events-none select-none"
/>
</motion.div>
);
}
+68
View File
@@ -0,0 +1,68 @@
export type CollectionData = {
title: string;
label: string;
tags: string[];
/** Основное фото карточки в колоде */
src: string;
/** План в блоке справа */
planSrc: string;
planDescription: string;
};
export const COLLECTION_DATA: CollectionData[] = [
{
title: "Однокомнатная видовая квартира до 44м² 1",
label: "Соло в центре событий",
tags: ["потолок 3,9м", "панорамное остекление", "матер-спальня"],
src: "/img/collection/1.png",
planSrc: "/img/collection/1.png",
planDescription: " Компактный и функциональный лот, созданный для тех, кто ценит ритм города 
и личную свободу. Пространство, где каждый метр работает на ваш комфорт, 
не отвлекая от главного.",
},
{
title: "Однокомнатная видовая квартира до 44м² 2",
label: "Соло в центре событий",
tags: ["потолок 3,9м", "панорамное остекление", "матер-спальня"],
src: "/img/collection/2.png",
planSrc: "/img/collection/2.png",
planDescription: " Компактный и функциональный лот, созданный для тех, кто ценит ритм города 
и личную свободу. Пространство, где каждый метр работает на ваш комфорт, 
не отвлекая от главного.",
},
{
title: "Однокомнатная видовая квартира до 44м² 3",
label: "Соло в центре событий",
tags: ["потолок 3,9м", "панорамное остекление", "матер-спальня"],
src: "/img/collection/3.png",
planSrc: "/img/collection/3.png",
planDescription: " Компактный и функциональный лот, созданный для тех, кто ценит ритм города 
и личную свободу. Пространство, где каждый метр работает на ваш комфорт, 
не отвлекая от главного.",
},
{
title: "Однокомнатная видовая квартира до 44м² 4",
label: "Соло в центре событий",
tags: ["потолок 3,9м", "панорамное остекление", "матер-спальня"],
src: "/img/collection/4.png",
planSrc: "/img/collection/4.png",
planDescription: " Компактный и функциональный лот, созданный для тех, кто ценит ритм города 
и личную свободу. Пространство, где каждый метр работает на ваш комфорт, 
не отвлекая от главного.",
},
{
title: "Однокомнатная видовая квартира до 44м² 5",
label: "Соло в центре событий",
tags: ["потолок 3,9м", "панорамное остекление", "матер-спальня"],
src: "/img/collection/5.png",
planSrc: "/img/collection/5.png",
planDescription: " Компактный и функциональный лот, созданный для тех, кто ценит ритм города 
и личную свободу. Пространство, где каждый метр работает на ваш комфорт, 
не отвлекая от главного.",
},
];
// Порядок объектов по умолчанию. Объект по индексу 1 - активный.
// Поэтому начинаем с последнего. N-1, 0, 1, 2...
export function getInitialCollectionSlotIndices(): number[] {
const n = COLLECTION_DATA.length;
if(n < 3)
return [0, 0, 0, 0]
else{
const first = n - 1;
const second = new Array(n - 1).fill(0).map((_, i) => i);
return [first, 0, 1, 2];
}
}
@@ -0,0 +1,5 @@
export const ANIMATION_DURATION = 0.5;
export type SwipeState =
| { phase: "idle" }
| { phase: "animating"; dir: "left" | "right" };
@@ -0,0 +1,60 @@
import { type MotionValue, useTransform } from "framer-motion";
/** Диапазон прогресса скролла секции (как в useScroll offset) */
const SCROLL_INPUT: [number, number] = [0, 0.8];
/** Тройки [начало, конец] для left / width / height по каждой из трёх карточек вне тиндера */
export const COLLECTION_SCROLL_CARD_SPECS: Array<{
left: [number, number];
width: [number, number];
height: [number, number];
}> = [
{
left: [24.514, 32.222],
width: [14.931, 24.797], // размеры левой карточки начальные -> тиндер
height: [19.583, 32.528],
},
{
left: [40.8, 34.8],
width: [14.931, 27.275], // размеры центральной карточки начальные -> тиндер
height: [19.583, 35.139],
},
{
left: [57.222, 40],
width: [14.931, 24.797], // размеры правой карточки начальные -> тиндер
height: [19.583, 32.528],
},
];
export type CollectionScrollCardMotion = {
leftVw: MotionValue<string>;
widthVw: MotionValue<string>;
heightVw: MotionValue<string>;
};
function useCardScrollVw(
scrollYProgress: MotionValue<number>,
spec: (typeof COLLECTION_SCROLL_CARD_SPECS)[number],
): CollectionScrollCardMotion {
const left = useTransform(scrollYProgress, SCROLL_INPUT, spec.left);
const width = useTransform(scrollYProgress, SCROLL_INPUT, spec.width);
const height = useTransform(scrollYProgress, SCROLL_INPUT, spec.height);
const leftVw = useTransform(left, (v) => `${v}vw`);
const widthVw = useTransform(width, (v) => `${v}vw`);
const heightVw = useTransform(height, (v) => `${v}vw`);
return { leftVw, widthVw, heightVw };
}
/** Motion-значения в `vw` для трёх карточек коллекции при скролле (хуки — фиксированное число вызовов). */
export function useCollectionScrollCardMotion(
scrollYProgress: MotionValue<number>,
): readonly [
CollectionScrollCardMotion,
CollectionScrollCardMotion,
CollectionScrollCardMotion,
] {
const card0 = useCardScrollVw(scrollYProgress, COLLECTION_SCROLL_CARD_SPECS[0]);
const card1 = useCardScrollVw(scrollYProgress, COLLECTION_SCROLL_CARD_SPECS[1]);
const card2 = useCardScrollVw(scrollYProgress, COLLECTION_SCROLL_CARD_SPECS[2]);
return [card0, card1, card2] as const;
}
+102
View File
@@ -0,0 +1,102 @@
/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */
import Section from "../ui/Section";
import {
motion,
useMotionValueEvent,
useScroll,
useTransform,
} from "motion/react";
import { useRef, useState } from "react";
import { useAppStateStore } from "@/stores/useAppStateStore";
import VideoPlayer from "@/components/ui/VideoPlayer";
export default function Compromises() {
const ref = useRef<HTMLDivElement>(null);
const { setHeaderColorScheme } = useAppStateStore();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end end"],
});
const [expanded, setExpanded] = useState(false);
useMotionValueEvent(scrollYProgress, "change", (latest) => {
if (latest > 0.5) {
setExpanded(true);
setHeaderColorScheme("Light");
} else {
setExpanded(false);
setHeaderColorScheme("Dark");
}
});
const videoTop = useTransform(scrollYProgress, [0.2, 0.5], [13.567, 0]);
const videoLeft = useTransform(scrollYProgress, [0.2, 0.5], [26.181, 0]);
const videoWidth = useTransform(scrollYProgress, [0.2, 0.5], [47.139, 100]);
const videoHeight = useTransform(scrollYProgress, [0.2, 0.5], [43.125, 100]);
const videoTopVw = useTransform(videoTop, (v) => `${v}vw`);
const videoLeftVw = useTransform(videoLeft, (v) => `${v}vw`);
const videoWidthVw = useTransform(videoWidth, (v) => `${v}vw`);
const videoHeightVh = useTransform(videoHeight, (v) => `${v}vh`);
return (
<Section
id="compromises"
headerColorScheme="Dark"
className="!h-[400vh] relative !px-0 !pb-0"
ref={ref}
>
<h2 className="text-[5.556vw] lg:px-[calc(11.042vw-1.667vw)] lg:mb-[2.778vw] leading-[1] w-full font-light flex flex-col gap-[0.556vw] select-none">
<span className="block">Дом для жизни</span>
<span className="block ml-auto">без компромиссов</span>
</h2>
<div className="sticky lg:top-[0vw] left-0 w-full h-[100vh] overflow-hidden">
<img
src="/img/compromises/1.png"
className="absolute
lg:left-[26.181vw] lg:top-0 lg:w-[18.819vw] lg:aspect-[271/176]
"
alt=""
/>
<img
src="/img/compromises/2.png"
className="absolute
lg:left-[1.667vw] lg:top-[8.472vw] lg:w-[23.125vw] lg:aspect-[333/268]
"
alt=""
/>
<motion.div
style={{
top: videoTopVw,
left: videoLeftVw,
width: videoWidthVw,
height: videoHeightVh,
}}
className=" absolute z-[2]"
>
<VideoPlayer expanded={expanded} />
</motion.div>
<img
src="/img/compromises/4.png"
className="absolute
lg:top-[35.514vw] lg:left-[43.253vw] lg:h-[15.347vw]
"
alt=""
/>
<img
src="/img/compromises/3.png"
className="absolute
lg:top-[23.083vw] lg:left-[74.708vw] lg:w-[23.125vw] lg:aspect-[333/320]
"
alt=""
/>
</div>
</Section>
);
}
+6 -6
View File
@@ -1,14 +1,14 @@
/* eslint-disable @next/next/no-img-element */
import React from "react";
import Section from "../ui/Section";
export default function Hero() {
return (
<section
<Section
id="hero"
className="relative w-full h-screen bg-[url('/img/bg/sky.png')] bg-cover bg-center
lg:px-[1.667vw] lg:pt-[7.986vw]"
className="bg-[url('/img/bg/sky.png')]"
headerColorScheme="Light"
>
<h1 className="text-[7.778vw] leading-[0.7] w-full font-light flex flex-col gap-[0.556vw] select-none">
<h1 className="text-[7.778vw] leading-[0.7] w-full font-light flex flex-col gap-[0.556vw] select-none text-white">
<span className="block">Жизнь на высоте</span>
<span className="block ml-auto">с видом на залив</span>
</h1>
@@ -19,6 +19,6 @@ export default function Hero() {
draggable={false}
className="object-cover absolute bottom-0 left-1/2 -translate-x-1/2 w-[75%] select-none"
/>
</section>
</Section>
);
}
+198
View File
@@ -0,0 +1,198 @@
/* eslint-disable @next/next/no-img-element */
import React from "react";
import Section from "../ui/Section";
import {
motion,
useMotionValueEvent,
useScroll,
useTransform,
} from "motion/react";
import { useRef, useState } from "react";
import { useAppStateStore } from "@/stores/useAppStateStore";
import ButtonRound from "../ui/Buttons/ButtonRound";
import Tour3D from "../icons/Tour3D";
export default function LifeAndWork() {
const ref = useRef<HTMLDivElement>(null);
const [titleColor, setTitleColor] = useState<"White" | "Black">("White");
const { setHeaderColorScheme } = useAppStateStore();
const [expanded, setExpanded] = useState(true);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end end"],
});
const videoTop = useTransform(scrollYProgress, [0.2, 1], [0, 23.333]);
const videoLeft = useTransform(scrollYProgress, [0.2, 1], [0, 9.861]);
const videoWidth = useTransform(scrollYProgress, [0.2, 1], [100, 47.639]);
const videoHeight = useTransform(scrollYProgress, [0.2, 1], [100, 59.375]);
const videoTopVw = useTransform(videoTop, (v) => `${v}vw`);
const videoLeftVw = useTransform(videoLeft, (v) => `${v}vw`);
const videoWidthVw = useTransform(videoWidth, (v) => `${v}vw`);
const videoHeightVh = useTransform(videoHeight, (v) => `${v}vh`);
useMotionValueEvent(scrollYProgress, "change", (latest) => {
if (latest < 0.3) {
setHeaderColorScheme("Light");
} else {
setHeaderColorScheme("Dark");
}
if (latest < 0.6) {
setTitleColor("White");
setExpanded(true);
} else {
setTitleColor("Black");
setExpanded(false);
}
});
return (
<Section
id="life-and-work"
headerColorScheme="Dark"
className="!px-0 !pt-0"
>
<div ref={ref} className="!h-[200vh] relative">
<div className="sticky top-0 left-0 w-full h-[120dvh] overflow-hidden lg:pt-[6.778vw]">
<h2
style={{ color: titleColor === "White" ? "#FFFFFF" : "#262626" }}
className="text-[5.556vw] lg:px-[calc(11.111vw)] leading-[1] w-full font-light flex flex-col gap-[0.556vw] select-none relative transition-colors duration-300 ease-in-out z-[3]"
>
<span className="block">Жизнь, работа и отдых</span>
<span className="block ml-auto">под одной крышей</span>
</h2>
<motion.div
style={{
top: videoTopVw,
left: videoLeftVw,
width: videoWidthVw,
height: videoHeightVh,
}}
className="absolute bottom-0 left-[58.889vw] z-[2]"
>
<img src="/img/lifeAndWork/1.png" alt="" className="size-full " />
<ButtonRound
className={`z-[3] !absolute bottom-[0.833vw] left-[0.833vw] transition-opacity duration-300 ease-in-out ${
expanded ? "opacity-0" : "opacity-100"
}`}
type="White"
text={"Исследовать\nпространство"}
icon={<Tour3D />}
onClick={() => {}}
/>
</motion.div>
<p className="title-s absolute bottom-[5.2vw] left-[58.889vw] text-[#242424]/40">
<span className="text-[#262626]">Зона тихого отдыха:</span>{" "}
уединённые <br />
скамьи среди зелени, мягкий <br />
свет и шум листвы
</p>
</div>
</div>
<div className=" flex flex-col">
<div className="ml-auto mr-[9.861vw] flex items-center gap-[5.347vw] mb-[5vw]">
<p className="title-s text-[#242424]/40">
<span className="text-[#262626]">Зона тихого отдыха:</span>{" "}
уединённые <br />
скамьи среди зелени, мягкий <br />
свет и шум листвы
</p>
<div className="relative">
<img
src="/img/lifeAndWork/2.png"
alt=""
className="lg:w-[39.444vw] aspect-square z-[2]"
/>
<ButtonRound
className={`z-[3] !absolute bottom-[0.833vw] left-[0.833vw]`}
type="White"
text={"Исследовать\nпространство"}
icon={<Tour3D />}
onClick={() => {}}
/>
</div>
</div>
<div className="ml-[1.667vw] flex items-start gap-[1.389vw]">
<div className="relative">
<img
src="/img/lifeAndWork/3.png"
alt=""
className="lg:w-[23.125vw] z-[2]"
/>
<ButtonRound
className={`z-[3] !absolute bottom-[0.833vw] left-[0.833vw]`}
type="White"
text={"Исследовать\nпространство"}
icon={<Tour3D />}
onClick={() => {}}
/>
</div>
<p className="title-s text-[#242424]/40">
<span className="text-[#262626]">Зона тихого отдыха:</span>{" "}
уединённые <br />
скамьи среди зелени, мягкий <br />
свет и шум листвы
</p>
</div>
<div className="flex justify-end gap-[1.389vw] mr-[1.667vw] -mt-[15vw]">
<p className="title-s text-[#242424]/40">
<span className="text-[#262626]">Зона тихого отдыха:</span>{" "}
уединённые <br />
скамьи среди зелени, мягкий <br />
свет и шум листвы
</p>
<div className="relative">
<img
src="/img/lifeAndWork/4.png"
alt=""
className="lg:w-[39.444vw] z-[2]"
/>
<ButtonRound
className={`z-[3] !absolute bottom-[0.833vw] left-[0.833vw]`}
type="White"
text={"Исследовать\nпространство"}
icon={<Tour3D />}
onClick={() => {}}
/>
</div>
</div>
<div className="flex items-center gap-[1.389vw] ml-[1.667vw] mt-[5vw]">
<div className="relative">
<img
src="/img/lifeAndWork/5.png"
alt=""
className="lg:w-[47.639vw] z-[2]"
/>
<ButtonRound
className={`z-[3] !absolute bottom-[0.833vw] left-[0.833vw]`}
type="White"
text={"Исследовать\nпространство"}
icon={<Tour3D />}
onClick={() => {}}
/>
</div>
<p className="title-s text-[#242424]/40">
<span className="text-[#262626]">Зона тихого отдыха:</span>{" "}
уединённые <br />
скамьи среди зелени, мягкий <br />
свет и шум листвы
</p>
</div>
</div>
</Section>
);
}
+61
View File
@@ -0,0 +1,61 @@
import React from "react";
import BookSlider from "../ui/BookSlider";
import Section from "../ui/Section";
export default function Location() {
return (
<Section
id="architecture"
className="bg-[url('/img/bg/building2.png')] overflow-hidden"
headerColorScheme="Light"
>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 lg:w-[55.556vw] lg:h-[33.889vw]">
<BookSlider>
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">{"(1) Локации"}</div>
<div className="text-black block font-light text-m mt-[25.431vw] ">
<span className="font-medium">Чебоксарский залив:</span> центр
притяжения города и естественное продолжение вашего двора. Место
для утренних пробежек вдоль воды или тихих вечерних прогулок под
шум прибоя
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage2.png')] bg-cover bg-center" />
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">{"(1) Локации"}</div>
<div className="text-black block font-light text-m mt-[25.431vw] ">
<span className="font-medium">Чебоксарский залив:</span> центр
притяжения города и естественное продолжение вашего двора. Место
для утренних пробежек вдоль воды или тихих вечерних прогулок под
шум прибоя
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage2.png')] bg-cover bg-center" />
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">{"(1) Локации"}</div>
<div className="text-black block font-light text-m mt-[25.431vw] ">
<span className="font-medium">Чебоксарский залив:</span> центр
притяжения города и естественное продолжение вашего двора. Место
для утренних пробежек вдоль воды или тихих вечерних прогулок под
шум прибоя
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage2.png')] bg-cover bg-center" />
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">{"(1) Локации"}</div>
<div className="text-black block font-light text-m mt-[25.431vw] ">
<span className="font-medium">Чебоксарский залив:</span> центр
притяжения города и естественное продолжение вашего двора. Место
для утренних пробежек вдоль воды или тихих вечерних прогулок под
шум прибоя
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage2.png')] bg-cover bg-center" />
</BookSlider>
</div>
</Section>
);
}
+10
View File
@@ -0,0 +1,10 @@
import Map from "@/components/map/Map";
import Section from "../ui/Section";
export default function MapPage() {
return (
<Section id="map" headerColorScheme="Dark" className="h-screen !p-0">
<Map />
</Section>
);
}
+5 -5
View File
@@ -1,12 +1,12 @@
import React from "react";
import BookSlider from "../ui/BookSlider";
import Section from "../ui/Section";
export default function Overview() {
return (
<section
<Section
id="overview"
className="relative w-full h-screen bg-[url('/img/bg/building.png')] bg-cover bg-center
lg:px-[1.667vw]"
className=" bg-[url('/img/bg/building.png')] overflow-hidden"
headerColorScheme="Light"
>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 lg:w-[55.556vw] lg:h-[33.889vw]">
<BookSlider>
@@ -56,6 +56,6 @@ export default function Overview() {
<div className="bg-[url('/img/mocks/bookPage.png')] bg-cover bg-center" />
</BookSlider>
</div>
</section>
</Section>
);
}
+4 -7
View File
@@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import { useEffect, useRef } from "react";
import { useAppStateStore } from "@/stores/useAppStateStore";
import Section from "../ui/Section";
export default function Premiere() {
const ref = useRef<HTMLDivElement>(null);
@@ -23,14 +24,10 @@ export default function Premiere() {
}, [setHeaderColorScheme]);
return (
<section
id="premiere"
className="relative w-full min-h-screen text-black bg-white bg-cover bg-center
lg:px-[1.667vw] lg:pt-[7.778vw] lg:pb-[9.444vw]"
>
<Section id="premiere" headerColorScheme="Dark">
<div
ref={ref}
className="absolute top-[95dvh] left-0 w-full h-[calc(100%-95dvh)]"
className="absolute top-[95dvh] left-0 w-full h-[calc(100%-100dvh)]"
/>
<h2 className="text-[5.556vw] lg:px-[calc(7.153vw-1.667vw)] leading-[1] w-full font-light flex flex-col gap-[0.556vw] select-none">
@@ -95,6 +92,6 @@ export default function Premiere() {
<span className="lg:title-m text-[#262626]/25 ">высота потолков</span>
</div>
</div>
</section>
</Section>
);
}
+35
View File
@@ -0,0 +1,35 @@
/* eslint-disable @next/next/no-img-element */
import React, { useEffect, useRef } from "react";
import Button from "../ui/Buttons/Button";
import Section from "../ui/Section";
export default function ResidentialForm() {
return (
<Section
id="premiere"
headerColorScheme="Dark"
className={"h-screen flex !p-0"}
>
<img
src="/img/residential/1.png"
alt="Residential Form"
className="h-full object-cover"
/>
<div className="flex-1 flex flex-col justify-center items-center">
<div>
<img
src="/img/residential/2.png"
alt="Residential Form"
className="lg:w-[15vw] lg:mb-[2.5vw] object-cover"
/>
<p className="title-m lg:mb-[1.111vw]">
Хотите стать резидентом <br /> клубного дома у воды?
</p>
<Button className="" onClick={() => {}} type="Primary" size="S">
Оставить заявку
</Button>
</div>
</div>
</Section>
);
}
+61
View File
@@ -0,0 +1,61 @@
import BookSlider from "../ui/BookSlider";
import Section from "../ui/Section";
export default function Residents() {
return (
<Section
id="residents"
headerColorScheme="Light"
className="bg-[url('/img/bg/building.png')] bg-cover bg-center"
>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 lg:w-[55.556vw] lg:h-[33.889vw]">
<BookSlider>
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">
{"(3) Пространства резидентов"}
</div>
<div className="text-black block text-m mt-[24.131vw] ">
Приватный фитнес-клуб с панорамным видом и премиальным оснащением.
Интеллектуальное пространство для тех, кто привык быть в форме и
ценит абсолютную приватность каждой тренировки.
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage3.png')] bg-cover bg-center" />
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">
{"(3) Пространства резидентов"}
</div>
<div className="text-black block text-m mt-[24.131vw] ">
Приватный фитнес-клуб с панорамным видом и премиальным оснащением.
Интеллектуальное пространство для тех, кто привык быть в форме и
ценит абсолютную приватность каждой тренировки.
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage3.png')] bg-cover bg-center" />
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">
{"(3) Пространства резидентов"}
</div>
<div className="text-black block text-m mt-[24.131vw] ">
Приватный фитнес-клуб с панорамным видом и премиальным оснащением.
Интеллектуальное пространство для тех, кто привык быть в форме и
ценит абсолютную приватность каждой тренировки.
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage3.png')] bg-cover bg-center" />
<div className="bg-white p-[1.111vw] flex flex-col">
<div className="text-black caption-s">
{"(3) Пространства резидентов"}
</div>
<div className="text-black block text-m mt-[24.131vw] ">
Приватный фитнес-клуб с панорамным видом и премиальным оснащением.
Интеллектуальное пространство для тех, кто привык быть в форме и
ценит абсолютную приватность каждой тренировки.
</div>
</div>
<div className="bg-[url('/img/mocks/bookPage3.png')] bg-cover bg-center" />
</BookSlider>
</div>
</Section>
);
}
+11 -125
View File
@@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef, useState } from "react";
import Arrow from "@/components/icons/Arrow";
import clsx from "clsx";
import { motion, AnimatePresence } from "motion/react";
import HTMLFlipBook from "react-pageflip";
import CursorButtonWrapper from "./CursorButtonWrapper";
const FLIPPING_TIME = 500;
@@ -12,33 +10,11 @@ export default function BookSlider({
}: {
children: React.ReactNode;
}) {
const [isHovered, setIsHovered] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
const [cursorOnRightSide, setCursorOnRightSide] = useState(false);
const [bookWidth, setBookWidth] = useState(0);
const [bookHeight, setBookHeight] = useState(0);
const flipBookRef = useRef(null);
const sliderRef = useRef<HTMLDivElement>(null);
const rectRef = useRef<DOMRect | null>(null);
const frameRef = useRef<number | null>(null);
const isFlipping = useRef(false);
const lastMouseEvent = useRef<MouseEvent | null>(null);
const handleMouseEnter = () => {
setIsHovered(true);
if (sliderRef.current) {
rectRef.current = sliderRef.current.getBoundingClientRect();
}
};
const handleMouseLeave = () => {
setIsHovered(false);
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
useEffect(() => {
if (sliderRef.current) {
@@ -47,56 +23,13 @@ export default function BookSlider({
}
}, []);
useEffect(() => {
const handleMove = (e: MouseEvent) => {
lastMouseEvent.current = e;
if (!rectRef.current) return;
const x = e.clientX - rectRef.current.left;
const y = e.clientY - rectRef.current.top;
const inside =
e.clientX >= rectRef.current.left &&
e.clientX <= rectRef.current.right &&
e.clientY >= rectRef.current.top &&
e.clientY <= rectRef.current.bottom;
if (!inside) {
setIsHovered(false);
return;
}
setIsHovered(true);
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
setCursorPosition({ x, y });
setCursorOnRightSide(x > rectRef.current!.width / 2);
frameRef.current = null;
});
};
const handleScroll = () => {
if (!sliderRef.current || !lastMouseEvent.current) return;
rectRef.current = sliderRef.current.getBoundingClientRect();
handleMove(lastMouseEvent.current);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("scroll", handleScroll, true);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("scroll", handleScroll, true);
};
}, []);
const handleClick = () => {
const runFlip = (direction: "next" | "prev") => {
if (flipBookRef.current && !isFlipping.current) {
isFlipping.current = true;
if (cursorOnRightSide) (flipBookRef.current as any).pageFlip().flipNext();
if (direction === "next") (flipBookRef.current as any).pageFlip().flipNext();
else (flipBookRef.current as any).pageFlip().flipPrev();
setTimeout(() => {
isFlipping.current = false;
}, FLIPPING_TIME);
@@ -104,23 +37,12 @@ export default function BookSlider({
};
return (
<div
ref={sliderRef}
className="w-full relative h-full hover:cursor-none"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
<CursorButtonWrapper
wrapperRef={sliderRef}
className="w-full h-full"
onRightClick={() => runFlip("next")}
onLeftClick={() => runFlip("prev")}
>
<AnimatePresence mode="wait">
{isHovered && (
<CursorButton
x={cursorPosition.x}
y={cursorPosition.y}
rightSide={cursorOnRightSide}
/>
)}
</AnimatePresence>
{/* @ts-expect-error Почему то хочет чтобы были вообще все пропсы (даже необязательные), хотя работает только и без них */}
<HTMLFlipBook
key={`${bookWidth}-${bookHeight}`}
@@ -136,42 +58,6 @@ export default function BookSlider({
>
{children}
</HTMLFlipBook>
</div>
);
}
function CursorButton({
x,
y,
rightSide,
}: {
x: number;
y: number;
rightSide: boolean;
}) {
const [width, setWidth] = useState(0);
return (
<motion.div
ref={(el) => {
if (el) setWidth(el.getBoundingClientRect().width);
}}
style={{
transform: `translate3d(${x - width / 2}px, ${y - width / 2}px, 0)`,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="absolute z-10 lg:size-[6.944vw] border border-[#FFFFFF]/25 backdrop-blur-[16px] flex items-center justify-center rounded-full bg-[#262626]/25 pointer-events-none"
>
<div
className={clsx(
"lg:size-[1.736vw] transition-transform duration-300 ease-out",
rightSide ? "rotate-180" : ""
)}
>
<Arrow direction="left" />
</div>
</motion.div>
</CursorButtonWrapper>
);
}
+1 -1
View File
@@ -33,7 +33,7 @@ export default function Button({
type === "Primary" &&
(disabled
? "bg-[#262626]/6 text-[#262626]/6 backdrop-blur-[16px]"
: "bg-[#262626]/6 text-[#262626] hover:bg-[#262626]/25 active:bg-[#262626] active:text-white"),
: "bg-[#262626]/6 text-[#262626] hover:bg-[#262626]/25 active:bg-[#262626] active:text-white bacdrop-blur-[16px]"),
type === "PrimaryInverse" &&
(disabled
? "bg-[#FFFFFF]/20 text-[#262626]/6 backdrop-blur-[16px]"
+1 -1
View File
@@ -42,7 +42,7 @@ export default function ButtonRound({
}`}
></div>
<p className="button-s">{text}</p>
<p className="button-s whitespace-pre-wrap text-start">{text}</p>
</button>
);
}
+182
View File
@@ -0,0 +1,182 @@
import clsx from "clsx";
import { AnimatePresence, motion } from "motion/react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import Arrow from "../icons/Arrow";
type CursorButtonWrapperProps = {
children: React.ReactNode;
className?: string;
showCursor?: boolean;
onWrapperClick?: (rightSide: boolean) => void;
onRightClick?: () => void;
onLeftClick?: () => void;
onHoverChange?: (hovered: boolean) => void;
wrapperRef?: React.RefObject<HTMLDivElement | null>;
renderCursor?: (params: {
x: number;
y: number;
rightSide: boolean;
}) => React.ReactNode;
};
export default function CursorButtonWrapper({
children,
className,
showCursor = true,
onWrapperClick,
onRightClick,
onLeftClick,
onHoverChange,
wrapperRef,
renderCursor,
}: CursorButtonWrapperProps) {
const [isHovered, setIsHovered] = useState(false);
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
const [cursorOnRightSide, setCursorOnRightSide] = useState(false);
const localWrapperRef = useRef<HTMLDivElement>(null);
const rectRef = useRef<DOMRect | null>(null);
const frameRef = useRef<number | null>(null);
const lastMouseEvent = useRef<MouseEvent | null>(null);
const targetRef = wrapperRef ?? localWrapperRef;
const setHovered = useCallback((value: boolean) => {
setIsHovered(value);
onHoverChange?.(value);
}, [onHoverChange]);
const handleMouseEnter = () => {
setHovered(true);
if (targetRef.current) {
rectRef.current = targetRef.current.getBoundingClientRect();
}
};
const handleMouseLeave = () => {
setHovered(false);
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
useEffect(() => {
const handleMove = (e: MouseEvent) => {
lastMouseEvent.current = e;
if (!rectRef.current) return;
const x = e.clientX - rectRef.current.left;
const y = e.clientY - rectRef.current.top;
const inside =
e.clientX >= rectRef.current.left &&
e.clientX <= rectRef.current.right &&
e.clientY >= rectRef.current.top &&
e.clientY <= rectRef.current.bottom;
if (!inside) {
setHovered(false);
return;
}
setHovered(true);
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
if (!rectRef.current) return;
const rightSide = x > rectRef.current.width / 2;
setCursorPosition({ x, y });
setCursorOnRightSide(rightSide);
frameRef.current = null;
});
};
const handleScroll = () => {
if (!targetRef.current || !lastMouseEvent.current) return;
rectRef.current = targetRef.current.getBoundingClientRect();
handleMove(lastMouseEvent.current);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("scroll", handleScroll, true);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("scroll", handleScroll, true);
};
}, [setHovered, targetRef]);
const handleClick = () => {
if (cursorOnRightSide) {
onRightClick?.();
} else {
onLeftClick?.();
}
onWrapperClick?.(cursorOnRightSide);
};
return (
<div
ref={targetRef}
className={clsx("relative hover:cursor-none", className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
>
<AnimatePresence mode="wait">
{showCursor &&
isHovered &&
(renderCursor ? (
renderCursor({
x: cursorPosition.x,
y: cursorPosition.y,
rightSide: cursorOnRightSide,
})
) : (
<CursorButton
x={cursorPosition.x}
y={cursorPosition.y}
rightSide={cursorOnRightSide}
/>
))}
</AnimatePresence>
{children}
</div>
);
}
function CursorButton({
x,
y,
rightSide,
}: {
x: number;
y: number;
rightSide: boolean;
}) {
const [width, setWidth] = useState(0);
return (
<motion.div
ref={(el) => {
if (el) setWidth(el.getBoundingClientRect().width);
}}
style={{
transform: `translate3d(${x - width / 2}px, ${y - width / 2}px, 0)`,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="absolute z-10 lg:size-[6.944vw] border border-[#FFFFFF]/25 backdrop-blur-[16px] flex items-center justify-center rounded-full bg-[#262626]/25 pointer-events-none"
>
<div
className={clsx(
"lg:size-[1.736vw] transition-transform duration-300 ease-out text-white",
rightSide ? "rotate-180" : ""
)}
>
<Arrow direction="left" />
</div>
</motion.div>
);
}
+3 -3
View File
@@ -34,7 +34,7 @@ export default function Header() {
>
<div
className={clsx(
"w-full lg:h-[0.069vw] bg-white",
"w-full lg:h-[0.069vw] ",
headerColorScheme === "Light" || menuOpen
? "bg-white"
: "bg-[#262626]"
@@ -42,7 +42,7 @@ export default function Header() {
/>
<div
className={clsx(
"w-full lg:h-[0.069vw] bg-white",
"w-full lg:h-[0.069vw] ",
headerColorScheme === "Light" || menuOpen
? "bg-white"
: "bg-[#262626]"
@@ -50,7 +50,7 @@ export default function Header() {
/>
<div
className={clsx(
"w-full lg:h-[0.069vw] bg-white",
"w-full lg:h-[0.069vw] ",
headerColorScheme === "Light" || menuOpen
? "bg-white"
: "bg-[#262626]"
+65
View File
@@ -0,0 +1,65 @@
import React, { useState } from "react";
import Add from "../icons/Add";
import clsx from "clsx";
import { AnimatePresence, motion } from "motion/react";
export default function Hint({
title,
subtitle,
direction = "left",
openByDefault = false,
}: {
title: string;
subtitle: string;
direction?: "left" | "right";
openByDefault?: boolean;
}) {
const [open, setOpen] = useState(openByDefault);
const [buttonSize, setButtonSize] = useState<number | null>(null);
return (
<button
ref={(el) => {
if (el) {
setButtonSize(el.getBoundingClientRect().width);
}
}}
onClick={() => setOpen(!open)}
className="w-full aspect-square rounded-full bg-[#FFFFFF]/40 backdrop-blur-[16px] relative flex items-center justify-center"
>
<div
className={clsx(
"w-[55%] aspect-square rounded-full bg-white hover:opacity-90 text-[#262626]/60 p-[10%] transition-transform duration-300 z-[2]",
open && "rotate-45"
)}
>
<Add />
</div>
<AnimatePresence mode="wait">
{open && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
style={{
left:
direction === "left" ? `${(buttonSize || 0) / 2}px` : "auto",
right:
direction === "right" ? `${(buttonSize || 0) / 2}px` : "auto",
bottom: `${(buttonSize || 0) / 2}px`,
}}
className="lg:p-[1.111vw] lg:rounded-[0.278vw] bg-[#DBDBDB] absolute right-0 bottom-0 z-[1] text-start "
>
<p className="title-s whitespace-nowrap text-[#333333] mb-[0.556vw]">
{title}
</p>
<p className="text-xs text-[#333333]/60 whitespace-pre-line">
{subtitle}
</p>
</motion.div>
)}
</AnimatePresence>
</button>
);
}
+57
View File
@@ -0,0 +1,57 @@
import clsx from "clsx";
import React, { useEffect, useRef } from "react";
import { useAppStateStore } from "@/stores/useAppStateStore";
interface SectionProps {
id: string;
ref?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
className?: string;
headerColorScheme: "Light" | "Dark";
}
export default function Section({
ref,
children,
className,
id,
headerColorScheme,
}: SectionProps) {
const setHeaderColorScheme = useAppStateStore(
(state) => state.setHeaderColorScheme
);
const anchorRef = useRef<HTMLDivElement | null>(null);
const baseClassName = `
relative w-full min-h-screen text-black bg-white bg-cover bg-center
lg:px-[1.667vw] lg:pt-[7.778vw] lg:pb-[9.444vw]
`;
useEffect(() => {
const el = anchorRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setHeaderColorScheme(headerColorScheme);
}
});
observer.observe(el);
return () => observer.disconnect();
}, [setHeaderColorScheme]);
return (
<section
ref={ref ?? null}
id={id}
className={clsx(baseClassName, className)}
>
{/* Когда блок будет находиться почти полностью на экране (top=95dvh) тогда элемент появится и хедер поменяется */}
<div
ref={anchorRef}
className="absolute top-[95dvh] left-0 w-full h-[calc(100%-100dvh)]"
/>
{children}
</section>
);
}
+116
View File
@@ -0,0 +1,116 @@
import React, { useState } from "react";
import { VIDEO_SLIDER_CONTENT } from "@/data/videos";
import Arrow from "../icons/Arrow";
import { motion } from "motion/react";
import NumberFlow from "@number-flow/react";
import RoundButton from "./Buttons/ButtonRound";
import Tour3D from "../icons/Tour3D";
export default function VideoPlayer({
className,
expanded,
}: {
className?: string;
expanded?: boolean;
}) {
const videosCount = VIDEO_SLIDER_CONTENT.length;
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const handleNext = () => {
setCurrentVideoIndex((prev) => (prev + 1) % videosCount);
};
const handlePrevious = () => {
setCurrentVideoIndex((prev) => (prev - 1 + videosCount) % videosCount);
};
return (
<div className="w-full h-full relative">
<video
src={VIDEO_SLIDER_CONTENT[currentVideoIndex].video}
poster={VIDEO_SLIDER_CONTENT[currentVideoIndex].video}
playsInline
autoPlay
muted
loop
className="w-full h-full object-cover"
/>
{/* Controls */}
<motion.div
className="absolute left-1/2 bottom-0 -translate-x-1/2 flex items-center justify-between lg:gap-[0.833vw]"
animate={{
width: expanded ? "65.125vw" : "35.833vw",
bottom: expanded ? "8.333vw" : "2.222vw",
}}
>
{/* Title */}
<motion.p
animate={{
fontSize: expanded ? "2.778vw" : "1.667vw",
}}
className="title-l font-light text-white whitespace-pre-wrap"
>
{VIDEO_SLIDER_CONTENT[currentVideoIndex].title}
</motion.p>
{/* Buttons */}
<motion.div className="absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 flex items-center lg:gap-[0.833vw]">
<motion.button
onClick={handlePrevious}
animate={{
width: expanded ? "5vw" : "3.056vw",
}}
className="aspect-square bg-white hover:bg-white/90 active:bg-white/60 rounded-full flex items-center justify-center"
>
<motion.div
animate={{
width: expanded ? "1.944vw" : "1.181vw",
}}
className="aspect-square"
>
<Arrow direction="left" />
</motion.div>
</motion.button>
<motion.button
onClick={handleNext}
animate={{
width: expanded ? "5vw" : "3.056vw",
}}
className="aspect-square bg-white hover:bg-white/90 active:bg-white/60 rounded-full flex items-center justify-center"
>
<motion.div
animate={{
width: expanded ? "1.944vw" : "1.181vw",
}}
className="aspect-square"
>
<Arrow direction="right" />
</motion.div>
</motion.button>
</motion.div>
{/* Index */}
<motion.p
animate={{
fontSize: expanded ? "2.778vw" : "1.667vw",
}}
className="text-m text-white font-light"
>
<NumberFlow value={currentVideoIndex + 1} /> / {videosCount}
</motion.p>
{expanded && (
<RoundButton
className="!absolute !-bottom-[5vw] !left-[2vw]"
type="White"
text={"Исследовать\nпространство"}
icon={<Tour3D />}
onClick={() => {}}
/>
)}
</motion.div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { VideoPlayerContentItem } from "@/types";
export const VIDEO_SLIDER_CONTENT: VideoPlayerContentItem[] = [
{
title: "Прогулочный\n бульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
},
{
title: "Прогулочный\nбульвар",
video: "/video/mock.png"
}
]
+16 -2
View File
@@ -11,9 +11,23 @@ interface AppStateStore {
export const useAppStateStore = create<AppStateStore>((set) => ({
isLoading: true,
setIsLoading: (isLoading) => set({ isLoading }),
setIsLoading: (isLoading) => {
set({ isLoading });
if (isLoading) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
},
menuOpen: false,
setMenuOpen: (menuOpen) => set({ menuOpen }),
setMenuOpen: (menuOpen) => {
if (menuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
set({ menuOpen });
},
headerColorScheme: "Light",
setHeaderColorScheme: (headerColorScheme) => set({ headerColorScheme }),
}));
+4
View File
@@ -0,0 +1,4 @@
export interface VideoPlayerContentItem {
title: string;
video: string;
}
+8 -1
View File
@@ -18,8 +18,15 @@
"name": "next"
}
],
"typeRoots": [
"./node_modules/@types",
"./node_modules/@yandex/ymaps3-types"
],
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"ymaps3": [
"./node_modules/@yandex/ymaps3-types"
]
}
},
"include": [