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