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 (
+
+
+
+ Архитектура, говорящая на языке города
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ Хотите стать резидентом клубного дома у воды?
+
+
{}} type="Primary" size="S">
+ Оставить заявку
+
+
+
+
+ );
+}
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 (
+
{
+ 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"
+ >
+
+
+ {open && (
+
+
+ {title}
+
+
+ {subtitle}
+
+
+ )}
+
+
+ );
+}
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 (
+
+
+
+ {/* Controls */}
+
+ {/* Title */}
+
+ {VIDEO_SLIDER_CONTENT[currentVideoIndex].title}
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Index */}
+
+ / {videosCount}
+
+
+ {expanded && (
+ }
+ onClick={() => {}}
+ />
+ )}
+
+
+ );
+}
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": [