This commit is contained in:
2025-02-04 19:24:02 +05:00
parent 8069cdc106
commit 986fc5f360
32 changed files with 598 additions and 460 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -20,6 +20,7 @@
"@tinymce/tinymce-react": "^5.1.1",
"countries-phone-masks": "^1.1.0",
"date-fns": "^3.6.0",
"decline-word": "^1.4.0",
"framer-motion": "^11.17.0",
"html-react-parser": "^5.1.18",
"jose": "^5.9.3",
+3 -7
View File
@@ -16,7 +16,6 @@ import {
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { Suspense } from 'react';
export default async function HomePage() {
const queryClient = new QueryClient();
@@ -42,9 +41,9 @@ export default async function HomePage() {
});
await queryClient.prefetchQuery({
queryKey: ['projects', 'Удаленная дмонстрация'],
queryKey: ['projects', 'Удаленная демонстрация'],
queryFn: async ({ queryKey: [, tag] }) =>
await api.get('projects/tags=' + tag).json<IProject[]>(),
await api.get('projects?tags=' + tag).json<IProject[]>(),
});
return (
@@ -52,15 +51,12 @@ export default async function HomePage() {
<Motivation />
<Presentation />
<Statistics />
{/* <ProjectsMap /> */}
<NewMap />
<Streaming />
<Calculator />
<Reviews />
<Awards />
<Suspense>
<Projects />
</Suspense>
<Projects />
<Clients />
</HydrationBoundary>
);
+1 -1
View File
@@ -57,7 +57,7 @@ export function AwardItem({
: 'lg:self-start'
}`}
>
<div className="relative lg:min-w-[calc(276/458*100%)] sm:min-w-[calc(276/362*100%)] min-w-[calc(276/340*100%)] lg:self-end">
<div className="relative lg:max-w-[calc(276/458*100%)] sm:max-w-[calc(276/362*100%)] max-w-[calc(276/340*100%)] lg:self-end self-center">
<Image
src={image}
fill
@@ -14,7 +14,7 @@ import { StatsColumn } from './StatColumn';
export function Calculator() {
const [selectedRegion, setSelectedRegion] = useState<Region>();
const [consultations, setConsultations] = useState<number>(100);
const [consultations, setConsultations] = useState<number>(10);
const [implementationPeriod, setImplementationPeriod] = useState<number>(
null!
);
@@ -145,7 +145,7 @@ export function Calculator() {
/>
)}
<ConsultationRange
consultations={consultations!}
consultations={consultations}
setConsultations={setConsultations}
/>
<GradientButton
@@ -270,7 +270,7 @@ export function Calculator() {
</AnimatePresence>
</div>
<div className="rounded-2xl bg-[linear-gradient(to_top,#7A7A7A40,#7A7A7A30)] p-7 w-1/2 relative overflow-hidden">
<div className="flex flex-col justify-between">
<div className="flex flex-col justify-between h-full">
<p className="text1 mb-11 font-medium">Ежемесячный доход</p>
<p className="font-medium">
<span className="line2">
@@ -26,11 +26,9 @@ export function ConsultationRange({
setStart(
Math.max(
Math.min(
isMouseEvent(e)
? e.clientX
: e.touches[0].clientX -
root.current!.getBoundingClientRect().x -
offset,
(isMouseEvent(e) ? e.clientX : e.touches[0].clientX) -
root.current!.getBoundingClientRect().x -
offset,
root.current!.clientWidth
),
root.current!.clientWidth / 35
@@ -43,11 +41,9 @@ export function ConsultationRange({
if (!el || !isMouseDown) return;
const dx = Math.max(
Math.min(
isMouseEvent(e)
? (e as MouseEvent).clientX
: e.touches[0].clientX -
root.current!.getBoundingClientRect().x -
start,
(isMouseEvent(e) ? e.clientX : e.touches[0].clientX) -
root.current!.getBoundingClientRect().x -
start,
root.current!.clientWidth - 48
),
(root.current!.clientWidth - 48) / 35
+48 -24
View File
@@ -3,6 +3,7 @@
import { ItemActions } from '@/components/ItemActions';
import { CompanyFormModal } from '@/components/modals/CompanyFormModal';
import { OpenFormModalWrapper } from '@/hocs/OpenFormModalWrapper';
import { useMediaQueries } from '@/hooks/useMediaQueries';
import { useGetCompaniesQuery } from '@/queries/getCompanies';
import { GradientButton } from '@/ui/GradientButton';
import { Icon } from '@/ui/Icon';
@@ -14,38 +15,61 @@ import Link from 'next/link';
export function Clients() {
const { data: companies } = useGetCompaniesQuery();
const { isLg, isSm } = useMediaQueries();
return (
<div className="lg:space-y-16 space-y-10 lg:mt-40 mt-[100px]">
<Title className="mx-auto text-center max-w-2/3">
<Title className="mx-auto text-center max-w-3/4">
<span className="text-gradient">
{companies !== undefined && getCompaniesCount(companies.length)}
</span>{' '}
уже внедрили наш продукт в свою цепочку продаж
</Title>
{companies && (
<div className="lg:grid-cols-9 sm:grid-cols-6 grid grid-cols-3 gap-2">
{companies?.map(
(company) =>
company.logo && (
<div className="relative" key={company.id}>
<Link
href={'/projects?companyId=' + company.id}
className="bg-[#232425] rounded-xl aspect-square flex justify-center items-center opacity-60 hover:opacity-100 transition-opacity p-5"
>
<div className="relative max-h-[calc(100%)]">
<Image
fill
src={process.env.NEXT_PUBLIC_S3_BUCKET + company.logo}
className="!relative object-cover select-none pointer-events-none"
alt={company.title}
sizes="100%"
/>
</div>
</Link>
<ItemActions item={company} />
</div>
)
)}
<div className="lg:grid-cols-9 sm:grid-cols-5 grid grid-cols-3 gap-2">
{companies
.filter((company) => company.logo)
.map((company, index, { length }) => (
<div
className="relative"
key={company.id}
style={
index > length - 1 - (length % (isLg ? 9 : isSm ? 5 : 3))
? {
transform: `translateX(${
(Math.trunc((isLg ? 9 : isSm ? 5 : 3) / 2) -
Math.trunc(
(length % (isLg ? 9 : isSm ? 5 : 3)) / 2
)) *
100
}%)`,
left:
(Math.trunc((isLg ? 9 : isSm ? 5 : 3) / 2) -
Math.trunc(
(length % (isLg ? 9 : isSm ? 5 : 3)) / 2
)) *
8,
}
: undefined
}
>
<Link
href={'/projects?companyId=' + company.id}
className="bg-[#232425] rounded-2xl aspect-square flex justify-center items-center opacity-60 hover:opacity-100 transition-opacity p-5"
>
<div className="relative">
<Image
fill
src={process.env.NEXT_PUBLIC_S3_BUCKET + company.logo}
className="!relative object-cover select-none pointer-events-none"
alt={company.title}
sizes="100%"
/>
</div>
</Link>
<ItemActions item={company} />
</div>
))}
<OpenFormModalWrapper
modal={<CompanyFormModal action="create" />}
modalName="addCompany"
+85 -85
View File
@@ -1,90 +1,90 @@
import { ICityProjects } from '@/types/ICityProjects';
import { distinctCompanies } from '@/utils/distinctCompanies';
import Image from 'next/image';
import { Dispatch, SetStateAction, useEffect, useRef } from 'react';
import { useHover } from 'usehooks-ts';
// import { ICityProjects } from '@/types/ICityProjects';
// import { distinctCompanies } from '@/utils/distinctCompanies';
// import Image from 'next/image';
// import { Dispatch, SetStateAction, useEffect, useRef } from 'react';
// import { useHover } from 'usehooks-ts';
export function CityPoint({
title,
companies,
x,
y,
chooseCity,
active,
setCurrentHovered,
index,
}: ICityProjects & {
chooseCity: (_: ICityProjects) => void;
active: boolean;
setCurrentHovered: Dispatch<SetStateAction<number | undefined>>;
index: number;
}) {
const companiesWithMapIcon = distinctCompanies(
companies.filter((company) => company.mapIcon)
);
// export function CityPoint({
// title,
// companies,
// x,
// y,
// chooseCity,
// active,
// setCurrentHovered,
// index,
// }: ICityProjects & {
// chooseCity: (_: ICityProjects) => void;
// active: boolean;
// setCurrentHovered: Dispatch<SetStateAction<number | undefined>>;
// index: number;
// }) {
// const companiesWithMapIcon = distinctCompanies(
// companies.filter((company) => company.mapIcon)
// );
const ref = useRef<HTMLDivElement>(null);
// const ref = useRef<HTMLDivElement>(null);
const hovered = useHover(ref);
// const hovered = useHover(ref);
useEffect(() => {
setCurrentHovered(hovered ? index : undefined);
}, [hovered, index, setCurrentHovered]);
// useEffect(() => {
// setCurrentHovered(hovered ? index : undefined);
// }, [hovered, index, setCurrentHovered]);
return (
<div className="absolute" style={{ top: y + '%', left: x + '%' }}>
<div
className={`p-[11px] rounded-full 2xl:bg-[#37393B99] transition-colors relative z-10 ${
active ? '2xl:bg-transparent' : ''
}`}
onClick={() => chooseCity({ title, x, y, companies })}
ref={ref}
>
<div className="w-[10px] h-[10px] bg-white rounded-full" />
</div>
<div
className={`transition-all delay-200 duration-400 opacity-0 backdrop-blur-xl rounded-r-lg rounded-t-lg p-2 min-w-[132px] ${
active ? 'opacity-100 z-[11]' : 'opacity-0 -z-10'
} bg-[#37393B99] absolute translate-x-5 -translate-y-[calc(100%+24px)] space-y-0.5`}
>
<p className="w-full font-medium leading-4 text2">{title}</p>
<div className="relative flex py-px">
{companiesWithMapIcon
.slice(0, 3)
.map(({ mapIcon, title, id }, index) => (
<div
key={index}
className="relative p-px rounded-lg odd:rotate-[-3.5deg] w-[34px] h-[34px]"
style={{
right: 6 * index,
zIndex: index,
}}
>
{mapIcon && (
<Image
key={id}
src={process.env.NEXT_PUBLIC_S3_BUCKET + '' + mapIcon}
alt={title}
className="!relative object-cover"
sizes=""
fill
/>
)}
</div>
))}
{Math.min(companiesWithMapIcon.length, 3) !== companies.length && (
<div
className="rounded-lg bg-[#7A7A7A] flex justify-center items-center w-[34px] h-[34px] aspect-square text-xs relative"
style={{
right: Math.min(companiesWithMapIcon.length, 3) * 6,
zIndex: companies.length,
}}
>
+{companies.length - Math.min(companiesWithMapIcon.length, 3)}
</div>
)}
</div>
</div>
</div>
);
}
// return (
// <div className="absolute" style={{ top: y + '%', left: x + '%' }}>
// <div
// className={`p-[11px] rounded-full 2xl:bg-[#37393B99] transition-colors relative z-10 ${
// active ? '2xl:bg-transparent' : ''
// }`}
// onClick={() => chooseCity({ title, x, y, companies })}
// ref={ref}
// >
// <div className="w-[10px] h-[10px] bg-white rounded-full" />
// </div>
// <div
// className={`transition-all delay-200 duration-400 opacity-0 backdrop-blur-xl rounded-r-lg rounded-t-lg p-2 min-w-[132px] ${
// active ? 'opacity-100 z-[11]' : 'opacity-0 -z-10'
// } bg-[#37393B99] absolute translate-x-5 -translate-y-[calc(100%+24px)] space-y-0.5`}
// >
// <p className="w-full font-medium leading-4 text2">{title}</p>
// <div className="relative flex py-px">
// {companiesWithMapIcon
// .slice(0, 3)
// .map(({ mapIcon, title, id }, index) => (
// <div
// key={index}
// className="relative p-px rounded-lg odd:rotate-[-3.5deg] w-[34px] h-[34px]"
// style={{
// right: 6 * index,
// zIndex: index,
// }}
// >
// {mapIcon && (
// <Image
// key={id}
// src={process.env.NEXT_PUBLIC_S3_BUCKET + '' + mapIcon}
// alt={title}
// className="!relative object-cover"
// sizes=""
// fill
// />
// )}
// </div>
// ))}
// {Math.min(companiesWithMapIcon.length, 3) !== companies.length && (
// <div
// className="rounded-lg bg-[#7A7A7A] flex justify-center items-center w-[34px] h-[34px] aspect-square text-xs relative"
// style={{
// right: Math.min(companiesWithMapIcon.length, 3) * 6,
// zIndex: companies.length,
// }}
// >
// +{companies.length - Math.min(companiesWithMapIcon.length, 3)}
// </div>
// )}
// </div>
// </div>
// </div>
// );
// }
+118 -25
View File
@@ -1,40 +1,133 @@
'use client';
import { useGetProjectsQuery } from '@/queries/getProjects';
import { cities } from '@/consts/cities';
import { mapVideos } from '@/consts/mapVideos';
import { useGetCompniesByCityQuery } from '@/queries/getCompaniesByCity';
import { useGetProjectsCountQuery } from '@/queries/getProjectsCount';
import { useCityPointStore } from '@/stores/useCityPointStore';
import { ICityProjects } from '@/types/ICityProjects';
import { ICompany } from '@/types/ICompany';
import { useEffect, useState } from 'react';
import { prepositionCity } from '@/utils/prepositionCity';
import Image from 'next/image';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { useHover } from 'usehooks-ts';
export function NewMap() {
const { cityPoint, setCityPoint } = useCityPointStore();
const { cityPoint } = useCityPointStore();
const [companies, setCompanies] = useState<ICompany[]>([]);
const [currentHovered, setCurrentHovered] = useState<number | undefined>();
const { data: projects } = useGetProjectsQuery(cityPoint.title);
useEffect(() => {
setCompanies(
projects!.map(({ company }) => company)?.filter((company) => !!company)
);
}, []);
const [currentCompany, setCurrentCompany] = useState('Бруниска');
const { data: companies } = useGetCompniesByCityQuery(cityPoint.title);
return (
<div className="bg-[url(/img/pages/home/stats/new_map.png)] bg-no-repeat bg-center bg-contain aspect-[1333.4/731.98] relative">
<div className="absolute top-[calc(94/731.98*100%)] left-[calc(679.1/1333.4*100%)] rounded-3xl overflow-hidden w-[calc(341/1333.4*100%)] h-[calc(534/731.98*100%)] bg-[#37393B99] backdrop-blur-xs">
<video src="" className="aspect-[341/396] bg-white w-full" />
<div className="p-[17px] flex flex-col justify-between">
<p className="btnl font-medium opacity-60">Последнее в Москве</p>
<div className="flex gap-1">
{companies.map(({ id, mapIcon, title }) => (
<div key={mapIcon}></div>
))}
</div>
<div className="bg-[url(/img/pages/home/stats/new_map.png)] bg-no-repeat bg-center bg-contain aspect-[1333.4/731.98] relative max-lg:hidden">
{cities.map((point, index) => (
<CityPoint
key={point.title}
{...point}
active={
currentHovered === index ||
(point.title === cityPoint.title && currentHovered === undefined)
}
index={index}
setCurrentHovered={setCurrentHovered}
/>
))}
{companies && <Slider companies={companies} city={cityPoint.title} />}
</div>
);
}
export function CityPoint({
x,
y,
title,
active,
index,
setCurrentHovered,
}: ICityProjects & {
active: boolean;
index: number;
setCurrentHovered: Dispatch<SetStateAction<number | undefined>>;
}) {
const { data: count } = useGetProjectsCountQuery(title);
const { setCityPoint } = useCityPointStore();
const ref = useRef<HTMLButtonElement>(null);
const hovered = useHover(ref);
useEffect(() => {
setCurrentHovered(hovered ? index : undefined);
}, [hovered, index, setCurrentHovered]);
return (
<button
style={{ left: `${x}%`, top: `${y}%` }}
ref={ref}
onClick={() => setCityPoint({ title, x, y })}
className={`absolute outline-none px-4 py-[9px] flex gap-1 items-center transition-colors font-medium ${
active ? 'text-white' : 'text-[#7A7A7A]'
}`}
>
<p className="heading2">{title}</p>
<p className="btnm h-[30px]">({count})</p>
</button>
);
}
export function Slider({
companies,
city,
}: {
companies: ICompany[];
city: string;
}) {
const [current, setCurrent] = useState(0);
const src =
companies.length &&
mapVideos.find(({ title }) => title === companies[current].title)?.src!;
return (
<div className="absolute top-[calc(94/731.98*100%)] left-[calc(679.1/1333.4*100%)] rounded-3xl overflow-hidden w-[calc(341/1333.4*100%)] flex flex-col h-[calc(534/731.98*100%)] bg-[#37393B99] backdrop-blur-xs">
{!src ? (
<div className="aspect-[341/396] w-full" />
) : src.startsWith('/') ? (
<video
src={src}
muted
autoPlay
playsInline
loop
className="aspect-[341/396] object-cover w-full"
/>
) : (
<iframe src={src} className="aspect-[341/396]" />
)}
<div className="p-[17px] flex flex-col gap-4 flex-1">
<p className="btnl font-medium opacity-60">
Последнее в{city.startsWith('В') ? 'о' : ''} {prepositionCity(city)}
</p>
<div className="flex gap-4">
{companies.map(({ id, mapIcon, title }, index) => (
<button
onClick={() => setCurrent(index)}
key={id}
className={`p-1 bg-gradient-to-r from-[#FFFFFF14] rounded-2xl${
index === current ? ' border' : ''
}`}
>
<Image
src={process.env.NEXT_PUBLIC_S3_BUCKET + mapIcon!}
width={48}
height={48}
alt={title}
/>
</button>
))}
</div>
</div>
</div>
);
}
export function CityPoint() {}
@@ -1,102 +1,102 @@
'use client';
// 'use client';
import { cities } from '@/consts/cities';
import { useGetProjectsQuery } from '@/queries/getProjects';
import { useCityPointStore } from '@/stores/useCityPointStore';
import { ICityProjects } from '@/types/ICityProjects';
import { ICompany } from '@/types/ICompany';
import { IProject } from '@/types/IProject';
import { getProjectsGroupedByCities } from '@/utils/getProjectsGroupedByCities';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react';
import { CityPoint } from './CityPoint';
import { ProjectsSlider } from './ProjectsSlider';
// import { cities } from '@/consts/cities';
// import { useGetProjectsQuery } from '@/queries/getProjects';
// import { useCityPointStore } from '@/stores/useCityPointStore';
// import { ICityProjects } from '@/types/ICityProjects';
// import { ICompany } from '@/types/ICompany';
// import { IProject } from '@/types/IProject';
// import { getProjectsGroupedByCities } from '@/utils/getProjectsGroupedByCities';
// import Image from 'next/image';
// import { useEffect, useMemo, useState } from 'react';
// import { CityPoint } from './CityPoint';
// import { ProjectsSlider } from './ProjectsSlider';
export function ProjectsMap() {
const { data: projects } = useGetProjectsQuery();
// export function ProjectsMap() {
// const { data: projects } = useGetProjectsQuery();
const { cityPoint, setCityPoint } = useCityPointStore();
// const { cityPoint, setCityPoint } = useCityPointStore();
const [cityProjects, setCityProjects] = useState<IProject[]>([]);
// const [cityProjects, setCityProjects] = useState<IProject[]>([]);
useEffect(() => {
if (projects)
setCityProjects(
projects.filter((project) => project.city === cityPoint.title)
);
}, [projects, cityPoint]);
// useEffect(() => {
// if (projects)
// setCityProjects(
// projects.filter((project) => project.city === cityPoint.title)
// );
// }, [projects, cityPoint]);
return (
projects && (
<div className="grid xl:grid-cols-[3fr_1fr] gap-3 xl:pl-[23px] mt-16 max-xl:hidden">
<Map projects={projects} chooseCity={setCityPoint} />
<ProjectsSlider
projects={cityProjects}
city={cityPoint.title}
count={cityProjects.length}
/>
</div>
)
);
}
// return (
// projects && (
// <div className="grid xl:grid-cols-[3fr_1fr] gap-3 xl:pl-[23px] mt-16 max-xl:hidden">
// <Map projects={projects} chooseCity={setCityPoint} />
// <ProjectsSlider
// projects={cityProjects}
// city={cityPoint.title}
// count={cityProjects.length}
// />
// </div>
// )
// );
// }
export function Map({
projects,
chooseCity,
}: {
projects: IProject[];
chooseCity: (_: ICityProjects) => void;
}) {
const { cityPoint } = useCityPointStore();
// export function Map({
// projects,
// chooseCity,
// }: {
// projects: IProject[];
// chooseCity: (_: ICityProjects) => void;
// }) {
// const { cityPoint } = useCityPointStore();
const [currentHovered, setCurrentHovered] = useState<number>();
// const [currentHovered, setCurrentHovered] = useState<number>();
const groupedProjects = useMemo(
() =>
Array.from(
getProjectsGroupedByCities(
projects.filter(({ city }) =>
cities.some(({ title }) => title === city)
)
).entries()
),
[projects]
);
// const groupedProjects = useMemo(
// () =>
// Array.from(
// getProjectsGroupedByCities(
// projects.filter(({ city }) =>
// cities.some(({ title }) => title === city)
// )
// ).entries()
// ),
// [projects]
// );
return (
<div className="relative">
<Image
src="/img/pages/home/stats/newmap.png"
alt="map"
className="!relative aspect-[1027/560] object-cover"
fill
sizes=""
/>
{Array.from(groupedProjects).map(([city, projects], index) => {
const point = cities.find(({ title }) => title === city)!;
// return (
// <div className="relative">
// <Image
// src="/img/pages/home/stats/newmap.png"
// alt="map"
// className="!relative aspect-[1027/560] object-cover"
// fill
// sizes=""
// />
// {Array.from(groupedProjects).map(([city, projects], index) => {
// const point = cities.find(({ title }) => title === city)!;
return (
point && (
<CityPoint
active={
currentHovered === index ||
(cityPoint.title === city && currentHovered === undefined)
}
key={city}
chooseCity={chooseCity}
title={city}
companies={
projects
.filter((project) => project.company)
.map(({ company }) => company) as ICompany[]
}
x={point.x ?? 0}
y={point.y ?? 0}
{...{ setCurrentHovered, index }}
/>
)
);
})}
</div>
);
}
// return (
// point && (
// <CityPoint
// active={
// currentHovered === index ||
// (cityPoint.title === city && currentHovered === undefined)
// }
// key={city}
// chooseCity={chooseCity}
// title={city}
// companies={
// projects
// .filter((project) => project.company)
// .map(({ company }) => company) as ICompany[]
// }
// x={point.x ?? 0}
// y={point.y ?? 0}
// {...{ setCurrentHovered, index }}
// />
// )
// );
// })}
// </div>
// );
// }
+1 -4
View File
@@ -22,10 +22,7 @@ export function Motivation() {
return (
<div className="lg:space-y-16 space-y-10">
<Title
headerLevel={1}
className="sm:max-lg:text-5xl text-center 2xl:atext-[160px]"
>
<Title headerLevel={1} className="sm:max-lg:text-5xl text-center">
Помогаем девелоперам
<br />
продавать недвижимость проще и&nbsp;
@@ -16,7 +16,7 @@ export function Infrastructure({ slide }: { slide?: number }) {
}}
exit={{
opacity: 0,
x: '100%',
// x: '100%',
}}
className="absolute col-span-2 col-start-5 rounded-2xl p-6 xl:p-12 h-full bg-radial-[at_0%_100%] from-[#7A7A7A99] flex flex-col gap-2 z-10 backdrop-blur-[500px] select-none"
>
@@ -7,7 +7,9 @@ export function Insolation({ slide }: { slide?: number }) {
<AnimatePresence>
{slide === 3 && (
<motion.div
exit={{ opacity: 0, x: '100%' }}
initial={{ y: '100%', opacity: 0 }}
animate={{ y: '0%', opacity: 1, transition: { duration: 0.5 } }}
exit={{ opacity: 0, y: '-100%', transition: { duration: 0.5 } }}
className="absolute col-span-2 col-start-5 rounded-2xl p-6 xl:p-12 h-full bg-radial-[at_0%_100%] from-[#7A7A7A99] flex flex-col gap-2 z-10 backdrop-blur-[500px] select-none"
>
<p className="heading2 font-medium">Интерактивная инсоляция</p>
@@ -43,13 +43,14 @@ export function PresentationMini() {
return (
<div className="mt-25 lg:hidden">
<div className="top-20 md:space-y-12 sticky space-y-10">
<Title>
<div className="top-15 md:space-y-12 sticky space-y-5">
<Title className="text-nowrap">
Интерактивная презентация{' '}
<span className="text-gradient">
улучшает опыт выбора недвижимости
</span>{' '}
и увеличивают темпы продаж квартир в жилом комплексе
и увеличивают темпы продаж
<span className="max-sm:hidden">квартир в жилом комплексе</span>
</Title>
<div className="md:h-95 h-62 relative">
{videos.map((video, index) => (
@@ -63,7 +64,7 @@ export function PresentationMini() {
style={{
zIndex: videos.length - index,
}}
className={`absolute w-full h-full transition-opacity${
className={`absolute w-full h-full object-cover transition-opacity${
slide > index && index !== videos.length - 1 ? ' opacity-0' : ''
}`}
/>
@@ -1,44 +0,0 @@
import { IReview } from '@/types/IReview';
import { motion } from 'framer-motion';
import { useEffect, useRef } from 'react';
export function ReviewContent({
author,
text,
src,
active = false,
index,
}: IReview & { active: boolean; index: number }) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (ref.current && active) ref.current!.currentTime = !index ? 6 : 5;
}, [active, index]);
return (
active && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
className="relative flex items-center justify-center h-full max-h-[calc(100vh-40px)]"
>
<div className="lg:space-y-6 space-y-4 lg:max-w-[60%] absolute lg:left-6 lg:top-6 sm:top-4 left-4 bottom-4 transition-opacity z-[5]">
<p className="max-w-[507px] text2 opacity-80">{author}</p>
<p className="accent font-medium">{text}</p>
</div>
{/* <VideoPlayer src={src} showMutingBtn={false} /> */}
<video
ref={ref}
src={src}
loop
muted
autoPlay
playsInline
className="rounded-2xl absolute object-cover object-center w-full h-full"
/>
<div className="transition-colors delay-500 max-sm:bg-gradient-to-t from-[rgba(20,22,31,0.6)] to-[rgba(20,22,31,0)] absolute h-full w-full rounded-2xl z-[4]" />
</motion.div>
)
);
}
@@ -31,7 +31,7 @@ export function ReviewTab({
return (
<motion.button
onClick={onClick}
className="flex items-stretch rounded-xl p-1 backdrop-blur-[34.2px] bg-[#37393B99] max-h-[104px] outline-none max-lg:min-w-[280px]"
className="flex items-stretch rounded-2xl p-1 backdrop-blur-[34.2px] bg-[#37393B99] max-h-[104px] outline-none max-lg:min-w-[280px]"
animate={{
width: active ? 283 : 104,
}}
@@ -3,14 +3,19 @@
import reviewsData from '@/consts/reviews.json';
import { useMediaQueries } from '@/hooks/useMediaQueries';
import { useScroll } from '@/hooks/useScroll';
import { useRef, useState } from 'react';
import { ReviewContent } from './ReviewContent';
import { VideoPlayer } from '@/ui/VideoPlayer';
import { useEffect, useRef, useState } from 'react';
import { ReviewTab } from './ReviewTab';
export function Reviews() {
const [tab, setTab] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (videoRef.current) videoRef.current!.currentTime = tab === 0 ? 6 : 5;
}, [tab]);
const scroll = useScroll(ref);
@@ -35,32 +40,32 @@ export function Reviews() {
: '100%',
}}
>
<div className="absolute flex flex-nowrap bottom-6 left-6 gap-2 z-[6] max-lg:hidden">
{reviewsData.map(({ author, title, miniImage }, index) => (
<ReviewTab
key={author}
image={miniImage}
onClick={() => setTab(index)}
active={
currentHovered === index ||
(tab === index && currentHovered === undefined)
}
title={title}
index={index}
setCurrentHovered={setCurrentHovered}
/>
))}
</div>
{reviewsData.map((review, index) => (
<ReviewContent
{...review}
key={review.author}
active={tab === index}
index={index}
/>
))}
<VideoPlayer src={reviewsData[tab].src} showMutingBtn ref={videoRef}>
<div className="lg:space-y-6 space-y-4 lg:max-w-[60%] absolute lg:left-6 lg:top-6 sm:top-4 left-4 bottom-4 transition-opacity z-[5]">
<p className="max-w-[507px] text2 opacity-80">
{reviewsData[tab].author}
</p>
<p className="accent font-medium">{reviewsData[tab].text}</p>
</div>
<div className="absolute flex flex-nowrap bottom-6 left-6 gap-2 z-[6] max-lg:hidden">
{reviewsData.map(({ author, title, miniImage }, index) => (
<ReviewTab
key={author}
image={miniImage}
onClick={() => setTab(index)}
active={
currentHovered === index ||
(tab === index && currentHovered === undefined)
}
title={title}
index={index}
setCurrentHovered={setCurrentHovered}
/>
))}
</div>
</VideoPlayer>
</div>
<div className="flex gap-2 z-[3] lg:hidden max-lg:-mx-5 max-lg:px-5 overflow-auto [scrollbar-width:none]">
<div className="flex gap-2 z-[3] lg:hidden mt-3 max-lg:-mx-5 max-lg:px-5 overflow-auto [scrollbar-width:none]">
{reviewsData.map(({ author, title, miniImage }, index) => (
<ReviewTab
key={author}
+2 -2
View File
@@ -27,7 +27,7 @@ export function Statistics() {
text="Конверсия из консультации в бронирование увеличивается на"
className="xl:col-start-1 col-start-2 xl:aspect-[344/225] sm:aspect-[362/255]"
/>
<div className="p-6 flex flex-col justify-between xl:col-start-2 xl:row-span-2 xl:row-start-1 sm:col-start-1 sm:row-start-2 col-span-2 sm:max-xl:aspect-[736/360] bg-[url(/img/pages/home/stats/building2.png),linear-gradient(to_bottom_right,#7A7A7A50,transparent)] bg-no-repeat bg-right-bottom bg-[length:65%,100%] rounded-lg">
<div className="p-6 flex flex-col justify-between xl:col-start-2 xl:row-span-2 xl:row-start-1 sm:col-start-1 sm:row-start-2 col-span-2 sm:max-xl:aspect-[736/360] bg-[url(/img/pages/home/stats/building2.png),linear-gradient(to_bottom_right,#7A7A7A50,transparent)] bg-no-repeat bg-right-bottom bg-[length:65%,100%] rounded-2xl">
<p className="text1 lg:max-w-[35%] max-w-[70%]">
Время реализации проекта сокращается до
</p>
@@ -36,7 +36,7 @@ export function Statistics() {
<span className="heading2">раз</span>
</div>
</div>
<div className="xl:col-start-4 xl:row-start-1 max-sm:col-span-2 sm:col-start-1 sm:row-start-3 aspect-[338/225] bg-[linear-gradient(to_top_left,#7a7a7a50,transparent)] p-6 rounded-lg flex flex-col justify-between relative">
<div className="xl:col-start-4 xl:row-start-1 max-sm:col-span-2 sm:col-start-1 sm:row-start-3 aspect-[338/225] bg-[linear-gradient(to_top_left,#7a7a7a50,transparent)] p-6 rounded-2xl flex flex-col justify-between relative">
<p className="text1">
Время на подготовку рекламных материалов сокращается до
</p>
+33 -33
View File
@@ -2,63 +2,63 @@ import { ICityProjects } from '@/types/ICityProjects';
export const cities: Omit<ICityProjects, 'logos' | 'companies'>[] = [
{
x: 15200 / 1027,
y: 24600 / 560,
title: 'Пенза',
},
{
x: 16800 / 1027,
y: 16500 / 560,
x: 9210 / 1333.4,
y: 29282 / 731.98,
title: 'Санкт-Петербург',
},
{
x: 9900 / 1027,
y: 24600 / 560,
x: 22910 / 1333.4,
y: 41482 / 731.98,
title: 'Пенза',
},
{
x: 9110 / 1333.4,
y: 40782 / 731.98,
title: 'Брянск',
},
{
x: 16800 / 1027,
y: 31400 / 560,
x: 15010 / 1333.4,
y: 35582 / 731.98,
title: 'Москва',
},
{
x: 20800 / 1027,
y: 29800 / 560,
x: 30110 / 1333.4,
y: 34682 / 731.98,
title: 'Казань',
},
// {
// x: 27600 / 1027,
// y: 31400 / 560,
// title: 'Пермь',
// },
// {
// x: 30200 / 1027,
// y: 32800 / 560,
// title: 'Нижний Тагил',
// },
{
x: 27600 / 1027,
y: 31400 / 560,
title: 'Пермь',
},
{
x: 30200 / 1027,
y: 32800 / 560,
title: 'Нижний Тагил',
},
{
x: 28800 / 1027,
y: 34300 / 560,
x: 36510 / 1333.4,
y: 41982 / 731.98,
title: 'Екатеринбург',
},
{
x: 27600 / 1027,
y: 36900 / 560,
x: 46710 / 1333.4,
y: 46282 / 731.98,
title: 'Челябинск',
},
{
x: 33000 / 1027,
y: 36900 / 560,
x: 48710 / 1333.4,
y: 37882 / 731.98,
title: 'Тюмень',
},
{
x: 87300 / 1027,
y: 46400 / 560,
x: 105510 / 1333.4,
y: 53482 / 731.98,
title: 'Хабаровск',
},
{
x: 87300 / 1027,
y: 53100 / 560,
x: 114910 / 1333.4,
y: 64682 / 731.98,
title: 'Владивосток',
},
];
+27 -8
View File
@@ -1,77 +1,96 @@
export const mapVideos = [
{
city: 'Москва',
title: 'Upside Development',
src: 'https://rutube.ru/video/8db3147bba72177d055d7f4bc198a8f3/',
src: 'https://rutube.ru/play/embed/8db3147bba72177d055d7f4bc198a8f3/',
},
{
city: 'Москва',
title: 'Sezar Group',
src: '/videos/pages/home/map/Sezar.mp4',
},
{
city: 'Москва',
title: 'Брусника',
src: 'https://rutube.ru/video/01c1354c4b4572d91a0d6768b3d9370a/',
src: 'https://rutube.ru/play/embed/01c1354c4b4572d91a0d6768b3d9370a/',
},
{
city: 'Москва',
title: 'Легенда',
src: '/videos/pages/home/map/SevernyPort.mp4',
},
{
city: 'Екатеринбург',
title: 'Форум',
src: '/videos/pages/home/map/Zoolog.mp4',
},
{
city: 'Екатеринбург',
title: 'Паритет',
src: 'https://rutube.ru/video/8dec2951fb80b6de87e6b08fad0b8871/',
src: 'https://rutube.ru/play/embed/8dec2951fb80b6de87e6b08fad0b8871/',
},
{
city: 'Екатеринбург',
title: 'Лето',
src: '/videos/pages/home/map/Leto.mp4',
},
{
city: 'Екатеринбург',
title: 'НКС',
src: 'https://rutube.ru/video/fb90512a39b945788f9fd21196c0d45b/',
src: 'https://rutube.ru/play/embed/fb90512a39b945788f9fd21196c0d45b/',
},
{
city: 'Тюмень',
title: 'Делом',
src: 'https://rutube.ru/video/2ada2ae6fc2c5079ae1b6b06ce5979a8/',
src: 'https://rutube.ru/play/embed/2ada2ae6fc2c5079ae1b6b06ce5979a8/',
},
{
city: 'Тюмень',
title: 'Rodina',
src: 'https://rutube.ru/video/2ada2ae6fc2c5079ae1b6b06ce5979a8/',
src: '/videos/pages/home/map/August.mp4',
},
{
city: 'Тюмень',
title: 'ЭНКО',
src: 'https://rutube.ru/video/2979e821ed713651864666b1cb1e91f7/',
src: 'https://rutube.ru/play/embed/2979e821ed713651864666b1cb1e91f7/',
},
{
city: 'Казань',
title: 'Брусника',
src: 'https://rutube.ru/video/829c2ddc24351d08df1c4aa423f10f59/',
src: 'https://rutube.ru/play/embed/829c2ddc24351d08df1c4aa423f10f59/',
},
{
city: 'Пенза',
title: 'Рисан',
src: '/videos/pages/home/map/Risan.MOV',
},
{
city: 'Пермь',
title: 'ГК Альфа',
src: '/videos/pages/home/map/Risan.MOV',
},
{
city: 'Челябинск',
title: 'Голос',
src: '/videos/pages/home/map/Risan.MOV',
},
{
city: 'Хабаровск',
title: 'СК+',
src: '/videos/pages/home/map/Risan.MOV',
},
{
city: 'Хабаровск',
title: 'DNS',
src: '/videos/pages/home/map/DNS.mp4',
},
{
city: 'Хабаровск',
title: 'Edelweiss',
src: '/videos/pages/home/map/DNS.mp4',
},
{
city: 'Санкт-Петербург',
title: 'Мавис',
src: '/videos/pages/home/map/Mavis.mp4',
},
-3
View File
@@ -1,6 +1,5 @@
[
{
"image": "/img/pages/home/reviews/1.png",
"miniImage": "/img/pages/home/reviews/1_mini.png",
"text": "«Эффективность инструмента была подтверждена буквально в первый день после его внедрения»",
"author": "Егор Бобров, Коммерческий директор авторского квартала «Машаров»",
@@ -8,7 +7,6 @@
"title": "Авторский Квартал «Машаров»"
},
{
"image": "/img/pages/home/reviews/3.jpg",
"miniImage": "/img/pages/home/reviews/2_mini.png",
"text": "«Одним из преимуществ инструмента является возможность посмотреть 3D-модель квартиры с готовым дизайнерским ремонтом и оценить видовые характеристиками, изменяя время суток»",
"author": "Алина Веселова, Ведущий специалист отдела продаж",
@@ -16,7 +14,6 @@
"title": "ЖК «Айвазовский»"
},
{
"image": "/img/pages/home/reviews/2.jpg",
"miniImage": "/img/pages/home/reviews/3_mini.png",
"text": "«Клиенты особенно ценят возможность легко выбрать квартиру с помощью 3D-модель жилого комплекса»",
"author": "Олег Бондорев, Ведущий менеджер компании «ЭНКО»",
+14
View File
@@ -0,0 +1,14 @@
import { api } from '@/api';
import { ICompany } from '@/types/ICompany';
import { useQuery } from '@tanstack/react-query';
export function useGetCompniesByCityQuery(city: string) {
return useQuery({
queryKey: ['companies', city],
queryFn: async () =>
await api
.get(`companies?${city ? 'city=' + city : ''}`)
.json<ICompany[]>(),
select: (companies) => companies.filter((company) => company.mapIcon),
});
}
+15 -3
View File
@@ -1,9 +1,21 @@
import { api } from '@/api';
import { useQuery } from '@tanstack/react-query';
export function useGetProjectsCountQuery() {
export function useGetProjectsCountQuery(
city?: string,
tags: string | string[] = []
) {
return useQuery({
queryKey: ['project', 'count'],
queryFn: async () => await api.get('projects/count').json<number>(),
queryKey: ['project', 'count', city, tags],
queryFn: async () =>
await api
.get(
`projects/count?${city ? 'city=' + city : ''}&${
Array.isArray(tags)
? tags.map((tag) => `tags=${tag}`).join('&')
: 'tags=' + tags
}`
)
.json<number>(),
});
}
+1 -3
View File
@@ -1,8 +1,6 @@
import { ICompany } from './ICompany';
export interface ICityProjects {
x: number;
y: number;
title: string;
companies: ICompany[];
// companies: ICompany[];
}
-1
View File
@@ -1,5 +1,4 @@
export interface IReview {
image: string;
miniImage: string;
text: string;
author: string;
+1 -1
View File
@@ -40,7 +40,7 @@ export function Figure({
<motion.div
ref={root}
className={
'bg-[linear-gradient(to_top_left,#7a7a7a50,transparent)] p-6 rounded-lg flex flex-col justify-between relative ' +
'bg-[linear-gradient(to_top_left,#7a7a7a50,transparent)] p-6 rounded-2xl flex flex-col justify-between relative ' +
className
}
>
+1 -1
View File
@@ -13,7 +13,7 @@ export function GradientButton({
return (
<button
onClick={onClick}
className={`bg-gradient-to-bl outline-none p-px rounded-full animate-spin [animation-duration:2s] from-[#BE69F5] to-[#798FFF00]${
className={`bg-gradient-to-bl outline-none p-px rounded-full animate-spin [animation-duration:2s] from-[#BE69F5] cursor-pointer to-[#798FFF00]${
className ? ' ' + className : ''
}`}
>
+1 -1
View File
@@ -32,7 +32,7 @@ export function Option<T extends FieldValues>({
return (
<div
className={`cursor-pointer transition-colors rounded-xl font-medium select-none ${
className={`cursor-pointer transition-colors rounded-xl font-medium text-nowrap select-none ${
chosen ? 'bg-white text-black' : 'bg-[#37393B99]'
} ${fieldName === 'products' ? 'px-6 py-[17px] btnm' : 'px-3 py-2 btns'}`}
onClick={() => setChosen(!chosen)}
+2 -2
View File
@@ -9,14 +9,14 @@ export function VanyaBoom({ className }: { className?: string }) {
>
<video
src="/videos/pages/inProcess/impla.mp4"
className="aspect-[128/62] absolute rounded-3xl"
className="aspect-[128/62] absolute rounded-3xl w-full object-cover"
muted
autoPlay
loop
playsInline
/>
<Image
src={'/img/pages/about/vanya_workaet.png'}
src={'/img/pages/inProcess/vanya_workaet.png'}
alt="vanya workaet"
className="relative mix-blend-lighten"
width={128}
+1 -1
View File
@@ -43,7 +43,7 @@ export function VideoMutingBtn({
}, [animate, handleMouseMove]);
return (
<div className="absolute left-0 top-0 h-5/6 w-full z-[1]">
<div className="absolute left-0 top-0 h-5/6 w-full z-7">
<div ref={ref} className="relative w-full h-full group">
<button
className="bg-[#37393B99] p-[50px] backdrop-blur-[30.72px] rounded-full group-hover:opacity-100 transition-opacity group-hover:cursor-none opacity-0 sticky outline-none"
+97 -73
View File
@@ -1,82 +1,106 @@
'use client';
import { ComponentProps, useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ComponentProps,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { VideoMutingBtn } from './VideoMutingBtn';
import { VideoProgressBar } from './VideoProgressBar';
export function VideoPlayer({
src,
showMutingBtn,
children,
loop = true,
autoPlay = true,
}: {
src: string;
showMutingBtn: boolean;
children?: React.ReactNode;
} & ComponentProps<'video'>) {
const progressbarRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
export const VideoPlayer = forwardRef<
HTMLVideoElement,
{
src: string;
showMutingBtn: boolean;
children?: React.ReactNode;
} & ComponentProps<'video'>
>(
(
{ src, showMutingBtn, children, loop = true, autoPlay = true, className },
ref
) => {
const progressbarRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [muted, setMuted] = useState(autoPlay);
const [playing, setPlaying] = useState(autoPlay);
const [progress, setProgress] = useState(0);
useImperativeHandle(ref, () => videoRef.current!);
function handleProgressbarClick(e: React.MouseEvent) {
videoRef.current!.currentTime =
(videoRef.current!.duration *
(e.clientX - progressbarRef.current!.getBoundingClientRect().x)) /
progressbarRef.current!.clientWidth;
setProgress(
((videoRef.current?.currentTime ?? 0) /
(videoRef.current?.duration ?? 1)) *
100
const [muted, setMuted] = useState(autoPlay);
const [playing, setPlaying] = useState(autoPlay);
const [progress, setProgress] = useState(0);
function handleProgressbarClick(e: React.MouseEvent) {
videoRef.current!.currentTime =
(videoRef.current!.duration *
(e.clientX - progressbarRef.current!.getBoundingClientRect().x)) /
progressbarRef.current!.clientWidth;
setProgress(
((videoRef.current?.currentTime ?? 0) /
(videoRef.current?.duration ?? 1)) *
100
);
}
function handlePlaybackClick() {
if (!videoRef.current) return;
setPlaying(videoRef.current.paused);
videoRef.current[videoRef.current.paused ? 'play' : 'pause']();
}
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const timeUpdateHandler = () =>
setProgress(((video.currentTime ?? 0) / (video.duration ?? 1)) * 100);
videoRef.current.addEventListener('timeupdate', timeUpdateHandler);
return () => video.removeEventListener('timeupdate', timeUpdateHandler);
}, []);
return (
<div className="relative">
<video
ref={videoRef}
src={src}
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline
className={`lg:aspect-[1400/640] sm:aspect-[736/480] aspect-[340/600] rounded-2xl w-full h-full object-cover${
className ? ' ' + className : ''
}`}
/>
{showMutingBtn && (
<VideoMutingBtn
handleClick={() => setMuted(!videoRef.current!.muted)}
muted={muted}
/>
)}
<div className="absolute w-full h-full top-0 bg-gradient-to-t from-[rgba(20,22,31,0.6)] to-[rgba(20,22,31,0)] rounded-2xl" />
<AnimatePresence>
{muted && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
<VideoProgressBar
muted={muted}
progress={progress}
progressbarRef={progressbarRef}
playing={playing}
handlePlaybackClick={handlePlaybackClick}
handleProgressbarClick={handleProgressbarClick}
/>
</div>
);
}
function handlePlaybackClick() {
if (!videoRef.current) return;
setPlaying(videoRef.current.paused);
videoRef.current[videoRef.current.paused ? 'play' : 'pause']();
}
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const timeUpdateHandler = () =>
setProgress(((video.currentTime ?? 0) / (video.duration ?? 1)) * 100);
videoRef.current.addEventListener('timeupdate', timeUpdateHandler);
return () => video.removeEventListener('timeupdate', timeUpdateHandler);
}, []);
return (
<div className="relative">
<video
ref={videoRef}
src={src}
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline
className="lg:aspect-[1400/640] sm:aspect-[736/480] aspect-[340/600] rounded-2xl w-full h-full object-cover"
/>
{showMutingBtn && (
<VideoMutingBtn
handleClick={() => setMuted(!videoRef.current!.muted)}
muted={muted}
/>
)}
<div className="absolute w-full h-full top-0 bg-gradient-to-t from-[rgba(20,22,31,0.6)] to-[rgba(20,22,31,0)] rounded-2xl" />
{muted && children}
<VideoProgressBar
muted={muted}
progress={progress}
progressbarRef={progressbarRef}
playing={playing}
handlePlaybackClick={handlePlaybackClick}
handleProgressbarClick={handleProgressbarClick}
/>
</div>
);
}
);
+4
View File
@@ -0,0 +1,4 @@
export function prepositionCity(city: string) {
if (city.at(-1) === 'ь') return city.slice(0, -1) + 'и';
return ('аеёиоуыэю'.includes(city.at(-1)!) ? city.slice(0, -1) : city) + 'e';
}