diff --git a/bun.lock b/bun.lock index 6493158..03b4a3a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package-lock.json b/package-lock.json index f9bc3fb..416d4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index ac5023e..1f6eb5c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/img/bg/building2.png b/public/img/bg/building2.png new file mode 100644 index 0000000..2ed2ebe Binary files /dev/null and b/public/img/bg/building2.png differ diff --git a/public/img/collection/1.png b/public/img/collection/1.png new file mode 100644 index 0000000..52529a3 Binary files /dev/null and b/public/img/collection/1.png differ diff --git a/public/img/collection/2.png b/public/img/collection/2.png new file mode 100644 index 0000000..06926ed Binary files /dev/null and b/public/img/collection/2.png differ diff --git a/public/img/collection/3.png b/public/img/collection/3.png new file mode 100644 index 0000000..5513ab4 Binary files /dev/null and b/public/img/collection/3.png differ diff --git a/public/img/collection/4.png b/public/img/collection/4.png new file mode 100644 index 0000000..1cbb8a2 Binary files /dev/null and b/public/img/collection/4.png differ diff --git a/public/img/collection/5.png b/public/img/collection/5.png new file mode 100644 index 0000000..53d1ec6 Binary files /dev/null and b/public/img/collection/5.png differ diff --git a/public/img/compromises/1.png b/public/img/compromises/1.png new file mode 100644 index 0000000..c448ed3 Binary files /dev/null and b/public/img/compromises/1.png differ diff --git a/public/img/compromises/2.png b/public/img/compromises/2.png new file mode 100644 index 0000000..2f58403 Binary files /dev/null and b/public/img/compromises/2.png differ diff --git a/public/img/compromises/3.png b/public/img/compromises/3.png new file mode 100644 index 0000000..6769f16 Binary files /dev/null and b/public/img/compromises/3.png differ diff --git a/public/img/compromises/4.png b/public/img/compromises/4.png new file mode 100644 index 0000000..74f56fb Binary files /dev/null and b/public/img/compromises/4.png differ diff --git a/public/img/lifeAndWork/1.png b/public/img/lifeAndWork/1.png new file mode 100644 index 0000000..7b8451f Binary files /dev/null and b/public/img/lifeAndWork/1.png differ diff --git a/public/img/lifeAndWork/2.png b/public/img/lifeAndWork/2.png new file mode 100644 index 0000000..21566c7 Binary files /dev/null and b/public/img/lifeAndWork/2.png differ diff --git a/public/img/lifeAndWork/3.png b/public/img/lifeAndWork/3.png new file mode 100644 index 0000000..9b132f7 Binary files /dev/null and b/public/img/lifeAndWork/3.png differ diff --git a/public/img/lifeAndWork/4.png b/public/img/lifeAndWork/4.png new file mode 100644 index 0000000..53d1ec6 Binary files /dev/null and b/public/img/lifeAndWork/4.png differ diff --git a/public/img/lifeAndWork/5.png b/public/img/lifeAndWork/5.png new file mode 100644 index 0000000..3a977c2 Binary files /dev/null and b/public/img/lifeAndWork/5.png differ diff --git a/public/img/mocks/bookPage2.png b/public/img/mocks/bookPage2.png new file mode 100644 index 0000000..5991aa6 Binary files /dev/null and b/public/img/mocks/bookPage2.png differ diff --git a/public/img/mocks/bookPage3.png b/public/img/mocks/bookPage3.png new file mode 100644 index 0000000..af62ab8 Binary files /dev/null and b/public/img/mocks/bookPage3.png differ diff --git a/public/img/mocks/building2.png b/public/img/mocks/building2.png new file mode 100644 index 0000000..02c2454 Binary files /dev/null and b/public/img/mocks/building2.png differ diff --git a/public/img/residential/1.png b/public/img/residential/1.png new file mode 100644 index 0000000..2b31f50 Binary files /dev/null and b/public/img/residential/1.png differ diff --git a/public/img/residential/2.png b/public/img/residential/2.png new file mode 100644 index 0000000..c2d054e Binary files /dev/null and b/public/img/residential/2.png differ diff --git a/public/video/mock.png b/public/video/mock.png new file mode 100644 index 0000000..28892e8 Binary files /dev/null and b/public/video/mock.png differ diff --git a/src/app/globals.css b/src/app/globals.css index 5ef4896..b035358 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 381ecc9..1a1fc9e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,7 +20,7 @@ export default function RootLayout({ return ( {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9e7da8f..d401f25 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
+
{/* Основной UI */} @@ -22,6 +30,14 @@ export default function Root() { + + + + + + + +
); } diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx new file mode 100644 index 0000000..72e18b0 --- /dev/null +++ b/src/components/map/Map.tsx @@ -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(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 ( + + + + + + ); +} diff --git a/src/components/pages/Architecture.tsx b/src/components/pages/Architecture.tsx new file mode 100644 index 0000000..c42fb05 --- /dev/null +++ b/src/components/pages/Architecture.tsx @@ -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 ( +
+

+ + Архитектура, говорящая
на языке города +
+

+ +
+ +
+ +
+ +
+ + Architecture +
+ ); +} diff --git a/src/components/pages/Collection/AppartamentDescription.tsx b/src/components/pages/Collection/AppartamentDescription.tsx new file mode 100644 index 0000000..5cc9f43 --- /dev/null +++ b/src/components/pages/Collection/AppartamentDescription.tsx @@ -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 ( +
+ + + + {label} + + + {title} + + +
+ {tags.map((tag, index) => ( + + {tag} + + ))} +
+
+
+
+ ); +} diff --git a/src/components/pages/Collection/Collection.tsx b/src/components/pages/Collection/Collection.tsx new file mode 100644 index 0000000..5e5b164 --- /dev/null +++ b/src/components/pages/Collection/Collection.tsx @@ -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(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({ 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 ( +
+
+

Нет данных коллекции

+
+
+ ); + } + + return ( +
+

+ Коллекция авторских + резиденций +

+ +
+
+ + {isTinder && activeItem && ( + + + + {activeRecordIndex + 1}/{DECK_LENGTH} + + + )} + + + + {isTinder && activeItem && ( + + {}} + onShowVariantsClick={() => {}} + /> + + )} + + + {isTinder ? ( + <> + {[0, 1, 2, 3].map((index) => ( + + ))} + + ) : ( + <> + + + + + )} + + {isTinder && ( + handleSliderClick("left")} + onLeftClick={() => handleSliderClick("right")} + > +
+ + )} +
+
+
+ ); +} diff --git a/src/components/pages/Collection/CollectionCard.tsx b/src/components/pages/Collection/CollectionCard.tsx new file mode 100644 index 0000000..982b408 --- /dev/null +++ b/src/components/pages/Collection/CollectionCard.tsx @@ -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; + h: MotionValue | string; + l: MotionValue | string; + src: string; + className?: string; + zIndex?: number; +}) { + return ( + + + + ); +} diff --git a/src/components/pages/Collection/PlanDescription.tsx b/src/components/pages/Collection/PlanDescription.tsx new file mode 100644 index 0000000..1357c40 --- /dev/null +++ b/src/components/pages/Collection/PlanDescription.tsx @@ -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 ( +
+ + +
+ + } + /> + + + } + /> + +
+ + + + {description} + +
+
+
+ ); +} diff --git a/src/components/pages/Collection/TinderSlotCard.tsx b/src/components/pages/Collection/TinderSlotCard.tsx new file mode 100644 index 0000000..0edc067 --- /dev/null +++ b/src/components/pages/Collection/TinderSlotCard.tsx @@ -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(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 ( + + + + ); +} diff --git a/src/components/pages/Collection/data.ts b/src/components/pages/Collection/data.ts new file mode 100644 index 0000000..bb82878 --- /dev/null +++ b/src/components/pages/Collection/data.ts @@ -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]; + } +} diff --git a/src/components/pages/Collection/helpers.ts b/src/components/pages/Collection/helpers.ts new file mode 100644 index 0000000..f3451e5 --- /dev/null +++ b/src/components/pages/Collection/helpers.ts @@ -0,0 +1,5 @@ +export const ANIMATION_DURATION = 0.5; + +export type SwipeState = + | { phase: "idle" } + | { phase: "animating"; dir: "left" | "right" }; \ No newline at end of file diff --git a/src/components/pages/Collection/useCollectionScrollCardMotion.ts b/src/components/pages/Collection/useCollectionScrollCardMotion.ts new file mode 100644 index 0000000..6f01a82 --- /dev/null +++ b/src/components/pages/Collection/useCollectionScrollCardMotion.ts @@ -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; + widthVw: MotionValue; + heightVw: MotionValue; +}; + +function useCardScrollVw( + scrollYProgress: MotionValue, + 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, +): 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; +} diff --git a/src/components/pages/Compromises.tsx b/src/components/pages/Compromises.tsx new file mode 100644 index 0000000..4b46225 --- /dev/null +++ b/src/components/pages/Compromises.tsx @@ -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(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 ( +
+

+ Дом для жизни + без компромиссов +

+ +
+ + + + + + + + + +
+
+ ); +} diff --git a/src/components/pages/Hero.tsx b/src/components/pages/Hero.tsx index 7c2c4a2..64cf5ff 100644 --- a/src/components/pages/Hero.tsx +++ b/src/components/pages/Hero.tsx @@ -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 ( -
-

+

Жизнь на высоте с видом на залив

@@ -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" /> -
+ ); } diff --git a/src/components/pages/LifeAndWork.tsx b/src/components/pages/LifeAndWork.tsx new file mode 100644 index 0000000..7392fbb --- /dev/null +++ b/src/components/pages/LifeAndWork.tsx @@ -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(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 ( +
+
+
+

+ Жизнь, работа и отдых + под одной крышей +

+ + + + + } + onClick={() => {}} + /> + + +

+ Зона тихого отдыха:{" "} + уединённые
+ скамьи среди зелени, мягкий
+ свет и шум листвы +

+
+
+ +
+
+

+ Зона тихого отдыха:{" "} + уединённые
+ скамьи среди зелени, мягкий
+ свет и шум листвы +

+ +
+ + } + onClick={() => {}} + /> +
+
+ +
+
+ + } + onClick={() => {}} + /> +
+ +

+ Зона тихого отдыха:{" "} + уединённые
+ скамьи среди зелени, мягкий
+ свет и шум листвы +

+
+ +
+

+ Зона тихого отдыха:{" "} + уединённые
+ скамьи среди зелени, мягкий
+ свет и шум листвы +

+
+ + + } + onClick={() => {}} + /> +
+
+ +
+
+ + } + onClick={() => {}} + /> +
+ +

+ Зона тихого отдыха:{" "} + уединённые
+ скамьи среди зелени, мягкий
+ свет и шум листвы +

+
+
+
+ ); +} diff --git a/src/components/pages/Location.tsx b/src/components/pages/Location.tsx new file mode 100644 index 0000000..48382d3 --- /dev/null +++ b/src/components/pages/Location.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import BookSlider from "../ui/BookSlider"; +import Section from "../ui/Section"; + +export default function Location() { + return ( +
+
+ +
+
{"(1) Локации"}
+
+ Чебоксарский залив: центр + притяжения города и естественное продолжение вашего двора. Место + для утренних пробежек вдоль воды или тихих вечерних прогулок под + шум прибоя +
+
+
+ +
+
{"(1) Локации"}
+
+ Чебоксарский залив: центр + притяжения города и естественное продолжение вашего двора. Место + для утренних пробежек вдоль воды или тихих вечерних прогулок под + шум прибоя +
+
+
+ +
+
{"(1) Локации"}
+
+ Чебоксарский залив: центр + притяжения города и естественное продолжение вашего двора. Место + для утренних пробежек вдоль воды или тихих вечерних прогулок под + шум прибоя +
+
+
+ +
+
{"(1) Локации"}
+
+ Чебоксарский залив: центр + притяжения города и естественное продолжение вашего двора. Место + для утренних пробежек вдоль воды или тихих вечерних прогулок под + шум прибоя +
+
+
+ +
+
+ ); +} diff --git a/src/components/pages/MapPage.tsx b/src/components/pages/MapPage.tsx new file mode 100644 index 0000000..428d6a4 --- /dev/null +++ b/src/components/pages/MapPage.tsx @@ -0,0 +1,10 @@ +import Map from "@/components/map/Map"; +import Section from "../ui/Section"; + +export default function MapPage() { + return ( +
+ +
+ ); +} diff --git a/src/components/pages/Overview.tsx b/src/components/pages/Overview.tsx index 8891623..04dbe0a 100644 --- a/src/components/pages/Overview.tsx +++ b/src/components/pages/Overview.tsx @@ -1,12 +1,12 @@ -import React from "react"; import BookSlider from "../ui/BookSlider"; +import Section from "../ui/Section"; export default function Overview() { return ( -
@@ -56,6 +56,6 @@ export default function Overview() {
-
+ ); } diff --git a/src/components/pages/Premiere.tsx b/src/components/pages/Premiere.tsx index ff71385..93259a1 100644 --- a/src/components/pages/Premiere.tsx +++ b/src/components/pages/Premiere.tsx @@ -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(null); @@ -23,14 +24,10 @@ export default function Premiere() { }, [setHeaderColorScheme]); return ( -
+

@@ -95,6 +92,6 @@ export default function Premiere() { высота потолков

-
+
); } diff --git a/src/components/pages/ResidentialForm.tsx b/src/components/pages/ResidentialForm.tsx new file mode 100644 index 0000000..e0760e3 --- /dev/null +++ b/src/components/pages/ResidentialForm.tsx @@ -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 ( +
+ Residential Form +
+
+ Residential Form +

+ Хотите стать резидентом
клубного дома у воды? +

+ +
+
+
+ ); +} diff --git a/src/components/pages/Residents.tsx b/src/components/pages/Residents.tsx new file mode 100644 index 0000000..5c724a2 --- /dev/null +++ b/src/components/pages/Residents.tsx @@ -0,0 +1,61 @@ +import BookSlider from "../ui/BookSlider"; +import Section from "../ui/Section"; + +export default function Residents() { + return ( +
+
+ +
+
+ {"(3) Пространства резидентов"} +
+
+ Приватный фитнес-клуб с панорамным видом и премиальным оснащением. + Интеллектуальное пространство для тех, кто привык быть в форме и + ценит абсолютную приватность каждой тренировки. +
+
+
+
+
+ {"(3) Пространства резидентов"} +
+
+ Приватный фитнес-клуб с панорамным видом и премиальным оснащением. + Интеллектуальное пространство для тех, кто привык быть в форме и + ценит абсолютную приватность каждой тренировки. +
+
+
+
+
+ {"(3) Пространства резидентов"} +
+
+ Приватный фитнес-клуб с панорамным видом и премиальным оснащением. + Интеллектуальное пространство для тех, кто привык быть в форме и + ценит абсолютную приватность каждой тренировки. +
+
+
+
+
+ {"(3) Пространства резидентов"} +
+
+ Приватный фитнес-клуб с панорамным видом и премиальным оснащением. + Интеллектуальное пространство для тех, кто привык быть в форме и + ценит абсолютную приватность каждой тренировки. +
+
+
+ +
+
+ ); +} diff --git a/src/components/ui/BookSlider.tsx b/src/components/ui/BookSlider.tsx index 051fabb..bf73722 100644 --- a/src/components/ui/BookSlider.tsx +++ b/src/components/ui/BookSlider.tsx @@ -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(null); - const rectRef = useRef(null); - const frameRef = useRef(null); const isFlipping = useRef(false); - const lastMouseEvent = useRef(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 ( -
runFlip("next")} + onLeftClick={() => runFlip("prev")} > - - {isHovered && ( - - )} - - {/* @ts-expect-error Почему то хочет чтобы были вообще все пропсы (даже необязательные), хотя работает только и без них */} {children} -
- ); -} - -function CursorButton({ - x, - y, - rightSide, -}: { - x: number; - y: number; - rightSide: boolean; -}) { - const [width, setWidth] = useState(0); - return ( - { - 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" - > -
- -
-
+ ); } diff --git a/src/components/ui/Buttons/Button.tsx b/src/components/ui/Buttons/Button.tsx index 4aa9aeb..0a071a5 100644 --- a/src/components/ui/Buttons/Button.tsx +++ b/src/components/ui/Buttons/Button.tsx @@ -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]" diff --git a/src/components/ui/Buttons/ButtonRound.tsx b/src/components/ui/Buttons/ButtonRound.tsx index 843e55c..3deef73 100644 --- a/src/components/ui/Buttons/ButtonRound.tsx +++ b/src/components/ui/Buttons/ButtonRound.tsx @@ -42,7 +42,7 @@ export default function ButtonRound({ }`} > -

{text}

+

{text}

); } diff --git a/src/components/ui/CursorButtonWrapper.tsx b/src/components/ui/CursorButtonWrapper.tsx new file mode 100644 index 0000000..44965c9 --- /dev/null +++ b/src/components/ui/CursorButtonWrapper.tsx @@ -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; + 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(null); + const rectRef = useRef(null); + const frameRef = useRef(null); + const lastMouseEvent = useRef(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 ( +
+ + {showCursor && + isHovered && + (renderCursor ? ( + renderCursor({ + x: cursorPosition.x, + y: cursorPosition.y, + rightSide: cursorOnRightSide, + }) + ) : ( + + ))} + + {children} +
+ ); +} + +function CursorButton({ + x, + y, + rightSide, +}: { + x: number; + y: number; + rightSide: boolean; +}) { + const [width, setWidth] = useState(0); + return ( + { + 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" + > +
+ +
+
+ ); +} diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index 7c35de2..4dfbb91 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -34,7 +34,7 @@ export default function Header() { >
(null); + + return ( + + ); +} diff --git a/src/components/ui/Section.tsx b/src/components/ui/Section.tsx new file mode 100644 index 0000000..a4929ce --- /dev/null +++ b/src/components/ui/Section.tsx @@ -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; + 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(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 ( +
+ {/* Когда блок будет находиться почти полностью на экране (top=95dvh) тогда элемент появится и хедер поменяется */} +
+ {children} +
+ ); +} diff --git a/src/components/ui/VideoPlayer.tsx b/src/components/ui/VideoPlayer.tsx new file mode 100644 index 0000000..c8b711d --- /dev/null +++ b/src/components/ui/VideoPlayer.tsx @@ -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 ( +
+
+ ); +} diff --git a/src/data/videos.ts b/src/data/videos.ts new file mode 100644 index 0000000..a82bd96 --- /dev/null +++ b/src/data/videos.ts @@ -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" + } + +] \ No newline at end of file diff --git a/src/stores/useAppStateStore.tsx b/src/stores/useAppStateStore.tsx index 6cf8b2a..3dcdecb 100644 --- a/src/stores/useAppStateStore.tsx +++ b/src/stores/useAppStateStore.tsx @@ -11,9 +11,23 @@ interface AppStateStore { export const useAppStateStore = create((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 }), })); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fdfeea5 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export interface VideoPlayerContentItem { + title: string; + video: string; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..706bc77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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": [