Compare commits

...

114 Commits

Author SHA1 Message Date
Umar Adilov
a1c95a019a Added app links 2026-02-27 11:55:50 +05:00
Umar Adilov
31e6939949 fixed slider filter 2025-12-17 14:56:32 +05:00
Umar Adilov
82121bac91 fixed slider filter 2025-12-17 14:55:02 +05:00
Umar Adilov
d33a4d941a Fixed deps 2025-12-17 14:09:36 +05:00
Umar Adilov
94ef553982 Frozen files 2025-12-17 14:07:11 +05:00
Umar Adilov
d2b4d046df Upgraded nextjs 2025-12-17 14:05:27 +05:00
Umar Adilov
f98e1fc715 Integrated query builder 2025-12-17 13:41:43 +05:00
Umar Adilov
f96934d27d removed iframe 2025-07-08 13:52:40 +05:00
Umar Adilov
6de16d017f Fixed presenters 2025-07-08 13:49:01 +05:00
BunyodL
86b116dd97 update: center the marker on station select 2025-05-22 10:53:16 +05:00
BunyodL
55a7b32c76 fix: fix the counting 2025-05-22 09:56:47 +05:00
BunyodL
8c2b92d194 update: get counter values from backend 2025-05-22 09:26:52 +05:00
BunyodL
6e3a498d46 refactor: make use-intersection-observer hook 2025-05-22 08:53:50 +05:00
BunyodL
cdc9de27f3 update: add print-btn 2025-05-21 22:22:59 +05:00
BunyodL
88f9fdd25c fix: format-date without localizing 2025-05-21 22:06:33 +05:00
BunyodL
3321395b7f refactor: move transactions-table to a separate folder 2025-05-21 21:52:51 +05:00
BunyodL
20eef84823 fix: make mails dynamic 2025-05-21 20:59:02 +05:00
Umar Adilov
bf134a99d5 Fixed issue with transactions table 2025-05-16 17:46:51 +05:00
Umar Adilov
06b886ca5d added taylordb to allowed media content 2025-05-15 14:49:30 +05:00
Umar Adilov
1daf3ed4e4 added taylordb to allowed media content 2025-05-15 14:46:39 +05:00
Umar Adilov
92c01590fd Added media content for charity 2025-05-15 10:54:43 +05:00
Umar Adilov
93288d1413 Added loader while loading transactions 2025-05-14 20:25:41 +05:00
Umar Adilov
4299f57e2d Added loader while loading transactions 2025-05-14 20:21:29 +05:00
Umar Adilov
64d2c12494 Fixed media content fetch 2025-05-14 20:08:01 +05:00
Umar Adilov
a6d836a543 Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into feat-get-media 2025-05-14 20:05:00 +05:00
Umar Adilov
a2730f0b03 Integrated media content to all pages 2025-05-14 20:04:24 +05:00
Umar Adilov
042186323b Fixed auth issues 2025-05-14 18:29:05 +05:00
Umar Adilov
7ddf6cd35c Fixed issue with transaction table 2025-05-14 17:45:25 +05:00
Umar Adilov
69c2e898cf refactor taylor requests 2025-05-14 16:39:11 +05:00
Umar Adilov
7272c30b2c fixed revalidate path 2025-05-10 18:25:58 +05:00
Umar Adilov
537ed60fa8 Added support for deleting all cache data 2025-05-10 18:14:48 +05:00
Umar Adilov
edb39d5538 Optimized on docker build 2025-05-10 18:07:11 +05:00
BunyodL
4e54957a15 Merge branch 'dev' into feat-get-media 2025-05-10 18:04:17 +05:00
BunyodL
3367b38f23 update: set media from taylor-db 2025-05-10 18:01:43 +05:00
Umar Adilov
6dd88f897c Fixed api endpoints for new API 2025-05-10 16:54:29 +05:00
Umar Adilov
a1c8dd80ec Added docker file for building app 2025-05-10 15:06:07 +05:00
Umar Adilov
c660c18d37 Fixed bug with media provider 2025-05-10 01:04:50 +05:00
BunyodL
9f43dd02d7 feat: add media provider and get madia from use-media-controller hook 2025-05-10 00:29:02 +05:00
BunyodL
39bb647b5c update: get all the text from backend 2025-05-08 00:05:45 +05:00
BunyodL
b405fc315b fix: use review-schema and rtk loading-state 2025-05-07 23:40:28 +05:00
BunyodL
e50187dea1 fix: fix rating selecting 2025-05-07 23:32:37 +05:00
Umar Adilov
54dc9c243b Added create review endpoint 2025-05-07 22:03:37 +05:00
BunyodL
2872d6c1dd fix: change review-form dialog-trigger btn 2025-05-07 21:48:17 +05:00
BunyodL
3e90019de2 feat: add review-form 2025-05-07 13:55:15 +05:00
BunyodL
f5c381c486 fix: fix the styles 2025-05-06 15:56:47 +05:00
BunyodL
3a2c7d42c0 fix: set price-board image in main page 2025-05-06 15:36:31 +05:00
BunyodL
41f012be33 update: add price-board 2025-05-06 15:17:54 +05:00
BunyodL
86a3ad9f81 update: hide loyalty-levels 2025-05-06 15:00:46 +05:00
BunyodL
fa52795738 refactor: change metadata 2025-05-04 13:39:10 +05:00
khadiatullo
79e354278c update: transfe metadata 2025-05-03 16:13:26 +03:00
khadiatullo
dd07b4c099 fix: fix mobile-nav section clients 2025-05-03 15:13:01 +03:00
khadiatullo
01110cb4ec Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into dev 2025-05-03 15:03:44 +03:00
khadiatullo
c701d1cd5a fix: fix animation about page 2025-05-03 15:02:26 +03:00
Umar Adilov
9878d20599 Added geo location support for map 2025-05-03 16:47:30 +05:00
khadiatullo
206f3da23e add: added text 2025-05-03 14:27:53 +03:00
khadiatullo
e481345d75 remove: remove button certifacates 2025-05-03 14:09:22 +03:00
khadiatullo
3c2d5834b2 add: added links for social networks 2025-05-03 13:48:34 +03:00
Umar Adilov
87e2cbf1f2 Included diesel into stations filter 2025-05-03 14:37:31 +05:00
Umar Adilov
bc8322a040 Fixed type issue 2025-05-03 14:29:45 +05:00
Umar Adilov
d6f7fbff37 Integrated certificates & charity page with backend 2025-05-03 14:24:21 +05:00
Umar Adilov
5cdcb8bb02 Fixed auth middleware 2025-05-03 14:00:59 +05:00
Umar Adilov
dd72be5ad8 added authorization middleware & pagination for transactions 2025-05-03 13:45:40 +05:00
Umar Adilov
77c76cd8ef Added cache revalidation logic 2025-05-03 12:47:12 +05:00
khadiatullo
8a64bc6ec2 update: update buttons 2025-05-03 02:15:50 +03:00
khadiatullo
d7aeeec95f update: update buttons 2025-05-03 01:35:10 +03:00
BunyodL
10a55fb685 Merge branch 'fix-bugs' into dev 2025-05-03 02:52:15 +05:00
BunyodL
e8bb8a7830 update: open station-list on-marker-click and add unselect opportunity 2025-05-03 02:50:07 +05:00
BunyodL
9fc4b2018e refactor: use container component 2025-05-03 02:33:06 +05:00
Umar Adilov
886ff726de Changed titles 2025-05-03 02:16:48 +05:00
Umar Adilov
b2cb399e79 Fixed styles 2025-05-03 02:13:25 +05:00
Umar Adilov
8cbdc1a6e4 Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into dev 2025-05-03 02:04:20 +05:00
Umar Adilov
5a5a1503b1 Customized hero container 2025-05-03 02:04:03 +05:00
khadiatullo
05e667cbf9 update: update logo for footer 2025-05-02 23:54:25 +03:00
Umar Adilov
6a069ecf39 Added stypes for partners 2025-05-03 01:42:29 +05:00
BunyodL
1e9e2445e6 Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into dev 2025-05-03 01:09:04 +05:00
BunyodL
cdc70e9c36 feat: make partners-slider 2025-05-03 01:07:50 +05:00
Umar Adilov
7e3c2cf24a Added unauthorized redirect 2025-05-03 00:45:20 +05:00
Umar Adilov
cf37fe67e6 added cursor pointer for tabs 2025-05-03 00:34:14 +05:00
Umar Adilov
2f2977dcbf Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into dev 2025-05-03 00:15:56 +05:00
Umar Adilov
ca4e057fdf Fixed minor issues 2025-05-03 00:15:31 +05:00
BunyodL
c94140545c Merge branch 'fix-map-bugs' into dev 2025-05-03 00:04:57 +05:00
Umar Adilov
4525b0b8c3 Merge branch 'add-pages' into dev 2025-05-02 23:54:45 +05:00
Umar Adilov
c9f36aeb1e Fixed type issues 2025-05-02 23:31:36 +05:00
khadiatullo
c95b1ec8b1 update: update logo 2025-05-02 15:52:42 +03:00
khadiatullo
e86cb6340c Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into add-pages 2025-05-02 15:19:45 +03:00
khadiatullo
d77e4b4610 update: update animation 2025-05-02 14:51:48 +03:00
BunyodL
bb5b331b06 update: make marker clickable, add scroll-to-ivew behavior 2025-05-02 14:35:33 +05:00
ed526338dd Merge pull request 'render-main-page-data' (#12) from render-main-page-data into dev
Reviewed-on: #12
2025-05-02 06:00:33 +05:00
BunyodL
2821025a3e Merge branch 'dev' into render-main-page-data 2025-05-02 05:57:04 +05:00
BunyodL
eb38af7fa7 feat: integrate yandex-maps 2025-05-02 05:54:47 +05:00
khadiatullo
ad57eed6e9 update: animation charity 2025-05-02 03:01:36 +03:00
khadiatullo
c4872b7323 Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into add-pages 2025-05-02 02:45:58 +03:00
khadiatullo
24dcaa0122 update: added images for blocks 2025-05-02 02:39:31 +03:00
khadiatullo
62e544d120 update and add: update adaptability and added Loader 2025-05-01 23:41:12 +03:00
Umar Adilov
93c4b75998 integrate transaction table 2025-05-02 00:49:05 +05:00
Umar Adilov
891fb64339 Added taylor api middleware 2025-05-01 23:27:00 +05:00
Umar Adilov
6deb48239e Fixed issue with SSR 2025-05-01 23:25:37 +05:00
Umar Adilov
148fd20b66 Integrated about us page 2025-05-01 22:52:21 +05:00
a839f37c27 Merge pull request 'update: added adaptability for pages' (#10) from add-pages into dev
Reviewed-on: #10
2025-05-01 22:17:05 +05:00
Umar Adilov
00257ce07b Merge branch 'dev' of https://devgit.oriyo.tj/adilovcode/oriyo_next into add-pages 2025-05-01 22:16:49 +05:00
ff9ac551bf Merge pull request 'render-main-page-data' (#8) from render-main-page-data into dev
Reviewed-on: #8
2025-05-01 22:16:15 +05:00
Umar Adilov
b4236db5d5 Added api for about us page 2025-05-01 21:10:12 +05:00
885d17a15c Merge pull request 'feat/auth' (#9) from feat/auth into dev
Reviewed-on: #9
2025-05-01 20:56:48 +05:00
khadiatullo
2dfcdd096e update: added animation for blocks 2025-05-01 07:31:37 +03:00
khadiatullo
2ed7f07520 update: add adaptability for pages 2025-05-01 05:38:29 +03:00
khadiatullo
e642da34c5 Merge branch 'dev' into add-pages 2025-05-01 05:30:49 +03:00
khadiatullo
cad373568e update: added adaptability for pages 2025-05-01 05:14:46 +03:00
khadiatullo
cc5db77494 Merge branch 'render-main-page-data' into add-pages 2025-05-01 01:21:04 +03:00
khadiatullo
e98c079ba6 change: change the text 2025-05-01 00:58:42 +03:00
BunyodL
f28204416d fix: revert changes 2025-04-30 20:28:25 +05:00
BunyodL
6dc26611c3 fix: fix text display 2025-04-30 18:13:25 +05:00
BunyodL
ec2cd2cf56 Merge branch 'dev' into render-main-page-data 2025-04-30 18:09:12 +05:00
BunyodL
2810c6b9fb update: render main-page data 2025-04-30 17:34:33 +05:00
BunyodL
240aaa81ae update: get main-page-data 2025-04-29 01:22:24 +05:00
119 changed files with 7154 additions and 3065 deletions

32
Dockerfile Normal file
View File

@ -0,0 +1,32 @@
FROM node:20-alpine AS builder
# Enable corepack and install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Disable interactive prompts
ENV CI=true
WORKDIR /app
# Copy package.json and pnpm-lock.yaml first for caching
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy the rest of the files
COPY . .
# Build the application
RUN pnpm build
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=builder /app ./
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@ -1,7 +1,20 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'media.bambooapp.ai',
pathname: '/files/**',
},
{
protocol: 'https',
hostname: 'taylordb.ai',
pathname: '/media/**',
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -7,10 +7,11 @@
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.0.1", "@hookform/resolvers": "^5.0.1",
"@pbe/react-yandex-maps": "^1.2.5",
"@radix-ui/react-collapsible": "^1.1.8", "@radix-ui/react-collapsible": "^1.1.8",
"@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.11", "@radix-ui/react-dropdown-menu": "^2.1.11",
@ -18,30 +19,35 @@
"@radix-ui/react-navigation-menu": "^1.2.10", "@radix-ui/react-navigation-menu": "^1.2.10",
"@radix-ui/react-popover": "^1.1.11", "@radix-ui/react-popover": "^1.1.11",
"@radix-ui/react-select": "^2.2.2", "@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.8", "@radix-ui/react-tabs": "^1.1.8",
"@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@reduxjs/toolkit": "^2.7.0", "@reduxjs/toolkit": "^2.7.0",
"@taylordb/query-builder": "^0.10.1",
"aos": "^2.3.4", "aos": "^2.3.4",
"axios": "^1.9.0", "axios": "^1.9.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookies-next": "^5.1.0", "cookies-next": "^5.1.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"embla-carousel-autoplay": "^8.6.0", "embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"json-to-graphql-query": "^2.3.0", "json-to-graphql-query": "^2.3.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.501.0", "lucide-react": "^0.501.0",
"next": "15.3.1", "next": "16.0.10",
"next-redux-wrapper": "^8.1.0", "next-redux-wrapper": "^8.1.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "19.2.3",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
"react-dom": "^19.0.0", "react-dom": "19.2.3",
"react-hook-form": "^7.56.1", "react-hook-form": "^7.56.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"swiper": "^11.2.6",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tailwindcss-animated": "^2.0.0", "tailwindcss-animated": "^2.0.0",
@ -49,7 +55,6 @@
"zod": "^3.24.3" "zod": "^3.24.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
@ -59,11 +64,11 @@
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^20.17.30", "@types/node": "^20.17.30",
"@types/react": "^19", "@types/react": "19.2.7",
"@types/react-dom": "^19", "@types/react-dom": "19.2.3",
"@typescript-eslint/parser": "^8.30.1", "@typescript-eslint/parser": "^8.30.1",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.1", "eslint-config-next": "16.0.10",
"eslint-config-prettier": "^10.1.2", "eslint-config-prettier": "^10.1.2",
"eslint-plugin-import-x": "^4.10.6", "eslint-plugin-import-x": "^4.10.6",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
@ -75,5 +80,11 @@
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"typescript-eslint": "^8.30.1" "typescript-eslint": "^8.30.1"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
} }
} }

2463
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

BIN
public/logo-new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/map/oriyo-marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 172 KiB

1
public/placeholder.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 142 80" xmlns="http://www.w3.org/2000/svg"><title></title><rect fill="#F2F4F5" height="80" stroke="#E3E4E6" width="142"></rect><path d="M84.8156 47.5402L77.8157 33.9407C77.5479 33.4222 77.0282 33.0703 76.4349 33.0108C75.8434 32.9445 75.2572 33.1876 74.8844 33.6381L68.8854 40.9206L64.9707 38.386C64.5717 38.1293 64.08 38.0392 63.6162 38.1412C63.149 38.2432 62.7447 38.5271 62.4997 38.9249L57.2497 47.4246C56.9242 47.9499 56.9172 48.6026 57.227 49.1381C57.5367 49.6719 58.1195 50 58.7495 50H83.2494C83.8567 50 84.4184 49.694 84.7386 49.1925C85.0589 48.691 85.0851 48.0655 84.8156 47.5402Z" fill="#CACBCC"></path><path d="M67.5 33C69.433 33 71 31.433 71 29.5C71 27.567 69.433 26 67.5 26C65.567 26 64 27.567 64 29.5C64 31.433 65.567 33 67.5 33Z" fill="#CACBCC"></path><defs><clipPath id="clip0"><rect fill="white" height="65" width="127"></rect></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 896 B

View File

@ -1,5 +1,17 @@
import AboutPage from "@/pages-templates/about"; import AboutPage from '@/pages-templates/about';
export default function About(){ import { fetchAboutUsPageContent } from '@/features/pages/services/pages.service';
return <AboutPage/>
export const metadata = {
title: 'О нас',
description:
'Узнайте больше о нашей компании, истории и ценностях. Качественное топливо и отличный сервис.',
};
export default async function About() {
const data = await fetchAboutUsPageContent();
if (!data) return null;
return <AboutPage content={data} />;
} }

View File

@ -1,3 +1,15 @@
import {
presentCertificates,
presentCharities,
presentDiscounts,
presentHistoryItems,
presentJobs,
presentPartners,
presentReviews,
presentStations,
presentTeamMembers,
} from '../presenters';
export type Root<T> = { records: T[] }; export type Root<T> = { records: T[] };
export interface Image { export interface Image {
@ -10,46 +22,98 @@ export interface Select {
} }
export type Discount = Root<{ export type Discount = Root<{
_name: string; zagolovok: string;
_opisanie: string; opisanie: string;
_do: string; do: string;
_foto: Image[]; foto: Image[];
}>; }>;
export type Job = Root<{ export type Job = Root<{
id: number; id: number;
_name: string; zagolovok: string;
_type: Select[]; tip: Select[];
_localtio: Select[]; lokaciya: Select[];
_tags: Select[]; tegi: Select[];
}>; }>;
export type Partner = Root<{ export type Partner = Root<{
id: number; id: number;
_name: string; nazvanie: string;
_image: Image[]; izobrozhenie: Image[];
}>; }>;
export type Station = Root<{ export type Station = Root<{
_name: string; imya: string;
_opisanie: string; opisanie: string;
_adress: string; adress: string;
_chasyRaboty: Select; chasyRaboty: Select[];
_lat: number; lat: number;
_long: number; long: number;
_avtomojka: boolean; avtomojka: boolean;
_dtCopy: boolean; ai92: boolean;
_ai92Copy: boolean; ai95: boolean;
_ai95Copy: boolean; z100: boolean;
_z100Copy: boolean; propan: boolean;
_propanCopy: boolean; zaryadnayaStanciya: boolean;
_zaryadnayaStanci: boolean; dt: boolean;
_miniMarketCop: boolean; miniMarket: boolean;
_region: Select; tualet: boolean;
_foto: Image[]; region: Select[];
foto: Image[];
}>; }>;
export type TextResponse = Root<{ export type TextResponse = Root<{
_name: string; klyuchNeIzmenyat: string;
_znachenie: string | null; znachenie: string | null;
}>; }>;
export type MediaResponse = Root<{
mestopolozheniya: string;
foto: Image[];
klyuchNeIzmenyat: string;
}>;
export type Team = Root<{
foto: Image[];
zvanie: string;
polnoeImya: string;
}>;
export type History = Root<{
zagolovok: string;
god: string;
opisanie: string;
}>;
export type Review = Root<{
id: number;
polnoeImya: string;
otzyv: string;
rejting: number;
}>;
export type Charity = Root<{
zagolovok: string;
opisanie: string;
data: string;
lokaciya: string;
foto: Image[];
}>;
export type Certificate = Root<{
nazvanie: string;
opisanie: string;
dataVydachi: string;
dejstvitelenDo: string;
foto: Image[];
}>;
export type TeamMembers = ReturnType<typeof presentTeamMembers>;
export type HistoryItems = ReturnType<typeof presentHistoryItems>;
export type Stations = ReturnType<typeof presentStations>;
export type Partners = ReturnType<typeof presentPartners>;
export type Jobs = ReturnType<typeof presentJobs>;
export type Discounts = ReturnType<typeof presentDiscounts>;
export type Reviews = ReturnType<typeof presentReviews>;
export type Charities = ReturnType<typeof presentCharities>;
export type Certificates = ReturnType<typeof presentCertificates>;

View File

@ -0,0 +1,33 @@
import {
Certificates,
Charities,
Discounts,
HistoryItems,
Jobs,
Partners,
Reviews,
Stations,
TeamMembers,
} from '.';
export type AboutUsPageData = {
team: TeamMembers;
history: HistoryItems;
stations: Stations;
reviews: Reviews;
};
export type MainPageData = {
discounts: Discounts;
jobs: Jobs;
partners: Partners;
stations: Stations;
};
export type CharityPageData = {
charities: Charities;
};
export type CertificatesPageData = {
certificates: Certificates;
};

View File

@ -5,6 +5,10 @@ const oriyoClient = new Axios({
headers: { headers: {
'Content-type': 'application/json', 'Content-type': 'application/json',
}, },
transformResponse: (response) => {
return JSON.parse(response);
},
}); });
export default oriyoClient; export default oriyoClient;

View File

@ -2,7 +2,7 @@ import { jsonToGraphQLQuery } from 'json-to-graphql-query';
export const requestTaylor = async (query: object, variables?: object) => { export const requestTaylor = async (query: object, variables?: object) => {
const body = JSON.stringify({ const body = JSON.stringify({
query: jsonToGraphQLQuery({ query }), query: jsonToGraphQLQuery(query),
variables, variables,
}); });
@ -12,6 +12,7 @@ export const requestTaylor = async (query: object, variables?: object) => {
headers: { headers: {
Authorization: process.env.TAYLOR_API_TOKEN || '', Authorization: process.env.TAYLOR_API_TOKEN || '',
'Content-type': 'application/json', 'Content-type': 'application/json',
schema: 'readable',
}, },
}); });

View File

@ -0,0 +1 @@
export class AuthorizationError extends Error {}

View File

@ -1,61 +1,126 @@
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { import {
Certificate,
Charity,
Discount, Discount,
History,
Image, Image,
Job, Job,
MediaResponse,
Partner, Partner,
Review,
Select,
Station, Station,
Team,
TextResponse, TextResponse,
} from '../@types'; } from '../@types';
export const presentImage = (images: Image[]) => export const presentImage = (images: Image[]) =>
isEmpty(images) ? null : `${process.env.TAYLOR_MEDIA_URL}/${images[0].url}`; isEmpty(images) ? null : `${process.env.TAYLOR_MEDIA_URL}/${images[0].url}`;
export const presentSelect = (selectItems: Select[]) =>
!isEmpty(selectItems) ? selectItems[0].name : null;
export const presentPartners = (partners: Partner) => export const presentPartners = (partners: Partner) =>
partners.records.map((record) => ({ partners.records.map((record, index) => ({
name: record._name, id: index + 1,
poster: presentImage(record._image), name: record.nazvanie,
poster: presentImage(record.izobrozhenie),
})); }));
export const presentJobs = (jobs: Job) => export const presentJobs = (jobs: Job) =>
jobs.records.map((job) => ({ jobs.records.map((job, index) => ({
name: job._name, id: index + 1,
tags: job._tags.map((tag) => tag.name), name: job.zagolovok,
location: !isEmpty(job._localtio) ? job._localtio[0].name : null, tags: job.tegi.map((tag) => tag.name),
type: !isEmpty(job._type) ? job._type[0].name : null, location: presentSelect(job.lokaciya),
type: presentSelect(job.tip),
}));
export const presentTeamMembers = (members: Team) =>
members.records.map((member) => ({
name: member.polnoeImya,
photo: presentImage(member.foto),
profession: member.zvanie,
}));
export const presentHistoryItems = (historyItems: History) =>
historyItems.records.map((item) => ({
name: item.zagolovok,
year: item.god,
description: item.opisanie,
})); }));
export const presentDiscounts = (discounts: Discount) => export const presentDiscounts = (discounts: Discount) =>
discounts.records.map((discount) => ({ discounts.records.map((discount, index) => ({
name: discount._name, id: index + 1,
description: discount._opisanie, name: discount.zagolovok,
expiresAt: discount._do, description: discount.opisanie,
image: presentImage(discount._foto), expiresAt: discount.do,
image: presentImage(discount.foto),
})); }));
export const presentStations = (stations: Station) => export const presentStations = (stations: Station) =>
stations.records.map((station: any) => ({ stations.records.map((station, index) => ({
name: station._name, id: index + 1,
description: station._opisanie, name: station.imya,
address: station._adress, description: station.opisanie,
workingHours: station._chasyRaboty?.name || null, address: station.adress,
latitude: station._lat, workingHours: presentSelect(station.chasyRaboty),
longitude: station._long, latitude: station.lat,
carWash: station._avtomojka || false, longitude: station.long,
ai92: station._dtCopy || false, carWash: station.avtomojka || false,
ai95: station._ai92Copy || false, ai92: station.ai92 || false,
z100: station._ai95Copy || false, ai95: station.ai95 || false,
propan: station._z100Copy || false, dt: station.dt || false,
electricCharge: station._propanCopy || false, z100: station.z100 || false,
miniMarket: station._zaryadnayaStanci || false, propan: station.propan || false,
toilet: station._miniMarketCop || false, electricCharge: station.zaryadnayaStanciya || false,
region: !isEmpty(station._region) ? station._region[0].name : null, miniMarket: station.miniMarket || false,
image: presentImage(station._foto), toilet: station.tualet || false,
region: presentSelect(station.region),
image: presentImage(station.foto),
})); }));
export const presentTexts = (texts: TextResponse) => export const presentTexts = (texts: TextResponse) =>
texts.records.map((item) => ({ texts.records.map((item) => ({
key: item._name, key: item.klyuchNeIzmenyat,
value: item._znachenie, value: item.znachenie,
}));
export const presentMedia = (media: MediaResponse) => {
return media.records.map((record) => ({
key: record.klyuchNeIzmenyat,
name: record.mestopolozheniya,
photo: presentImage(record.foto),
}));
};
export const presentReviews = (reviews: Review) =>
reviews.records.map((review) => ({
id: review.id,
fullname: review.polnoeImya,
review: review.otzyv,
rating: review.rejting,
}));
export const presentCharities = (charities: Charity) =>
charities.records.map((charity, index) => ({
id: index + 1,
name: charity.zagolovok,
description: charity.opisanie,
date: charity.data,
location: charity.lokaciya,
image: presentImage(charity.foto),
}));
export const presentCertificates = (certificates: Certificate) =>
certificates.records.map((certificate, index) => ({
id: index + 1,
name: certificate.nazvanie,
description: certificate.opisanie,
issuedAt: certificate.dataVydachi,
validUntil: certificate.dejstvitelenDo,
image: presentImage(certificate.foto),
})); }));

View File

@ -0,0 +1,238 @@
import { isEmpty } from 'lodash';
import {
AttachmentColumnValue,
TableRaws,
} from '@/shared/types/database.types';
// Helper to get image URL from Attachment array
export const getAttachmentUrl = (
attachments: AttachmentColumnValue[] | undefined | null,
): string | null => {
if (isEmpty(attachments) || !attachments?.[0]) return null;
const attachment = attachments[0];
return `${process.env.TAYLOR_MEDIA_URL}/${attachment.url}`;
};
// Helper to get link select name (link to selectTable returns object with name)
export const getLinkSelectName = (
link: { name: string } | undefined | null,
): string | null => {
return link?.name || null;
};
// Helper to get multiple link select names (for arrays of links)
export const getLinkSelectNames = (
links: Array<{ name: string }> | undefined | null,
): string[] => {
if (!links || isEmpty(links)) return [];
return links.map((link) => link.name);
};
// Presenters for TaylorDB query builder results (direct array format, no wrapper)
export const presentPartnersFromTaylor = (
partners: TableRaws<'partnyory'>[],
): Array<{ id: number; name: string; poster: string | null }> => {
return partners.map((partner, index) => ({
id: index + 1,
name: partner.nazvanie || '',
poster: getAttachmentUrl(partner.izobrozhenie),
}));
};
export const presentJobsFromTaylor = (
jobs: TableRaws<'vakansii'>[],
): Array<{
id: number;
name: string;
tags: string[];
location: string | null;
type: string | null;
}> => {
return jobs.map((job, index) => ({
id: index + 1,
name: job.zagolovok || '',
// tegi is a LinkColumnType, so it returns objects when loaded with .with()
tags: Array.isArray(job.tegi)
? (job.tegi as Array<{ name: string }>).map((tag) => tag.name)
: [],
// tip and lokaciya are SingleSelectColumnType, which return arrays of strings
location: job.lokaciya?.[0] || null,
type: job.tip?.[0] || null,
}));
};
export const presentDiscountsFromTaylor = (
discounts: TableRaws<'akcii'>[],
): Array<{
id: number;
name: string;
description: string;
expiresAt: string;
image: string | null;
}> => {
return discounts.map((discount, index) => ({
id: index + 1,
name: discount.zagolovok || '',
description: discount.opisanie || '',
expiresAt: discount.do || '',
image: getAttachmentUrl(discount.foto),
}));
};
export const presentStationsFromTaylor = (
stations: TableRaws<'azs'>[],
): Array<{
id: number;
name: string;
description: string;
address: string;
workingHours: string | null;
latitude: number;
longitude: number;
carWash: boolean;
ai92: boolean;
ai95: boolean;
dt: boolean;
z100: boolean;
propan: boolean;
electricCharge: boolean;
miniMarket: boolean;
toilet: boolean;
region: string | null;
image: string | null;
}> => {
return stations.map((station, index) => ({
id: index + 1,
name: station.imya || '',
description: station.opisanie || '',
address: station.adress || '',
// chasyRaboty and region are SingleSelectColumnType, which return arrays of strings
workingHours: station.chasyRaboty?.[0] || null,
// Parse string coordinates to numbers
latitude: parseFloat(station.lat || '0') || 0,
longitude: parseFloat(station.long || '0') || 0,
carWash: station.avtomojka || false,
ai92: station.ai92 || false,
ai95: station.ai95 || false,
dt: station.dt || false,
z100: station.z100 || false,
propan: station.propan || false,
electricCharge: station.zaryadnayaStanciya || false,
miniMarket: station.miniMarket || false,
toilet: station.tualet || false,
region: station.region?.[0] || null,
image: getAttachmentUrl(station.foto),
}));
};
export const presentTeamMembersFromTaylor = (
members: TableRaws<'komanda'>[],
): Array<{
name: string;
photo: string | null;
profession: string;
}> => {
return members.map((member) => ({
name: member.polnoeImya || '',
photo: getAttachmentUrl(member.foto),
profession: member.zvanie || '',
}));
};
export const presentHistoryItemsFromTaylor = (
historyItems: TableRaws<'istoriyaKompanii'>[],
): Array<{
name: string;
year: string;
description: string;
}> => {
return historyItems.map((item) => ({
name: item.zagolovok || '',
year: String(item.god || ''),
description: item.opisanie || '',
}));
};
export const presentReviewsFromTaylor = (
reviews: TableRaws<'otzyvy'>[],
): Array<{
id: number;
fullname: string;
review: string;
rating: number;
}> => {
return reviews.map((review) => ({
id: review.id || 0,
fullname: review.polnoeImya || '',
review: review.otzyv || '',
rating: review.rejting || 0,
}));
};
export const presentCharitiesFromTaylor = (
charities: TableRaws<'blagotvoritelnyjFond'>[],
): Array<{
id: number;
name: string;
description: string;
date: string;
location: string;
image: string | null;
}> => {
return charities.map((charity, index) => ({
id: index + 1,
name: charity.zagolovok || '',
description: charity.opisanie || '',
date: charity.data || '',
location: charity.lokaciya || '',
image: getAttachmentUrl(charity.foto),
}));
};
export const presentCertificatesFromTaylor = (
certificates: TableRaws<'sertifikaty'>[],
): Array<{
id: number;
name: string;
description: string;
issuedAt: string;
validUntil: string;
image: string | null;
}> => {
return certificates.map((certificate, index) => ({
id: index + 1,
name: certificate.nazvanie || '',
description: certificate.opisanie || '',
issuedAt: certificate.dataVydachi || '',
validUntil: certificate.dejstvitelenDo || '',
image: getAttachmentUrl(certificate.foto),
}));
};
export const presentTextsFromTaylor = (
texts: TableRaws<'tekstovyjKontentSajta'>[],
): Array<{
key: string;
value: string | null;
}> => {
return texts.map((item) => ({
key: item.klyuchNeIzmenyat || '',
value: item.znachenie || null,
}));
};
export const presentMediaFromTaylor = (
media: TableRaws<'mediaKontentSajta'>[],
): Array<{
key: string;
name: string;
photo: string | null;
}> => {
return media.map((record) => ({
key: record.klyuchNeIzmenyat || '',
name: record.mestopolozheniya || '',
photo: getAttachmentUrl(record.foto),
}));
};

View File

@ -1,26 +1,72 @@
import { EnumType, VariableType } from 'json-to-graphql-query';
export const stationsRequest = { export const stationsRequest = {
_azs: { azs: {
records: { records: {
_name: true, imya: true,
_opisanie: true, opisanie: true,
_adress: true, adress: true,
_chasyRaboty: { chasyRaboty: {
name: true, name: true,
}, },
_lat: true, lat: true,
_long: true, long: true,
_avtomojka: true, avtomojka: true,
_dtCopy: true, // ai92 ai92: true,
_ai92Copy: true, // ai95 ai95: true,
_ai95Copy: true, // z100 z100: true,
_z100Copy: true, // propan propan: true,
_propanCopy: true, // electricCharge zaryadnayaStanciya: true,
_zaryadnayaStanci: true, // miniMarket dt: true,
_miniMarketCop: true, // toilet miniMarket: true,
_region: { tualet: true,
region: {
name: true, name: true,
}, },
_foto: { foto: {
url: true,
},
},
},
};
export const stationsWithImageRequest = {
azs: {
__args: {
filtersSet: {
conjunction: new EnumType('and'),
filtersSet: [
{
field: new EnumType('foto'),
operator: 'isNotEmpty',
value: [],
},
],
},
},
records: {
imya: true,
opisanie: true,
adress: true,
chasyRaboty: {
name: true,
},
lat: true,
long: true,
avtomojka: true,
ai92: true,
ai95: true,
z100: true,
propan: true,
zaryadnayaStanciya: true,
dt: true,
miniMarket: true,
tualet: true,
region: {
name: true,
},
foto: {
url: true, url: true,
}, },
}, },
@ -28,10 +74,10 @@ export const stationsRequest = {
}; };
export const partnersRequest = { export const partnersRequest = {
_partners: { partnyory: {
records: { records: {
_name: true, nazvanie: true,
_image: { izobrozhenie: {
url: true, url: true,
}, },
}, },
@ -39,16 +85,16 @@ export const partnersRequest = {
}; };
export const jobsRequest = { export const jobsRequest = {
_vacancies: { vakansii: {
records: { records: {
_name: true, zagolovok: true,
_tags: { tegi: {
name: true, name: true,
}, },
_type: { tip: {
name: true, name: true,
}, },
_localtio: { lokaciya: {
name: true, name: true,
}, },
}, },
@ -56,23 +102,123 @@ export const jobsRequest = {
}; };
export const discountsRequest = { export const discountsRequest = {
_akcii: { akcii: {
records: { records: {
_name: true, zagolovok: true,
_opisanie: true, opisanie: true,
_do: true, do: true,
_foto: { foto: {
url: true, name: true,
}, },
}, },
}, },
}; };
export const textsRequest = { export const textsRequest = {
_kontentSajta: { tekstovyjKontentSajta: {
records: { records: {
_name: true, znachenie: true,
_znachenie: true, klyuchNeIzmenyat: true,
},
},
};
export const mediaRequest = {
mediaKontentSajta: {
records: {
mestopolozheniya: true,
foto: {
id: true,
url: true,
},
klyuchNeIzmenyat: true,
},
},
};
export const teamRequest = {
komanda: {
records: {
foto: {
url: true,
},
zvanie: true,
polnoeImya: true,
},
},
};
export const historyRequest = {
istoriyaKompanii: {
records: {
zagolovok: true,
god: true,
opisanie: true,
},
},
};
export const reviewsRequest = {
otzyvy: {
__args: {
filtersSet: {
conjunction: new EnumType('and'),
filtersSet: [
{
field: new EnumType('status'),
operator: 'contains',
value: 'Опубликовано',
},
],
},
},
records: {
id: true,
polnoeImya: true,
otzyv: true,
rejting: true,
},
},
};
export const charityRequest = {
blagotvoritelnyjFond: {
records: {
zagolovok: true,
opisanie: true,
data: true,
lokaciya: true,
foto: {
url: true,
},
},
},
};
export const certificatesRequest = {
sertifikaty: {
records: {
nazvanie: true,
opisanie: true,
dataVydachi: true,
dejstvitelenDo: true,
foto: {
url: true,
},
},
},
};
export const createReviewMutation = {
__variables: {
review: 'TableOtzyvyMutationParameters',
},
otzyvy: {
createRecord: {
__args: {
records: [new VariableType('review')],
},
id: true,
}, },
}, },
}; };

View File

@ -1,13 +0,0 @@
import {
discountsRequest,
jobsRequest,
partnersRequest,
stationsRequest,
} from './common';
export const mainPageRequest = {
...partnersRequest,
...jobsRequest,
...discountsRequest,
...stationsRequest,
};

View File

@ -0,0 +1,11 @@
import { NextRequest } from 'next/server';
export const getParams = (request: NextRequest) =>
Array.from(request.nextUrl.searchParams.entries()).reduce(
(pr, cr) => {
pr[cr[0]] = cr[1];
return pr;
},
{} as Record<string, string>,
);

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod'; import { z } from 'zod';
import oriyoClient from '@/app/api-utlities/utilities/oriyo.client'; import oriyoClient from '@/app/api-utlities/clients/oriyo.client';
import { loginFormSchema } from '@/entities/auth/model/validation/login-form.schema'; import { loginFormSchema } from '@/entities/auth/model/validation/login-form.schema';
@ -14,37 +14,41 @@ const routeHandler = async (req: NextRequest) => {
.merge(z.object({ type: z.enum(['bonus', 'corporate']) })) .merge(z.object({ type: z.enum(['bonus', 'corporate']) }))
.parse(body); .parse(body);
try { const oriyoResponse = await (() => {
const oriyoResponse = await oriyoClient.get('/client/login', { switch (validatedBody.type) {
params: { case 'corporate':
type: validatedBody.type, return oriyoClient.get('/corporatecard', {
phone: validatedBody.phoneNumber, params: {
uid: validatedBody.cardNumber, phone: validatedBody.phoneNumber,
}, uid: validatedBody.cardNumber,
}); },
});
const { token, card_id } = JSON.parse(oriyoResponse.data); default:
return oriyoClient.get('/bonuscard', {
if (!token) { params: {
return NextResponse.json({ error: 'Credentials error' }, { status: 401 }); phone: validatedBody.phoneNumber,
uid: validatedBody.cardNumber,
},
});
} }
})();
const response = NextResponse.json({ success: true }); if (oriyoResponse.data.error)
return NextResponse.json({ error: 'Credentials error' }, { status: 401 });
response.cookies.set( const response = NextResponse.json({ success: true });
`${validatedBody.type}__token`,
JSON.stringify({ token, card_id }),
{
path: '/',
maxAge: 2 * 60 * 60,
},
);
return response; response.cookies.set(
} catch (error) { `${validatedBody.type}__token`,
console.error('login error:', error); JSON.stringify(oriyoResponse.data),
return NextResponse.json({ error: 'Server error' }, { status: 500 }); {
} path: '/',
maxAge: 2 * 60 * 60,
},
);
return response;
}; };
export const POST = validationErrorHandler(routeHandler); export const POST = validationErrorHandler(routeHandler);

View File

@ -1,31 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'; import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextRequest } from 'next/server';
import oriyoClient from '@/app/api-utlities/utilities/oriyo.client'; import oriyoClient from '@/app/api-utlities/clients/oriyo.client';
import { AuthorizationError } from '@/app/api-utlities/errors/authorization.error';
import { authorizationMiddleware } from '../../middlewares/auth.middleware';
import { validationErrorHandler } from '../../middlewares/error-handler.middleware'; import { validationErrorHandler } from '../../middlewares/error-handler.middleware';
export const routeHandler = async (req: NextRequest) => { const routeHandler = async (req: NextRequest, requestCookie: RequestCookie) => {
const bonusTokenData = req.cookies.get('bonus__token'); const { card_id, token } = JSON.parse(requestCookie.value);
if (!bonusTokenData) { const oriyoResponse = await oriyoClient.get('/bonuscardinfo', {
return NextResponse.json(
{ error: 'User does not have access' },
{ status: 401 },
);
}
const { card_id, token } = JSON.parse(bonusTokenData.value);
const oriyoResponse = await oriyoClient.get('/client/info', {
params: { params: {
card_id, card_id,
token, token,
}, },
}); });
return new Response(oriyoResponse.data, { if (oriyoResponse.status === 401) {
throw new AuthorizationError();
}
return new Response(JSON.stringify(oriyoResponse.data), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
}; };
export const GET = validationErrorHandler(routeHandler); export const GET = validationErrorHandler(
authorizationMiddleware(routeHandler, 'bonus__token'),
);

View File

@ -0,0 +1,66 @@
import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextRequest } from 'next/server';
import { z } from 'zod';
import oriyoClient from '@/app/api-utlities/clients/oriyo.client';
import { AuthorizationError } from '@/app/api-utlities/errors/authorization.error';
import { getParams } from '@/app/api-utlities/utilities/get-params';
import { authorizationMiddleware } from '../../middlewares/auth.middleware';
import { validationErrorHandler } from '../../middlewares/error-handler.middleware';
const validatedSchema = z.object({
start_date: z.string().optional(),
end_date: z.string().optional(),
limit: z.coerce.number(),
page: z.coerce.number(),
});
const routeHandler = async (req: NextRequest, requestCookie: RequestCookie) => {
const validatedRequest = validatedSchema.parse(getParams(req));
const { card_id, token } = JSON.parse(requestCookie.value);
const oriyoResponse = await oriyoClient.get('/bonuscardts', {
params: {
card_id,
token,
limit: validatedRequest.limit,
page: validatedRequest.page,
type: 'bonus',
sort: 'id',
direction: 'desc',
start_date: validatedRequest.start_date,
end_date: validatedRequest.end_date,
},
});
if (oriyoResponse.status === 404)
return new Response(
JSON.stringify({
transactions: [],
card_id,
current_page: validatedRequest.page,
limit: validatedRequest.limit,
total_records: 0,
total_pages: 0,
}),
{
headers: { 'Content-Type': 'application/json' },
},
);
if (oriyoResponse.status === 401) {
throw new AuthorizationError();
}
if (oriyoResponse.data.error) throw oriyoResponse.data;
return new Response(JSON.stringify(oriyoResponse.data), {
headers: { 'Content-Type': 'application/json' },
});
};
export const GET = validationErrorHandler(
authorizationMiddleware(routeHandler, 'bonus__token'),
);

View File

@ -0,0 +1,16 @@
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export async function GET() {
try {
revalidatePath('/', 'layout');
revalidatePath('/', 'page');
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json(
{ error: 'Failed to drop cache', detail: err },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,18 @@
import { omit } from 'lodash';
import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextRequest } from 'next/server';
import { authorizationMiddleware } from '../../middlewares/auth.middleware';
import { validationErrorHandler } from '../../middlewares/error-handler.middleware';
const routeHandler = async (req: NextRequest, requestCookie: RequestCookie) => {
const parsedData = JSON.parse(requestCookie.value);
return new Response(JSON.stringify(omit(parsedData, 'token')), {
headers: { 'Content-Type': 'application/json' },
});
};
export const GET = validationErrorHandler(
authorizationMiddleware(routeHandler, 'corporate__token'),
);

View File

@ -0,0 +1,65 @@
import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextRequest } from 'next/server';
import { z } from 'zod';
import oriyoClient from '@/app/api-utlities/clients/oriyo.client';
import { AuthorizationError } from '@/app/api-utlities/errors/authorization.error';
import { getParams } from '@/app/api-utlities/utilities/get-params';
import { authorizationMiddleware } from '../../middlewares/auth.middleware';
import { validationErrorHandler } from '../../middlewares/error-handler.middleware';
const validatedSchema = z.object({
start_date: z.string().optional(),
end_date: z.string().optional(),
limit: z.coerce.number(),
page: z.coerce.number(),
});
const routeHandler = async (req: NextRequest, requestCookie: RequestCookie) => {
const validatedRequest = validatedSchema.parse(getParams(req));
const { group_id, token } = JSON.parse(requestCookie.value);
const oriyoResponse = await oriyoClient.get('/corporatecardts', {
params: {
group_id,
token,
limit: validatedRequest.limit,
page: validatedRequest.page,
type: 'corporate',
sort: 'id',
direction: 'desc',
start_date: validatedRequest.start_date,
end_date: validatedRequest.end_date,
},
});
if (oriyoResponse.status === 404)
return new Response(
JSON.stringify({
transactions: [],
current_page: validatedRequest.page,
limit: validatedRequest.limit,
total_records: 0,
total_pages: 0,
}),
{
headers: { 'Content-Type': 'application/json' },
},
);
if (oriyoResponse.status === 401) {
throw new AuthorizationError();
}
if (oriyoResponse.data.error) throw oriyoResponse.data;
return new Response(JSON.stringify(oriyoResponse.data), {
headers: { 'Content-Type': 'application/json' },
});
};
export const GET = validationErrorHandler(
authorizationMiddleware(routeHandler, 'corporate__token'),
);

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthorizationError } from '@/app/api-utlities/errors/authorization.error';
export const authorizationMiddleware =
(handler: Function, authorizationTokenKey: string) =>
async (req: NextRequest, ...args: any[]) => {
const requestedToken = req.cookies.get(authorizationTokenKey);
if (!requestedToken) {
return NextResponse.json(
{ error: 'User does not have access' },
{ status: 401 },
);
}
try {
return await handler(req, requestedToken, ...args);
} catch (error) {
if (error instanceof AuthorizationError) {
const response = NextResponse.json(
{ message: 'Authorization session was timed out' },
{ status: 401 },
);
response.cookies.delete(authorizationTokenKey);
return response;
}
throw error;
}
};

View File

@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
export const validationErrorHandler = export const validationErrorHandler =
(handler: Function) => async (req: NextRequest, res: NextResponse) => { (handler: Function) =>
async (req: NextRequest, ...args: any[]) => {
try { try {
return await handler(req, res); return await handler(req, ...args);
} catch (error) { } catch (error) {
if (error instanceof ZodError) if (error instanceof ZodError)
return NextResponse.json({ message: error.format() }, { status: 400 }); return NextResponse.json({ message: error.format() }, { status: 400 });

View File

@ -1,28 +0,0 @@
import {
presentDiscounts,
presentJobs,
presentPartners,
presentStations,
} from '@/app/api-utlities/presenters';
import { mainPageRequest } from '@/app/api-utlities/requests/main-page.request';
import { requestTaylor } from '@/app/api-utlities/utilities/taylor.client';
import { validationErrorHandler } from '../../middlewares/error-handler.middleware';
const routeHandler = async (request: Request) => {
const response = await requestTaylor(mainPageRequest);
return new Response(
JSON.stringify({
partners: presentPartners(response.data._partners),
jobs: presentJobs(response.data._vacancies),
discounts: presentDiscounts(response.data._akcii),
stations: presentStations(response.data._azs),
}),
{
headers: { 'Content-Type': 'application/json' },
},
);
};
export const GET = validationErrorHandler(routeHandler);

View File

@ -0,0 +1,27 @@
import { NextRequest } from 'next/server';
import { requestTaylor } from '@/app/api-utlities/clients/taylor.client';
import { createReviewMutation } from '@/app/api-utlities/requests/common';
import { reviewSchema } from '@/features/review-form/model/review-form.schema';
export const POST = async (req: NextRequest) => {
const body = await req.json();
const validatedRequest = reviewSchema.parse(body);
await requestTaylor(
{ mutation: createReviewMutation },
{
review: {
polnoeImya: validatedRequest.name,
otzyv: validatedRequest.reviewMessage,
rejting: validatedRequest.rating,
},
},
);
return new Response(JSON.stringify({ success: true }), {
status: 201,
});
};

View File

@ -1,14 +0,0 @@
import { presentTexts } from '@/app/api-utlities/presenters';
import { textsRequest } from '@/app/api-utlities/requests/common';
import { requestTaylor } from '@/app/api-utlities/utilities/taylor.client';
export async function GET(request: Request) {
const response = await requestTaylor(textsRequest);
return new Response(
JSON.stringify(presentTexts(response.data._kontentSajta)),
{
headers: { 'Content-Type': 'application/json' },
},
);
}

View File

@ -1,5 +1,17 @@
import { CharityPage } from "@/pages-templates/charity" import { CharityPage } from '@/pages-templates/charity';
export default function Charity() { import { fetchCharityPageContent } from '@/features/pages/services/pages.service';
return <CharityPage />
export const metadata = {
title: 'Благотворительность',
description:
'Благотворительные проекты и инициативы Ориё. Мы помогаем обществу и заботимся о будущем.',
};
export default async function Charity() {
const data = await fetchCharityPageContent();
if (!data) return null;
return <CharityPage content={data} />;
} }

View File

@ -1,5 +1,17 @@
import { CertificatesPage } from '@/pages-templates/clients/certificates'; import { CertificatesPage } from '@/pages-templates/clients/certificates';
export default function Certificates() { import { fetchCertificatesPageContent } from '@/features/pages/services/pages.service';
return <CertificatesPage />;
export const metadata = {
title: 'Сертификаты',
description:
'Ориё придерживается высоких стандартов качества и безопасности.',
};
export default async function Certificates() {
const data = await fetchCertificatesPageContent();
if (!data) return null;
return <CertificatesPage content={data} />;
} }

View File

@ -1,5 +1,11 @@
import { LoyaltyPage } from '@/pages-templates/clients/loyalty'; import { LoyaltyPage } from '@/pages-templates/clients/loyalty';
export const metadata = {
title: 'Программа лояльности',
description:
'Программа лояльности Ориё: накапливайте баллы и получайте скидки на топливо и услуги.',
};
export default function Loyalty() { export default function Loyalty() {
return <LoyaltyPage />; return <LoyaltyPage />;
} }

View File

@ -1,5 +1,11 @@
import { ClientsPage } from "@/pages-templates/clients" import { ClientsPage } from "@/pages-templates/clients"
export const metadata = {
title: 'Клиентам',
description:
'Информация для клиентов: программа лояльности, топливные карты, сертификаты и способы оплаты.',
};
export default function Clients() { export default function Clients() {
return <ClientsPage />; return <ClientsPage />;
} }

View File

@ -1,9 +1,13 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import { textControlApi } from '@/shared/language/api/text-control.api'; import {
fetchMediaContent,
fetchTextContent,
} from '@/features/pages/services/pages.service';
import { Providers } from '@/shared/providers/providers'; import { Providers } from '@/shared/providers/providers';
import { makeStore } from '@/shared/store'; import { MediaItem } from '@/shared/types/media.type';
import { TextItem } from '@/shared/types/text.types'; import { TextItem } from '@/shared/types/text.types';
import { Footer } from '@/widgets/footer'; import { Footer } from '@/widgets/footer';
@ -14,7 +18,10 @@ import './globals.css';
const inter = Inter({ subsets: ['latin', 'cyrillic'] }); const inter = Inter({ subsets: ['latin', 'cyrillic'] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'GasNetwork - Сеть заправок в Таджикистане', title: {
template: '%s | Ориё - Сеть заправок в Таджикистане',
default: 'Ориё - Сеть заправок в Таджикистане',
},
description: description:
'Качественное топливо, удобное расположение и отличный сервис для наших клиентов', 'Качественное топливо, удобное расположение и отличный сервис для наших клиентов',
}; };
@ -24,12 +31,11 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const store = makeStore(); // Fetch texts and media using TaylorDB query builder
const [textItems, mediaItems] = await Promise.all([
const response = await store.dispatch( fetchTextContent(),
textControlApi.endpoints.fetchText.initiate(), fetchMediaContent(),
); ]);
return ( return (
<html <html
lang='ru' lang='ru'
@ -37,8 +43,11 @@ export default async function RootLayout({
className='scroll-smooth' className='scroll-smooth'
style={{ scrollBehavior: 'smooth' }} style={{ scrollBehavior: 'smooth' }}
> >
<body className={`${inter.className} antialiased`}> <body className={`${inter.className} min-w-2xs antialiased`}>
<Providers textItems={response.data as TextItem[]}> <Providers
textItems={textItems as TextItem[]}
mediaItems={mediaItems as MediaItem[]}
>
<Header /> <Header />
{children} {children}
<Footer /> <Footer />

View File

@ -1,4 +1,7 @@
import { fetchMainPageContent } from '@/features/pages/services/pages.service';
import { AboutSection } from '@/widgets/about-section'; import { AboutSection } from '@/widgets/about-section';
import { AppDownloadSection } from '@/widgets/app-download-section';
import { CharitySection } from '@/widgets/charity-section'; import { CharitySection } from '@/widgets/charity-section';
import { CtaSection } from '@/widgets/cta-section'; import { CtaSection } from '@/widgets/cta-section';
import { HeroSection } from '@/widgets/hero-section'; import { HeroSection } from '@/widgets/hero-section';
@ -8,16 +11,21 @@ import { PromotionsSection } from '@/widgets/promotions-section';
import { StatsSection } from '@/widgets/stats-section'; import { StatsSection } from '@/widgets/stats-section';
import { VacanciesSection } from '@/widgets/vacancies-section'; import { VacanciesSection } from '@/widgets/vacancies-section';
export default function Home() { export default async function Home() {
const data = await fetchMainPageContent();
if (!data) return null;
return ( return (
<main> <main>
<HeroSection /> <HeroSection />
<AppDownloadSection />
<StatsSection /> <StatsSection />
<MapSection /> <MapSection stations={data.stations} />
<AboutSection /> <AboutSection />
<PromotionsSection /> <PromotionsSection discounts={data.discounts} />
<VacanciesSection /> <VacanciesSection jobs={data.jobs} />
<PartnersSection /> <PartnersSection partners={data.partners} />
<CharitySection /> <CharitySection />
<CtaSection /> <CtaSection />
</main> </main>

View File

@ -1,6 +1,10 @@
import { baseAPI } from '@/shared/api/base-api'; import { baseAPI } from '@/shared/api/base-api';
import { ClientInfo } from '../model/types/bonus-client-info.type'; import { ClientInfo } from '../model/types/bonus-client-info.type';
import {
BonusTransactionRequest,
BonusTransactionResponse,
} from '../model/types/bonus-transactions.type';
export const bonusApi = baseAPI.injectEndpoints({ export const bonusApi = baseAPI.injectEndpoints({
endpoints: (builder) => ({ endpoints: (builder) => ({
@ -11,7 +15,19 @@ export const bonusApi = baseAPI.injectEndpoints({
}; };
}, },
}), }),
fetchBonusTransactions: builder.query<
BonusTransactionResponse,
BonusTransactionRequest
>({
query: (request) => {
return {
url: '/bonus/transactions',
params: request,
};
},
}),
}), }),
}); });
export const { useFetchMyBonusInfoQuery } = bonusApi; export const { useFetchMyBonusInfoQuery, useFetchBonusTransactionsQuery } =
bonusApi;

View File

@ -0,0 +1,24 @@
export interface BonusTransactionResponse {
transactions: BonusTransaction[];
current_page: number;
limit: number;
total_records: number;
total_pages: number;
}
export interface BonusTransaction {
id: number;
date_create: string;
station?: string;
product_name: string;
amount: string;
price_real: string;
sum_real: string;
}
export interface BonusTransactionRequest {
start_date?: string;
end_date?: string;
page: number;
limit: number;
}

View File

@ -0,0 +1,35 @@
import { baseAPI } from '@/shared/api/base-api';
import { CorporateInfoResponse } from '../model/types/corporate-client-info.type';
import {
CorporateTransactionRequest,
CorporateTransactionResponse,
} from '../model/types/corporate-transactions.type';
export const corporateApi = baseAPI.injectEndpoints({
endpoints: (builder) => ({
fetchMyCorporateInfo: builder.query<CorporateInfoResponse, any>({
query: () => {
return {
url: '/corporate/info',
};
},
}),
fetchCorporateTransactions: builder.query<
CorporateTransactionResponse,
CorporateTransactionRequest
>({
query: (request) => {
return {
url: '/corporate/transactions',
params: request,
};
},
}),
}),
});
export const {
useFetchMyCorporateInfoQuery,
useFetchCorporateTransactionsQuery,
} = corporateApi;

View File

@ -0,0 +1,9 @@
export interface CorporateInfoResponse {
created_at: string;
fund: string;
fund_total: string;
group_id: number;
group_name: string;
overdraft: string;
total_cards: number;
}

View File

@ -0,0 +1,24 @@
export interface CorporateTransactionResponse {
transactions: CorporateTransaction[];
current_page: number;
limit: number;
total_records: number;
total_pages: number;
}
export interface CorporateTransaction {
date_create: string;
station_name?: string;
product_name: string;
amount: string;
price_real: string;
sum_real: string;
uid: string;
}
export interface CorporateTransactionRequest {
start_date?: string;
end_date?: string;
page: number;
limit: number;
}

View File

@ -0,0 +1,4 @@
export type Point = {
id: number;
coordinates: [number, number];
};

View File

@ -8,11 +8,14 @@ import {
List, List,
MapPin, MapPin,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Stations } from '@/app/api-utlities/@types';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Badge } from '@/shared/shadcn-ui/badge'; import { Badge } from '@/shared/shadcn-ui/badge';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { Separator } from '@/shared/shadcn-ui/separator';
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
@ -20,445 +23,259 @@ import {
TabsTrigger, TabsTrigger,
} from '@/shared/shadcn-ui/tabs'; } from '@/shared/shadcn-ui/tabs';
// Sample data for gas stations import { Point } from '../model';
const stations = [ import { YandexMap } from './yandex-map';
{
id: 1,
name: 'АЗС Душанбе-Центр',
address: 'ул. Рудаки 150, Душанбе',
city: 'Душанбе',
coordinates: { x: 0.2, y: 0.3 },
services: ['ДТ', 'АИ-92', 'АИ-95', 'Z-100 Power', 'Минимаркет', 'Туалет'],
},
{
id: 2,
name: 'АЗС Худжанд',
address: 'ул. Ленина 45, Худжанд',
city: 'Худжанд',
coordinates: { x: 0.5, y: 0.2 },
services: [
'ДТ',
'АИ-92',
'АИ-95',
'Пропан',
'Минимаркет',
'Автомойка',
'Туалет',
],
},
{
id: 3,
name: 'АЗС Куляб',
address: 'ул. Сомони 78, Куляб',
city: 'Куляб',
coordinates: { x: 0.7, y: 0.4 },
services: ['ДТ', 'АИ-92', 'Пропан', 'Туалет'],
},
{
id: 4,
name: 'АЗС Бохтар',
address: 'ул. Айни 23, Бохтар',
city: 'Бохтар',
coordinates: { x: 0.3, y: 0.6 },
services: [
'ДТ',
'АИ-92',
'АИ-95',
'Z-100 Power',
'Минимаркет',
'Зарядная станция',
'Туалет',
],
},
{
id: 5,
name: 'АЗС Хорог',
address: 'ул. Горная 12, Хорог',
city: 'Хорог',
coordinates: { x: 0.6, y: 0.7 },
services: ['ДТ', 'АИ-92', 'Автомойка', 'Туалет'],
},
{
id: 6,
name: 'АЗС Истаравшан',
address: 'ул. Исмоили Сомони 34, Истаравшан',
city: 'Истаравшан',
coordinates: { x: 0.8, y: 0.8 },
services: ['ДТ', 'АИ-92', 'АИ-95', 'Минимаркет', 'Туалет'],
},
{
id: 7,
name: 'АЗС Пенджикент',
address: 'ул. Рудаки 56, Пенджикент',
city: 'Пенджикент',
coordinates: { x: 0.1, y: 0.9 },
services: ['ДТ', 'АИ-92', 'АИ-95', 'Пропан', 'Минимаркет', 'Туалет'],
},
{
id: 8,
name: 'АЗС Душанбе-Запад',
address: 'ул. Джами 23, Душанбе',
city: 'Душанбе',
coordinates: { x: 0.25, y: 0.35 },
services: [
'ДТ',
'АИ-92',
'АИ-95',
'Z-100 Power',
'Пропан',
'Минимаркет',
'Автомойка',
'Туалет',
],
},
{
id: 9,
name: 'АЗС Душанбе-Восток',
address: 'ул. Айни 78, Душанбе',
city: 'Душанбе',
coordinates: { x: 0.15, y: 0.25 },
services: [
'ДТ',
'АИ-92',
'АИ-95',
'Зарядная станция',
'Минимаркет',
'Туалет',
],
},
{
id: 10,
name: 'АЗС Гиссар',
address: 'ул. Центральная 12, Гиссар',
city: 'Гиссар',
coordinates: { x: 0.4, y: 0.4 },
services: ['ДТ', 'АИ-92', 'Пропан', 'Туалет'],
},
{
id: 11,
name: 'АЗС Вахдат',
address: 'ул. Сомони 45, Вахдат',
city: 'Вахдат',
coordinates: { x: 0.55, y: 0.45 },
services: ['ДТ', 'АИ-92', 'АИ-95', 'Минимаркет', 'Туалет'],
},
{
id: 12,
name: 'АЗС Турсунзаде',
address: 'ул. Ленина 34, Турсунзаде',
city: 'Турсунзаде',
coordinates: { x: 0.65, y: 0.55 },
services: ['ДТ', 'АИ-92', 'АИ-95', 'Z-100 Power', 'Автомойка', 'Туалет'],
},
];
// All available filters // Пропсы для компонента GasStationMap
const allFilters = [ interface GasStationMapProps {
'ДТ', stations: Stations;
'АИ-92', }
'АИ-95',
'Z-100 Power',
'Пропан',
'Зарядная станция',
'Минимаркет',
'Автомойка',
'Туалет',
];
// Extract unique cities from stations // Пропсы для панели фильтров
const allCities = [...new Set(stations.map((station) => station.city))].sort(); interface FilterPanelProps {
isOpen: boolean;
onClose: () => void;
activeFilters: string[];
activeCities: string[];
allCities: string[];
allFilters: string[];
activeFilterTab: string;
toggleFilter: (filter: string) => void;
toggleCity: (city: string) => void;
selectAllCities: () => void;
setActiveFilterTab: (tab: string) => void;
resetFilters: () => void;
resetCities: () => void;
}
export default function GasStationMap() { // Пропсы для панели списка станций
interface StationListPanelProps {
isOpen: boolean;
onClose: () => void;
stations: Stations;
selectedStation: number | null;
activeFilters: string[];
activeCities: string[];
handleMapStationClick: (id: number) => void;
filterToFieldMap: { [key: string]: keyof Stations[number] };
allFilters: string[];
resetFilters: () => void;
resetCities: () => void;
}
// Компонент панели фильтров
function FilterPanel({
isOpen,
onClose,
activeFilters,
activeCities,
allCities,
allFilters,
activeFilterTab,
toggleFilter,
toggleCity,
selectAllCities,
setActiveFilterTab,
resetFilters,
resetCities,
}: FilterPanelProps) {
const { t } = useTextController(); const { t } = useTextController();
const mapRef = useRef<HTMLDivElement>(null);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [activeCities, setActiveCities] = useState<string[]>([]);
const [filteredStations, setFilteredStations] = useState(stations);
const [selectedStation, setSelectedStation] = useState<number | null>(null);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isStationListOpen, setIsStationListOpen] = useState(false);
const [activeFilterTab, setActiveFilterTab] = useState('cities');
// Toggle service filter
const toggleFilter = (filter: string) => {
if (activeFilters.includes(filter)) {
setActiveFilters(activeFilters.filter((f) => f !== filter));
} else {
setActiveFilters([...activeFilters, filter]);
}
};
// Toggle city filter
const toggleCity = (city: string) => {
if (activeCities.includes(city)) {
setActiveCities(activeCities.filter((c) => c !== city));
} else {
setActiveCities([...activeCities, city]);
}
};
// Select all cities
const selectAllCities = () => {
if (activeCities.length === allCities.length) {
setActiveCities([]);
} else {
setActiveCities([...allCities]);
}
};
// Filter stations based on active filters and cities
useEffect(() => {
let filtered = stations;
// Filter by services
if (activeFilters.length > 0) {
filtered = filtered.filter((station) =>
activeFilters.every((filter) => station.services.includes(filter)),
);
}
// Filter by cities
if (activeCities.length > 0) {
filtered = filtered.filter((station) =>
activeCities.includes(station.city),
);
}
setFilteredStations(filtered);
}, [activeFilters, activeCities]);
useEffect(() => {
// This is a placeholder for a real map implementation
// In a real application, you would use a mapping library like Mapbox, Google Maps, or Leaflet
if (mapRef.current) {
const canvas = document.createElement('canvas');
canvas.width = mapRef.current.clientWidth;
canvas.height = mapRef.current.clientHeight;
mapRef.current.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (ctx) {
// Draw a simple map placeholder
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw some roads
ctx.strokeStyle = '#d1d5db';
ctx.lineWidth = 5;
// Horizontal roads
for (let i = 1; i < 5; i++) {
ctx.beginPath();
ctx.moveTo(0, canvas.height * (i / 5));
ctx.lineTo(canvas.width, canvas.height * (i / 5));
ctx.stroke();
}
// Vertical roads
for (let i = 1; i < 8; i++) {
ctx.beginPath();
ctx.moveTo(canvas.width * (i / 8), 0);
ctx.lineTo(canvas.width * (i / 8), canvas.height);
ctx.stroke();
}
// Draw gas station markers
filteredStations.forEach((station) => {
const isSelected = selectedStation === station.id;
// Draw marker
ctx.fillStyle = isSelected ? '#3b82f6' : '#ef4444';
ctx.beginPath();
ctx.arc(
station.coordinates.x * canvas.width,
station.coordinates.y * canvas.height,
isSelected ? 12 : 10,
0,
2 * Math.PI,
);
ctx.fill();
// Draw white border
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(
station.coordinates.x * canvas.width,
station.coordinates.y * canvas.height,
isSelected ? 12 : 10,
0,
2 * Math.PI,
);
ctx.stroke();
});
// Add city names
ctx.fillStyle = '#1f2937';
ctx.font = 'bold 16px Arial';
ctx.fillText('Душанбе', canvas.width * 0.45, canvas.height * 0.15);
ctx.fillText('Худжанд', canvas.width * 0.2, canvas.height * 0.25);
ctx.fillText('Куляб', canvas.width * 0.7, canvas.height * 0.35);
ctx.fillText('Бохтар', canvas.width * 0.3, canvas.height * 0.55);
ctx.fillText('Хорог', canvas.width * 0.6, canvas.height * 0.65);
ctx.fillText('Истаравшан', canvas.width * 0.8, canvas.height * 0.75);
ctx.fillText('Пенджикент', canvas.width * 0.1, canvas.height * 0.85);
}
}
return () => {
if (mapRef.current) {
while (mapRef.current.firstChild) {
mapRef.current.removeChild(mapRef.current.firstChild);
}
}
};
}, [filteredStations, selectedStation]);
return ( return (
<div className='relative h-full w-full'> <div
{/* Filter panel - slides from left */} className={`absolute top-0 bottom-0 left-0 z-20 transform bg-white shadow-lg transition-transform duration-300 ${
<div isOpen ? 'translate-x-0' : '-translate-x-full'
className={`absolute top-0 bottom-0 left-0 z-20 transform bg-white shadow-lg transition-transform duration-300 ${ }`}
isFilterOpen ? 'translate-x-0' : '-translate-x-full' style={{ width: '300px' }}
}`} >
style={{ width: '300px' }} <div className='flex items-center justify-between border-b border-gray-200 p-4'>
> <div className='flex items-center gap-2'>
<div className='flex items-center justify-between border-b border-gray-200 p-4'> <Filter className='h-5 w-5 text-red-600' />
<div className='flex items-center gap-2'> <span className='font-medium'>{t('map.filters')}</span>
<Filter className='h-5 w-5 text-red-600' />
<span className='font-medium'>{t('map.filters')}</span>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => setIsFilterOpen(false)}
>
<ChevronLeft className='h-5 w-5' />
</Button>
</div>
<div className='p-4'>
<Tabs
value={activeFilterTab}
onValueChange={setActiveFilterTab}
className='w-full'
>
<TabsList className='mb-4 grid w-full grid-cols-2'>
<TabsTrigger value='cities'>{t('map.cities')}</TabsTrigger>
<TabsTrigger value='services'>{t('map.services')}</TabsTrigger>
</TabsList>
<TabsContent value='cities' className='mt-0'>
<Button
variant='outline'
size='sm'
className='mb-3 flex w-full items-center justify-between'
onClick={selectAllCities}
>
<span>{t('map.allCities')}</span>
{activeCities.length === allCities.length && (
<Check className='h-4 w-4 text-green-600' />
)}
</Button>
<div className='flex max-h-[calc(100vh-250px)] flex-col gap-2 overflow-y-auto'>
{allCities.map((city) => (
<Button
key={city}
variant={
activeCities.includes(city) ? 'default' : 'outline'
}
size='sm'
className={`justify-between ${activeCities.includes(city) ? 'bg-red-600 hover:bg-red-700' : ''}`}
onClick={() => toggleCity(city)}
>
<span>{city}</span>
{activeCities.includes(city) && (
<Check className='ml-2 h-4 w-4' />
)}
</Button>
))}
</div>
{activeCities.length > 0 && (
<Button
variant='link'
className='mt-4 p-0 text-red-600'
onClick={() => setActiveCities([])}
>
{t('common.buttons.resetFilters')}
</Button>
)}
</TabsContent>
<TabsContent value='services' className='mt-0'>
<div className='flex flex-wrap gap-2'>
{allFilters.map((filter) => (
<Button
key={filter}
variant={
activeFilters.includes(filter) ? 'default' : 'outline'
}
size='sm'
className={
activeFilters.includes(filter)
? 'bg-red-600 hover:bg-red-700'
: ''
}
onClick={() => toggleFilter(filter)}
>
{filter}
</Button>
))}
</div>
{activeFilters.length > 0 && (
<Button
variant='link'
className='mt-4 p-0 text-red-600'
onClick={() => setActiveFilters([])}
>
{t('common.buttons.resetFilters')}
</Button>
)}
</TabsContent>
</Tabs>
</div> </div>
<Button variant='ghost' size='sm' onClick={onClose}>
<ChevronLeft className='h-5 w-5' />
</Button>
</div> </div>
{/* Station list panel - slides from right */} <div className='p-4'>
<div <Tabs
className={`absolute top-0 right-0 bottom-0 z-20 transform bg-white shadow-lg transition-transform duration-300 ${ value={activeFilterTab}
isStationListOpen ? 'translate-x-0' : 'translate-x-full' onValueChange={setActiveFilterTab}
}`} className='w-full'
style={{ width: '350px' }}
>
<div className='flex items-center justify-between border-b border-gray-200 p-4'>
<Button
variant='ghost'
size='sm'
onClick={() => setIsStationListOpen(false)}
>
<ChevronRight className='h-5 w-5' />
</Button>
<div className='flex items-center gap-2'>
<span className='font-medium'>{t('map.stationsList')}</span>
<Badge>{filteredStations.length}</Badge>
</div>
</div>
<div
className='overflow-y-auto'
style={{ height: 'calc(100% - 60px)' }}
> >
{filteredStations.length > 0 ? ( <TabsList className='mb-4 grid w-full grid-cols-2'>
<div className='p-2'> <TabsTrigger value='cities'>{t('map.cities')}</TabsTrigger>
{filteredStations.map((station) => ( <TabsTrigger value='services'>{t('map.services')}</TabsTrigger>
</TabsList>
<TabsContent
value='cities'
className='mt-0 max-h-[300px] overflow-y-auto py-2 pr-1'
>
<Button
variant='outline'
size='sm'
className='mb-3 flex w-full items-center justify-between'
onClick={selectAllCities}
>
<span>{t('map.allCities')}</span>
{activeCities.length === allCities.length && (
<Check className='h-4 w-4 text-green-600' />
)}
</Button>
<div className='flex max-h-[calc(100vh-250px)] flex-col gap-2 overflow-y-auto'>
{allCities.map((city) => (
<Button
key={city}
variant={activeCities.includes(city) ? 'default' : 'outline'}
size='sm'
className={`justify-between ${
activeCities.includes(city)
? 'bg-red-600 hover:bg-red-700'
: ''
}`}
onClick={() => toggleCity(city)}
>
<span>{city}</span>
{activeCities.includes(city) && (
<Check className='ml-2 h-4 w-4' />
)}
</Button>
))}
</div>
</TabsContent>
<TabsContent value='services' className='mt-0'>
<div className='flex flex-wrap gap-2'>
{allFilters.map((filter) => (
<Button
key={filter}
variant={
activeFilters.includes(filter) ? 'default' : 'outline'
}
size='sm'
className={
activeFilters.includes(filter)
? 'bg-red-600 hover:bg-red-700'
: ''
}
onClick={() => toggleFilter(filter)}
>
{filter}
</Button>
))}
</div>
</TabsContent>
<Separator className='mt-2' />
{/* Кнопка сброса фильтров */}
{activeFilterTab === 'cities'
? activeCities.length > 0 && (
<Button
variant='link'
className='p-0 text-red-600'
onClick={resetCities}
>
{t('common.buttons.resetFilters')}
</Button>
)
: activeFilters.length > 0 && (
<Button
variant='link'
className='mt-4 p-0 text-red-600'
onClick={resetFilters}
>
{t('common.buttons.resetFilters')}
</Button>
)}
</Tabs>
</div>
</div>
);
}
// Компонент панели списка станций
function StationListPanel({
isOpen,
onClose,
stations,
selectedStation,
activeFilters,
activeCities,
handleMapStationClick,
filterToFieldMap,
allFilters,
resetCities,
resetFilters,
}: StationListPanelProps) {
const { t } = useTextController();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!selectedStation || !scrollContainerRef.current) return;
const selectedStationItem = document.getElementById(
`station_${selectedStation}`,
);
if (selectedStationItem) {
const container = scrollContainerRef.current;
const itemRect = selectedStationItem.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate the item's position relative to the container
const itemTopRelativeToContainer =
itemRect.top - containerRect.top + container.scrollTop - 10;
// Scroll the container to bring the item into view
container.scrollTo({
top: itemTopRelativeToContainer,
behavior: 'smooth',
});
}
}, [selectedStation]);
return (
<div
className={`absolute top-0 right-0 bottom-0 z-20 transform bg-white shadow-lg transition-transform duration-300 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ width: '350px' }}
>
<div className='flex items-center justify-between border-b border-gray-200 p-4'>
<Button variant='ghost' size='sm' onClick={onClose}>
<ChevronRight className='h-5 w-5' />
</Button>
<div className='flex items-center gap-2'>
<span className='font-medium'>{t('map.stationsList')}</span>
<Badge>{stations.length}</Badge>
</div>
</div>
<div
className='overflow-y-auto'
style={{ height: 'calc(100% - 60px)' }}
ref={scrollContainerRef}
>
{stations.length > 0 ? (
<div className='p-2'>
{stations.map((station) => {
const services = allFilters.filter(
(filter) => station[filterToFieldMap[filter]],
);
return (
<div <div
key={station.id} key={station.id}
id={`station_${station.id}`}
className={`mb-2 cursor-pointer rounded-lg border p-3 transition-colors ${ className={`mb-2 cursor-pointer rounded-lg border p-3 transition-colors ${
selectedStation === station.id selectedStation === station.id
? 'border-blue-500 bg-blue-50' ? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:bg-gray-50' : 'border-gray-200 hover:bg-gray-50'
}`} }`}
onClick={() => setSelectedStation(station.id)} onClick={() => handleMapStationClick(station.id)}
> >
<div className='flex items-start justify-between'> <div className='flex items-start justify-between'>
<h4 className='font-medium'>{station.name}</h4> <h4 className='font-medium'>{station.name}</h4>
@ -467,11 +284,23 @@ export default function GasStationMap() {
<p className='mb-2 text-sm text-gray-500'> <p className='mb-2 text-sm text-gray-500'>
{station.address} {station.address}
</p> </p>
{station.workingHours && (
<p className='mb-2 text-sm text-gray-500'>
{t('map.workingHours')}: {station.workingHours}
</p>
)}
{station.description && (
<p className='mb-2 text-sm text-gray-500'>
{station.description}
</p>
)}
<div className='flex flex-wrap gap-1'> <div className='flex flex-wrap gap-1'>
<Badge className='mb-1 border-blue-200 bg-blue-100 text-blue-800'> {station.region && (
{station.city} <Badge className='mb-1 border-blue-200 bg-blue-100 text-blue-800'>
</Badge> {station.region}
{station.services.map((service) => ( </Badge>
)}
{services.map((service) => (
<Badge <Badge
key={service} key={service}
variant='outline' variant='outline'
@ -485,40 +314,216 @@ export default function GasStationMap() {
</Badge> </Badge>
))} ))}
</div> </div>
{station.image && (
<img
src={station.image}
alt={station.name}
className='mt-2 h-20 w-full rounded object-cover'
/>
)}
</div> </div>
))} );
})}
</div>
) : (
<div className='p-4 text-center text-gray-500'>
<p>{t('map.noStations')}</p>
<div className='mt-2 flex justify-center gap-2'>
{activeFilters.length > 0 && (
<Button
variant='link'
className='text-red-600'
onClick={resetFilters}
>
{t('common.buttons.resetFilters')}
</Button>
)}
{activeCities.length > 0 && (
<Button
variant='link'
className='text-red-600'
onClick={resetCities}
>
{t('map.allCities')}
</Button>
)}
</div> </div>
) : ( </div>
<div className='p-4 text-center text-gray-500'> )}
<p>{t('map.noStations')}</p>
<div className='mt-2 flex justify-center gap-2'>
{activeFilters.length > 0 && (
<Button
variant='link'
className='text-red-600'
onClick={() => setActiveFilters([])}
>
{t('common.buttons.resetFilters')}
</Button>
)}
{activeCities.length > 0 && (
<Button
variant='link'
className='text-red-600'
onClick={() => setActiveCities([])}
>
{t('map.allCities')}
</Button>
)}
</div>
</div>
)}
</div>
</div> </div>
</div>
);
}
// Главный компонент
export default function GasStationMap({ stations }: GasStationMapProps) {
const { t } = useTextController();
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [activeCities, setActiveCities] = useState<string[]>([]);
const [selectedStation, setSelectedStation] = useState<number | null>(null);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [isStationListOpen, setIsStationListOpen] = useState(false);
const [activeFilterTab, setActiveFilterTab] = useState('cities');
useEffect(() => {
if (selectedStation === null) return;
setIsStationListOpen(true);
}, [selectedStation]);
// Все доступные фильтры
const allFilters = [
'ДТ',
'АИ-92',
'АИ-95',
'Z-100 Power',
'Пропан',
'Зарядная станция',
'Минимаркет',
'Автомойка',
'Туалет',
];
// Маппинг фильтров на поля Station
const filterToFieldMap: { [key: string]: keyof Stations[number] } = {
'АИ-92': 'ai92',
ДТ: 'dt',
'АИ-95': 'ai95',
'Z-100 Power': 'z100',
Пропан: 'propan',
'Зарядная станция': 'electricCharge',
Минимаркет: 'miniMarket',
Автомойка: 'carWash',
Туалет: 'toilet',
};
// Мемоизация списка уникальных регионов
const allCities = useMemo(() => {
return [
...new Set(
stations
.map((station) => station.region)
.filter((region): region is string => region !== null),
),
].sort();
}, [stations]);
// Мемоизация фильтрованных станций
const filteredStations = useMemo(() => {
let filtered = stations;
// Фильтрация по регионам (ИЛИ)
if (activeCities.length > 0) {
filtered = filtered.filter(
(station) => station.region && activeCities.includes(station.region),
);
}
// Фильтрация по услугам (И)
if (activeFilters.length > 0) {
filtered = filtered.filter((station) =>
activeFilters.every((filter) => {
const field = filterToFieldMap[filter];
return Boolean(station[field]) === true;
}),
);
}
return filtered;
}, [activeFilters, activeCities, stations]);
// Мемоизация точек для карты
const points = useMemo(
(): Point[] =>
filteredStations.map((st) => ({
id: st.id,
coordinates: [st.latitude, st.longitude],
})),
[filteredStations],
);
const handleMapStationClick = (stationId: number) => {
setSelectedStation(() => {
if (selectedStation !== null && selectedStation === stationId) {
return null;
}
return stationId;
});
};
// Переключение фильтра услуг
const toggleFilter = (filter: string) => {
setActiveFilters((prev) =>
prev.includes(filter)
? prev.filter((f) => f !== filter)
: [...prev, filter],
);
};
// Переключение фильтра региона
const toggleCity = (city: string) => {
setActiveCities((prev) =>
prev.includes(city) ? prev.filter((c) => c !== city) : [...prev, city],
);
};
// Выбор всех регионов
const selectAllCities = () => {
setActiveCities(
activeCities.length === allCities.length ? [] : [...allCities],
);
};
// Сброс фильтров услуг
const resetFilters = () => {
setActiveFilters([]);
};
// Сброс фильтров регионов
const resetCities = () => {
setActiveCities([]);
};
return (
<div className='relative h-full w-full'>
{/* Filter panel */}
<FilterPanel
isOpen={isFilterOpen}
onClose={() => setIsFilterOpen(false)}
activeFilters={activeFilters}
activeCities={activeCities}
allCities={allCities}
allFilters={allFilters}
activeFilterTab={activeFilterTab}
toggleFilter={toggleFilter}
toggleCity={toggleCity}
selectAllCities={selectAllCities}
setActiveFilterTab={setActiveFilterTab}
resetFilters={resetFilters}
resetCities={resetCities}
/>
{/* Station list panel */}
<StationListPanel
isOpen={isStationListOpen}
onClose={() => setIsStationListOpen(false)}
stations={filteredStations}
selectedStation={selectedStation}
activeFilters={activeFilters}
activeCities={activeCities}
handleMapStationClick={handleMapStationClick}
filterToFieldMap={filterToFieldMap}
allFilters={allFilters}
resetFilters={resetFilters}
resetCities={resetCities}
/>
{/* Map */} {/* Map */}
<div className='h-full w-full'> <div className='h-full w-full'>
<div ref={mapRef} className='h-full w-full'></div> <YandexMap
points={points}
selectedStation={selectedStation}
handleMapStationClick={handleMapStationClick}
/>
</div> </div>
{/* Control buttons */} {/* Control buttons */}
@ -560,7 +565,7 @@ export default function GasStationMap() {
<span>{t('map.ourStations')}</span> <span>{t('map.ourStations')}</span>
</div> </div>
<p className='mt-1 text-xs text-gray-500'> <p className='mt-1 text-xs text-gray-500'>
{t('map.totalStations')}: {stations.length} {t('map.totalStations')}: {filteredStations.length}
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,103 @@
import {
GeolocationControl,
Map,
Placemark,
YMaps,
} from '@pbe/react-yandex-maps';
import { isEmpty } from 'lodash';
import { useEffect, useRef } from 'react';
import { Point } from '../model';
type YandexMapProps = {
points: Point[];
selectedStation: number | null;
handleMapStationClick: (id: number) => void;
};
const mapCenter = [38.53575, 68.77905];
export const YandexMap = ({
points,
selectedStation,
handleMapStationClick,
}: YandexMapProps) => {
const mapRef = useRef<any>(null);
useEffect(() => {
if (!mapRef.current) return;
if (selectedStation !== null) {
const selectedPoint = points.find(
(point) => point.id === selectedStation,
);
if (selectedPoint) {
mapRef.current
.setCenter(selectedPoint.coordinates, mapRef.current.getZoom(), {
duration: 1000,
})
.then(() => {
mapRef.current.setZoom(13, { duration: 300 });
});
}
} else {
mapRef.current.setZoom(11, { duration: 300 });
}
}, [selectedStation, points]);
return (
<YMaps
query={{
apikey: process.env.NEXT_PUBLIC_YANDEX_MAP_API_KEY,
lang: 'ru_RU',
}}
>
<Map
defaultState={{
center: !isEmpty(points) ? points[0].coordinates : mapCenter,
zoom: 11,
behaviors: ['drag', 'multiTouch', 'dblClickZoom', 'scrollZoom'],
}}
options={{
copyrightUaVisible: false,
copyrightProvidersVisible: false,
copyrightLogoVisible: false,
suppressMapOpenBlock: true,
suppressObsoleteBrowserNotifier: true,
}}
className='h-full max-h-[500px] w-full overflow-hidden rounded-md shadow-lg'
instanceRef={(ref) => {
mapRef.current = ref;
}}
>
{points.map((point) => {
const isSelectedStation = selectedStation === point.id;
return (
<Placemark
key={point.id}
geometry={point.coordinates}
options={{
iconLayout: 'default#image',
iconImageHref:
!selectedStation || isSelectedStation
? '/map/oriyo-marker.png'
: '/map/oriyo-inactive-marker.png',
iconImageSize: isSelectedStation ? [70, 70] : [64, 64],
iconImageOffset: isSelectedStation ? [-28, -40] : [-24, -36],
}}
onClick={() => handleMapStationClick(point.id)}
/>
);
})}
<GeolocationControl
options={{
position: {
bottom: 20,
right: 20,
},
}}
/>
</Map>
</YMaps>
);
};

View File

@ -0,0 +1,193 @@
import {
AboutUsPageData,
CertificatesPageData,
CharityPageData,
MainPageData,
} from '@/app/api-utlities/@types/pages';
import {
presentCertificatesFromTaylor,
presentCharitiesFromTaylor,
presentDiscountsFromTaylor,
presentHistoryItemsFromTaylor,
presentJobsFromTaylor,
presentMediaFromTaylor,
presentPartnersFromTaylor,
presentReviewsFromTaylor,
presentStationsFromTaylor,
presentTeamMembersFromTaylor,
presentTextsFromTaylor,
} from '@/app/api-utlities/presenters/taylor-presenters';
import { taylorQueryBuilder } from '@/shared/api/taylor-query-builder';
/**
* Fetches main page content using TaylorDB query builder
* Replaces the RTK Query GraphQL approach with type-safe query builder
*/
export async function fetchMainPageContent(): Promise<MainPageData> {
// Use batch queries to fetch all data in a single request
const [partnersData, jobsData, discountsData, stationsData] =
await taylorQueryBuilder
.batch([
// Fetch partners
taylorQueryBuilder
.selectFrom('partnyory')
.selectAll()
.with({
izobrozhenie: (qb) => qb.selectAll(),
}),
// Fetch jobs
taylorQueryBuilder
.selectFrom('vakansii')
.selectAll()
.with({
tegi: (qb) => qb.select(['name']),
}),
// Fetch discounts
taylorQueryBuilder
.selectFrom('akcii')
.selectAll()
.with({
foto: (qb) => qb.selectAll(),
}),
// Fetch stations
taylorQueryBuilder
.selectFrom('azs')
.selectAll()
.with({
foto: (qb) => qb.selectAll(),
}),
])
.execute();
console.log('Loading main page content...');
return {
partners: presentPartnersFromTaylor(partnersData),
jobs: presentJobsFromTaylor(jobsData),
discounts: presentDiscountsFromTaylor(discountsData),
stations: presentStationsFromTaylor(stationsData),
};
}
/**
* Fetches about us page content using TaylorDB query builder
*/
export async function fetchAboutUsPageContent(): Promise<AboutUsPageData> {
// Use batch queries to fetch all data in a single request
const [teamData, historyData, stationsData, reviewsData] =
await taylorQueryBuilder
.batch([
// Fetch team members
taylorQueryBuilder
.selectFrom('komanda')
.selectAll()
.with({
foto: (qb) => qb.selectAll(),
}),
// Fetch history items
taylorQueryBuilder.selectFrom('istoriyaKompanii').selectAll(),
// Fetch stations
taylorQueryBuilder
.selectFrom('azs')
.selectAll()
.where('foto', 'isNotEmpty')
.with({
foto: (qb) => qb.selectAll(),
}),
// Fetch reviews (filtered by published status)
taylorQueryBuilder
.selectFrom('otzyvy')
.selectAll()
.where('status', '=', 'Опубликовано'),
])
.execute();
console.log('Loading about us page content...');
return {
team: presentTeamMembersFromTaylor(teamData),
history: presentHistoryItemsFromTaylor(historyData),
stations: presentStationsFromTaylor(stationsData),
reviews: presentReviewsFromTaylor(reviewsData),
};
}
/**
* Fetches charity page content using TaylorDB query builder
*/
export async function fetchCharityPageContent(): Promise<CharityPageData> {
const charitiesData = await taylorQueryBuilder
.selectFrom('blagotvoritelnyjFond')
.selectAll()
.with({
foto: (qb) => qb.selectAll(),
})
.execute();
console.log('Loading charity page content...');
return {
charities: presentCharitiesFromTaylor(charitiesData),
};
}
/**
* Fetches certificates page content using TaylorDB query builder
*/
export async function fetchCertificatesPageContent(): Promise<CertificatesPageData> {
const certificatesData = await taylorQueryBuilder
.selectFrom('sertifikaty')
.selectAll()
.with({
foto: (qb) => qb.selectAll(),
})
.execute();
console.log('Loading certificates page content...');
return {
certificates: presentCertificatesFromTaylor(certificatesData),
};
}
/**
* Fetches text content using TaylorDB query builder
*/
export async function fetchTextContent(): Promise<
Array<{ key: string; value: string | null }>
> {
const textsData = await taylorQueryBuilder
.selectFrom('tekstovyjKontentSajta')
.selectAll()
.execute();
console.log('Loading text content...');
return presentTextsFromTaylor(textsData);
}
/**
* Fetches media content using TaylorDB query builder
*/
export async function fetchMediaContent(): Promise<
Array<{ key: string; name: string; photo: string | null }>
> {
const mediaData = await taylorQueryBuilder
.selectFrom('mediaKontentSajta')
.selectAll()
.with({
foto: (qb) => qb.selectAll(),
})
.execute();
console.log('Loading media content...');
return presentMediaFromTaylor(mediaData);
}

View File

@ -0,0 +1,17 @@
import { baseAPI } from '@/shared/api/base-api';
import { ReviewFormValues } from '../model/review-form.schema';
export const reviewsAPI = baseAPI.injectEndpoints({
endpoints: (build) => ({
createReview: build.mutation<void, ReviewFormValues>({
query: (body) => ({
url: 'reviews/create',
method: 'POST',
body,
}),
}),
}),
});
export const { useCreateReviewMutation } = reviewsAPI;

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
export const reviewSchema = z.object({
name: z
.string()
.min(2, { message: 'Имя должно содержать не менее 2 символов' }),
rating: z.number().min(1, { message: 'Пожалуйста, выберите рейтинг' }).max(5),
reviewMessage: z
.string()
.min(10, { message: 'Отзыв должен содержать не менее 10 символов' }),
});
export type ReviewFormValues = z.infer<typeof reviewSchema>;

View File

@ -0,0 +1,217 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Plus, Star } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { useTextController } from '@/shared/language';
import { Button } from '@/shared/shadcn-ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/shared/shadcn-ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/shared/shadcn-ui/form';
import { Input } from '@/shared/shadcn-ui/input';
import { Textarea } from '@/shared/shadcn-ui/textarea';
import { useCreateReviewMutation } from '../api/reviews.api';
import { ReviewFormValues, reviewSchema } from '../model/review-form.schema';
export function ReviewForm() {
const { t } = useTextController();
const [openReviewFormDialog, setOpenReviewFormDialog] = useState(false);
const [hoveredStar, setHoveredStar] = useState(0);
const [createReview, { isLoading: isSubmitting }] = useCreateReviewMutation();
const form = useForm<ReviewFormValues>({
resolver: zodResolver(reviewSchema),
defaultValues: {
name: '',
rating: 0,
reviewMessage: '',
},
});
const onSubmit = async (data: ReviewFormValues) => {
try {
await createReview(data);
toast.success(t('about.review-form.dialog.successResponse'), {
duration: 5000,
});
form.reset();
setOpenReviewFormDialog(false);
} catch (error) {
toast.error(t('about.review-form.dialog.errorResponse'), {
duration: 5000,
});
}
};
const StarRating = ({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) => {
return (
<>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type='button'
onClick={() => onChange(star)}
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
className='cursor-pointer transition-transform hover:scale-110 focus:outline-none'
aria-label={`Rate ${star} stars out of 5`}
>
<Star
className={`h-8 w-8 ${
star <= (hoveredStar || value)
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300'
} transition-colors`}
/>
</button>
))}
</>
);
};
return (
<div>
<Dialog
open={openReviewFormDialog}
onOpenChange={setOpenReviewFormDialog}
>
<DialogTrigger asChild>
<Button className='flex shadow-lg transition-all duration-300 hover:scale-105'>
<Plus />
<span>{t('common.buttons.addReview')}</span>
</Button>
</DialogTrigger>
<DialogContent className='overflow-hidden rounded-xl border-none bg-white/95 p-0 shadow-xl backdrop-blur-sm sm:max-w-[500px]'>
<div className='p-6'>
<DialogHeader className='pb-4'>
<DialogTitle className='text-center text-2xl font-bold'>
{t('about.review-form.dialog.title')}
</DialogTitle>
<DialogDescription className='pt-2 text-center'>
{t('about.review-form.dialog.description')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-6'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>
{t('about.review-form.dialog.field.name')}
</FormLabel>
<FormControl>
<Input
placeholder={t(
'about.review-form.dialog.field.name.placeholder',
)}
{...field}
className='bg-white/50'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='rating'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel className='block'>
{t('about.review-form.dialog.field.rating')}
</FormLabel>
<FormControl>
<StarRating
value={field.value}
onChange={(value) => field.onChange(value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='reviewMessage'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>
{t('about.review-form.dialog.field.reviewMessage')}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'about.review-form.dialog.field.reviewMessage.placeholder',
)}
className='min-h-[120px] resize-none bg-white/50'
{...field}
/>
</FormControl>
<FormDescription>
{t('about.review-form.dialog.noteMessage')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className='pt-2'>
<Button
type='submit'
className='w-full'
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Отправка...
</>
) : (
t('common.buttons.sendReview')
)}
</Button>
</DialogFooter>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,10 +1,17 @@
'use client'; 'use client';
import { subMonths } from 'date-fns'; import { deleteCookie } from 'cookies-next';
import { Building2, LogOut, Wallet } from 'lucide-react'; import { Building2, LogOut, Wallet } from 'lucide-react';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import {
useFetchCorporateTransactionsQuery,
useFetchMyCorporateInfoQuery,
} from '@/entities/corporate/api/corporate.api';
import { CorporateTransactionRequest } from '@/entities/corporate/model/types/corporate-transactions.type';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { formatDate } from '@/shared/lib/format-date';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { import {
Card, Card,
@ -13,96 +20,45 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/shared/shadcn-ui/card'; } from '@/shared/shadcn-ui/card';
import { TableCell, TableHead, TableRow } from '@/shared/shadcn-ui/table';
import { TransactionsTable } from '@/widgets/transactions-table'; import { TransactionsTable } from '@/widgets/transactions-table';
// import { CardsList } from '@/widgets/cards-list';
// Sample company data
const companyData = {
companyName: 'ООО «ТаджикТранс»',
numberOfCards: 12,
fund: 25000,
overdraft: 5000,
totalFund: 30000,
registrationDate: '10.03.2019',
};
// Sample transaction data
const generateTransactions = () => {
const stations = [
'АЗС Душанбе-Центр',
'АЗС Душанбе-Запад',
'АЗС Душанбе-Восток',
'АЗС Худжанд',
'АЗС Куляб',
];
const products = [
{ name: 'ДТ', price: 8.5 },
{ name: 'АИ-92', price: 9.2 },
{ name: 'АИ-95', price: 10.5 },
{ name: 'Z-100 Power', price: 11.8 },
{ name: 'Пропан', price: 6.3 },
];
const transactions = [];
// Generate 50 random transactions over the last 6 months
for (let i = 0; i < 50; i++) {
const date = subMonths(new Date(), Math.random() * 6);
const station = stations[Math.floor(Math.random() * stations.length)];
const product = products[Math.floor(Math.random() * products.length)];
const quantity = Math.floor(Math.random() * 40) + 10; // 10-50 liters
const cost = product.price;
const total = quantity * cost;
transactions.push({
id: i + 1,
date,
station,
product: product.name,
quantity,
cost,
total,
});
}
// Sort by date (newest first)
return transactions.sort((a, b) => b.date.getTime() - a.date.getTime());
};
const transactions = generateTransactions();
export function CorporateDashboard() { export function CorporateDashboard() {
const [startDate, setStartDate] = useState<Date | undefined>(
subMonths(new Date(), 1),
);
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
const [filteredTransactions, setFilteredTransactions] =
useState(transactions);
// Filter transactions by date range
const filterTransactions = () => {
if (!startDate || !endDate) return;
const filtered = transactions.filter((transaction) => {
const transactionDate = new Date(transaction.date);
return transactionDate >= startDate && transactionDate <= endDate;
});
setFilteredTransactions(filtered);
};
const { t } = useTextController(); const { t } = useTextController();
const { data } = useFetchMyCorporateInfoQuery({});
const [request, setTransactionFetchRequest] =
useState<CorporateTransactionRequest>({
limit: 10,
page: 1,
});
const {
data: transactionsResponse,
refetch,
isFetching,
} = useFetchCorporateTransactionsQuery(request);
useEffect(() => {
refetch();
}, [request]);
return ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col px-2.5'>
<main className='flex-1 py-10'> <main className='flex-1 py-10'>
<div className='container mx-auto max-w-6xl'> <div className='container mx-auto max-w-6xl'>
<div className='mb-8 flex items-center justify-between'> <div className='mb-4 flex flex-col items-start gap-4 sm:mb-8 sm:flex-row sm:items-center sm:justify-between'>
<h1 className='text-3xl font-bold'>{t('corporate.pageTitle')}</h1> <h1 className='text-3xl font-bold'>{t('corporate.pageTitle')}</h1>
<Button variant='outline' className='gap-2'> <Button
variant='outline'
className='gap-2'
onClick={() => {
deleteCookie(`corporate__token`);
window.location.reload();
}}
>
<LogOut className='h-4 w-4' /> <LogOut className='h-4 w-4' />
{t('corporate.logoutButton')} {t('corporate.logoutButton')}
</Button> </Button>
@ -110,7 +66,11 @@ export function CorporateDashboard() {
<div className='mb-10 grid gap-3 md:grid-cols-3 md:gap-6'> <div className='mb-10 grid gap-3 md:grid-cols-3 md:gap-6'>
{/* Company Card */} {/* Company Card */}
<Card className='md:col-span-2'> <Card
data-aos='zoom-in'
data-aos-mirror='true'
className='md:col-span-2'
>
<CardHeader className='pb-2'> <CardHeader className='pb-2'>
<CardTitle className='flex items-center gap-2'> <CardTitle className='flex items-center gap-2'>
<Building2 className='h-5 w-5 text-red-600' /> <Building2 className='h-5 w-5 text-red-600' />
@ -118,55 +78,67 @@ export function CorporateDashboard() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='grid gap-6 md:grid-cols-2'> {!data ? (
<div> <>Loading</>
<div className='mb-4 space-y-1'> ) : (
<p className='text-sm text-gray-500'> <div className='grid gap-6 md:grid-cols-2'>
{t('corporate.companyCard.companyNameLabel')} <div>
</p> <div className='mb-4 space-y-1'>
<p className='font-medium'>{companyData.companyName}</p> <p className='text-sm text-gray-500'>
{t('corporate.companyCard.companyNameLabel')}
</p>
<p className='truncate font-medium'>
{data.group_name}
</p>
</div>
<div className='mb-4 space-y-1'>
<p className='text-sm text-gray-500'>
{t('corporate.companyCard.cardsCountLabel')}
</p>
<p className='font-medium'>{data.total_cards}</p>
</div>
<div className='space-y-1'>
<p className='text-sm text-gray-500'>
{t('corporate.companyCard.registrationDateLabel')}
</p>
<p className='font-medium'>
{new Date(data.created_at).toLocaleDateString(
'en-GB',
)}
</p>
</div>
</div> </div>
<div className='mb-4 space-y-1'> <div>
<p className='text-sm text-gray-500'> <div className='mb-4 space-y-1'>
{t('corporate.companyCard.cardsCountLabel')} <p className='text-sm text-gray-500'>
</p> {t('corporate.companyCard.fundLabel')}
<p className='font-medium'>{companyData.numberOfCards}</p> </p>
</div> <p className='font-medium'>
<div className='space-y-1'> {data.fund.toLocaleString()} {t('corporate.currency')}
<p className='text-sm text-gray-500'> </p>
{t('corporate.companyCard.registrationDateLabel')} </div>
</p> <div className='mb-4 space-y-1'>
<p className='font-medium'> <p className='text-sm text-gray-500'>
{companyData.registrationDate} {t('corporate.companyCard.overdraftLabel')}
</p> </p>
<p className='font-medium'>
{data.overdraft.toLocaleString()}{' '}
{t('corporate.currency')}
</p>
</div>
</div> </div>
</div> </div>
<div> )}
<div className='mb-4 space-y-1'>
<p className='text-sm text-gray-500'>
{t('corporate.companyCard.fundLabel')}
</p>
<p className='font-medium'>
{companyData.fund.toLocaleString()}{' '}
{t('corporate.currency')}
</p>
</div>
<div className='mb-4 space-y-1'>
<p className='text-sm text-gray-500'>
{t('corporate.companyCard.overdraftLabel')}
</p>
<p className='font-medium'>
{companyData.overdraft.toLocaleString()}{' '}
{t('corporate.currency')}
</p>
</div>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Fund Card */} {/* Fund Card */}
<Card className='bg-gradient-to-br from-red-600 to-red-800 text-white'> <Card
data-aos='zoom-in'
data-aos-mirror='true'
className='bg-gradient-to-br from-red-600 to-red-800 text-white'
>
<CardHeader> <CardHeader>
<CardTitle className='flex items-center gap-2'> <CardTitle className='flex items-center gap-2'>
<Wallet className='h-5 w-5' /> <Wallet className='h-5 w-5' />
@ -177,23 +149,82 @@ export function CorporateDashboard() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='text-center'> {!data ? (
<p className='mb-2 text-4xl font-bold'> <>Loading</>
{companyData.totalFund.toLocaleString()} ) : (
</p> <div className='text-center'>
<p className='text-white/80'> <p className='mb-2 text-4xl font-bold'>
{t('corporate.fundCard.currency')} {data.fund_total?.toLocaleString()}
</p> </p>
</div> <p className='text-white/80'>
{t('corporate.fundCard.currency')}
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* <CardsList totalCards={companyData.numberOfCards} /> */} <TransactionsTable
isLoading={isFetching}
{/* Transactions */} renderHeaders={() => (
<TableRow>
<TransactionsTable /> <TableHead>
{t('corporate.transactions.tableHeaders.date')}
</TableHead>
<TableHead>
{t('corporate.transactions.tableHeaders.card')}
</TableHead>
<TableHead>
{t('corporate.transactions.tableHeaders.station')}
</TableHead>
<TableHead>
{t('corporate.transactions.tableHeaders.product')}
</TableHead>
<TableHead className='text-right'>
{t('corporate.transactions.tableHeaders.quantity')}
</TableHead>
<TableHead className='text-right'>
{t('corporate.transactions.tableHeaders.price')}
</TableHead>
<TableHead className='text-right'>
{t('corporate.transactions.tableHeaders.total')}
</TableHead>
</TableRow>
)}
renderRow={(transaction, index) => (
<TableRow key={index}>
<TableCell>
{formatDate(
new Date(transaction.date_create),
'dd.MM.yyyy HH:mm',
)}
</TableCell>
<TableCell>{transaction.uid}</TableCell>
<TableCell>{transaction.station_name}</TableCell>
<TableCell>{transaction.product_name}</TableCell>
<TableCell className='text-right'>
{transaction.amount}
</TableCell>
<TableCell className='text-right'>
{transaction.price_real} {t('corporate.currency')}
</TableCell>
<TableCell className='text-right font-medium'>
{transaction.sum_real} {t('corporate.currency')}
</TableCell>
</TableRow>
)}
data={
transactionsResponse || {
limit: 10,
current_page: 1,
total_pages: 0,
total_records: 0,
transactions: [],
}
}
onChange={setTransactionFetchRequest}
/>
</div> </div>
</main> </main>
</div> </div>

View File

@ -1,10 +1,18 @@
'use client'; 'use client';
import { deleteCookie } from 'cookies-next';
import { ArrowUpRight, Clock, CreditCard, LogOut, User } from 'lucide-react'; import { ArrowUpRight, Clock, CreditCard, LogOut, User } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFetchMyBonusInfoQuery } from '@/entities/bonus/api/bonus.api'; import {
useFetchBonusTransactionsQuery,
useFetchMyBonusInfoQuery,
} from '@/entities/bonus/api/bonus.api';
import { BonusTransactionRequest } from '@/entities/bonus/model/types/bonus-transactions.type';
import Loader from '@/shared/components/loader';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { formatDate } from '@/shared/lib/format-date';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { import {
Card, Card,
@ -13,31 +21,58 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/shared/shadcn-ui/card'; } from '@/shared/shadcn-ui/card';
import { TableCell, TableHead, TableRow } from '@/shared/shadcn-ui/table';
import { TransactionsTable } from '@/widgets/transactions-table'; import { TransactionsTable } from '@/widgets/transactions-table';
export function CustomerDashboard() { export function CustomerDashboard() {
const { t } = useTextController(); const { t } = useTextController();
const [request, setTransactionFetchRequest] =
useState<BonusTransactionRequest>({
limit: 10,
page: 1,
});
const { data, isLoading } = useFetchMyBonusInfoQuery({}); const { data, isLoading } = useFetchMyBonusInfoQuery({});
const {
data: transactionsResponse,
refetch,
isFetching: isTransactionLoading,
} = useFetchBonusTransactionsQuery(request);
useEffect(() => {
refetch();
}, [request]);
return ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col px-2.5'>
<main className='flex-1 py-10'> <main className='flex-1 py-10'>
<div className='container mx-auto max-w-6xl'> <div className='container mx-auto max-w-6xl'>
<div className='mb-8 flex items-center justify-between'> <div className='mb-8 flex items-center justify-between'>
<h1 className='text-3xl font-bold'>{t('customer.pageTitle')}</h1> <h1 className='text-3xl font-bold'>{t('customer.pageTitle')}</h1>
<Button variant='outline' className='gap-2'> <Button
variant='outline'
className='gap-2'
onClick={() => {
deleteCookie(`bonus__token`);
window.location.reload();
}}
>
<LogOut className='h-4 w-4' /> <LogOut className='h-4 w-4' />
{t('customer.logoutButton')} {t('customer.logoutButton')}
</Button> </Button>
</div> </div>
<div className='mb-10 grid gap-3 md:grid-cols-3 md:gap-6'> <div className='mb-10 grid gap-3 md:grid-cols-3 md:gap-6'>
<Card className='bg-gradient-to-br from-red-600 to-red-800 text-white'> <Card
data-aos='zoom-in'
data-aos-mirror='true'
className='bg-gradient-to-br from-red-600 to-red-800 text-white'
>
{!data || isLoading ? ( {!data || isLoading ? (
// TODO: Bunyod please add loader here <Loader />
<>Loader here</>
) : ( ) : (
<> <>
<CardHeader> <CardHeader>
@ -73,7 +108,11 @@ export function CustomerDashboard() {
{/* Bonus Card */} {/* Bonus Card */}
{/* Customer Card */} {/* Customer Card */}
<Card className='md:col-span-2'> <Card
data-aos='zoom-in'
data-aos-mirror='true'
className='md:col-span-2'
>
<CardHeader className='pb-2'> <CardHeader className='pb-2'>
<CardTitle className='flex items-center gap-2'> <CardTitle className='flex items-center gap-2'>
<User className='h-5 w-5 text-red-600' /> <User className='h-5 w-5 text-red-600' />
@ -82,8 +121,7 @@ export function CustomerDashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!data || isLoading ? ( {!data || isLoading ? (
// TODO: Bunyod please add loader here <Loader />
<>Loader here</>
) : ( ) : (
<div className='grid gap-6 md:grid-cols-2'> <div className='grid gap-6 md:grid-cols-2'>
<div> <div>
@ -116,7 +154,59 @@ export function CustomerDashboard() {
</Card> </Card>
</div> </div>
<TransactionsTable /> <TransactionsTable
isLoading={isTransactionLoading}
data={
transactionsResponse || {
limit: 10,
current_page: 1,
total_pages: 0,
total_records: 0,
transactions: [],
}
}
renderHeaders={() => (
<TableRow>
<TableHead>
{t('corporate.transactions.tableHeaders.date')}
</TableHead>
<TableHead>
{t('corporate.transactions.tableHeaders.station')}
</TableHead>
<TableHead>
{t('corporate.transactions.tableHeaders.product')}
</TableHead>
<TableHead className='text-right'>
{t('corporate.transactions.tableHeaders.quantity')}
</TableHead>
<TableHead className='text-right'>
{t('corporate.transactions.tableHeaders.price')}
</TableHead>
<TableHead className='text-right'>
{t('corporate.transactions.tableHeaders.total')}
</TableHead>
</TableRow>
)}
renderRow={(transaction) => (
<TableRow key={transaction.id}>
<TableCell>
{formatDate(transaction.date_create, 'dd.MM.yyyy HH:mm')}
</TableCell>
<TableCell>{transaction.station}</TableCell>
<TableCell>{transaction.product_name}</TableCell>
<TableCell className='text-right'>
{transaction.amount}
</TableCell>
<TableCell className='text-right'>
{transaction.price_real} {t('corporate.currency')}
</TableCell>
<TableCell className='text-right font-medium'>
{transaction.sum_real} {t('corporate.currency')}
</TableCell>
</TableRow>
)}
onChange={setTransactionFetchRequest}
/>
</div> </div>
</main> </main>
</div> </div>

View File

@ -2,11 +2,17 @@
import { Fuel, History, MapPin, Star, Target, Users } from 'lucide-react'; import { Fuel, History, MapPin, Star, Target, Users } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
// import { useTranslation } from 'next-i18next'; import { AboutUsPageData } from '@/app/api-utlities/@types/pages';
import { ReviewForm } from '@/features/review-form/ui';
import AnimatedCounter from '@/shared/components/animated-counter'; import AnimatedCounter from '@/shared/components/animated-counter';
import { Container } from '@/shared/components/container';
import { Review } from '@/shared/components/review';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useMediaController } from '@/shared/media/hooks/use-media-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { Card, CardContent } from '@/shared/shadcn-ui/card'; import { Card, CardContent } from '@/shared/shadcn-ui/card';
@ -14,13 +20,13 @@ import { CompanyTimeline } from '@/widgets/about-page/company-timeline';
import { StationGallery } from '@/widgets/about-page/station-gallery'; import { StationGallery } from '@/widgets/about-page/station-gallery';
import { CtaSection } from '@/widgets/cta-section'; import { CtaSection } from '@/widgets/cta-section';
export const metadata = { export interface AboutPageProps {
title: 'about.metadata.title', content: AboutUsPageData;
description: 'about.metadata.description', }
};
export default function AboutPage() { export default function AboutPage({ content }: AboutPageProps) {
const { t } = useTextController(); const { t } = useTextController();
const { m } = useMediaController();
return ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col'>
@ -29,15 +35,21 @@ export default function AboutPage() {
<section className='relative'> <section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'> <div className='relative h-[400px] w-full overflow-hidden'>
<Image <Image
src='/placeholder.svg?height=400&width=1920&text=Наша+История' src={
m('about.hero-section.banner') ||
'/placeholder.svg?height=400&width=1920&text=Наша+История'
}
alt={t('about.hero.imageAlt')} alt={t('about.hero.imageAlt')}
width={1920}
height={400}
className='object-cover' className='object-cover'
priority priority
fill
/> />
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'> <div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30 px-2'>
<div className='container mx-auto'> <div
data-aos='fade-down'
data-aos-duration='1000'
className='container mx-auto'
>
<div className='max-w-2xl space-y-4 text-white'> <div className='max-w-2xl space-y-4 text-white'>
<h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'> <h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'>
{t('about.hero.title')} {t('about.hero.title')}
@ -52,59 +64,63 @@ export default function AboutPage() {
</section> </section>
{/* Company Overview */} {/* Company Overview */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='grid items-center gap-12 md:grid-cols-2'>
<div className='grid items-center gap-12 md:grid-cols-2'> <div data-aos='fade-right'>
<div> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Fuel className='h-6 w-6 text-red-600' />
<Fuel className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.overview.title')}
</h2>
<p className='mb-6 text-gray-600'>
{t('about.overview.description1')}
</p>
<p className='mb-6 text-gray-600'>
{t('about.overview.description2')}
</p>
<p className='mb-6 text-gray-600'>
{t('about.overview.description3')}
</p>
<div className='mb-6 grid grid-cols-2 gap-4'>
{[0, 1, 2, 3].map((index) => (
<div key={index} className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t(`about.overview.benefits.${index}.title`)}
</h3>
<p className='text-gray-600'>
{t(`about.overview.benefits.${index}.description`)}
</p>
</div>
</div>
))}
</div>
</div> </div>
<div className='relative h-[500px] overflow-hidden rounded-xl shadow-xl'> <h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
<Image {t('about.overview.title')}
src='/placeholder.svg?height=500&width=600&text=Главный+офис' </h2>
alt={t('about.overview.imageAlt')} <p className='mb-6 text-gray-600'>
fill {t('about.overview.description1')}
className='object-cover' </p>
/> <p className='mb-6 text-gray-600'>
{t('about.overview.description2')}
</p>
<p className='mb-6 text-gray-600'>
{t('about.overview.description3')}
</p>
<div className='mb-6 grid grid-cols-1 gap-4 md:grid-cols-2'>
{[0, 1, 2, 3].map((index) => (
<div key={index} className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t(`about.overview.benefits.${index}.title`)}
</h3>
<p className='text-gray-600'>
{t(`about.overview.benefits.${index}.description`)}
</p>
</div>
</div>
))}
</div> </div>
</div> </div>
<div
data-aos='zoom-out-right'
className='relative h-[500px] overflow-hidden rounded-xl shadow-xl'
>
<Image
src={
m('about.second-section.banner') ||
'/placeholder.svg?height=500&width=600&text=Главный+офис'
}
alt={t('about.overview.imageAlt')}
fill
className='object-cover'
/>
</div>
</div> </div>
</section> </Container>
{/* Stats Section */} {/* Stats Section */}
<section className='bg-red-600 py-16 text-white'> <section className='bg-red-600 text-white'>
<div className='container mx-auto'> <Container>
<div className='mb-12 text-center'> <div className='mb-12 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'> <h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.stats.title')} {t('about.stats.title')}
@ -113,7 +129,7 @@ export default function AboutPage() {
{t('about.stats.subtitle')} {t('about.stats.subtitle')}
</p> </p>
</div> </div>
<div className='grid grid-cols-2 gap-8 text-center md:grid-cols-4'> <div className='grid grid-cols-1 gap-8 text-center sm:grid-cols-2 md:grid-cols-4'>
{[0, 1, 2, 3].map((index) => ( {[0, 1, 2, 3].map((index) => (
<div key={index} className='space-y-2'> <div key={index} className='space-y-2'>
<h3 className='text-4xl font-bold'> <h3 className='text-4xl font-bold'>
@ -133,31 +149,31 @@ export default function AboutPage() {
</div> </div>
))} ))}
</div> </div>
</div> </Container>
</section> </section>
{/* Our History */} {/* Our History */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='mb-12 flex flex-col items-center justify-center text-center'>
<div className='mb-12 flex flex-col items-center justify-center text-center'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <History className='h-6 w-6 text-red-600' />
<History className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.history.title')}
</h2>
<p className='max-w-2xl text-gray-600'>
{t('about.history.subtitle')}
</p>
</div> </div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
<CompanyTimeline /> {t('about.history.title')}
</h2>
<p className='max-w-2xl text-gray-600'>
{t('about.history.subtitle')}
</p>
</div> </div>
</section> </Container>
<Container>
<CompanyTimeline timeline={content.history} />
</Container>
{/* Our Stations */} {/* Our Stations */}
<section className='bg-gray-50 py-16'> <section className='bg-gray-50'>
<div className='container mx-auto'> <Container>
<div className='mb-12 flex flex-col items-center justify-center text-center'> <div className='mb-12 flex flex-col items-center justify-center text-center'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<MapPin className='h-6 w-6 text-red-600' /> <MapPin className='h-6 w-6 text-red-600' />
@ -169,62 +185,62 @@ export default function AboutPage() {
{t('about.stations.subtitle')} {t('about.stations.subtitle')}
</p> </p>
</div> </div>
<StationGallery stations={content.stations} />
<StationGallery />
<div className='mt-12 text-center'> <div className='mt-12 text-center'>
<p className='mx-auto mb-6 max-w-2xl text-gray-600'> <p className='mx-auto mb-6 max-w-2xl text-gray-600'>
{t('about.stations.description')} {t('about.stations.description')}
</p> </p>
<Button className='bg-red-600 hover:bg-red-700'> <Link href='/#stations'>
{t('about.stations.buttonText')}{' '} <Button className='bg-red-600 hover:bg-red-700'>
<MapPin className='ml-2 h-4 w-4' /> {t('about.stations.buttonText')}{' '}
</Button> <MapPin className='ml-2 h-4 w-4' />
</Button>
</Link>
</div> </div>
</div> </Container>
</section> </section>
{/* Our Values */} {/* Our Values */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='mb-12 flex flex-col items-center justify-center text-center'>
<div className='mb-12 flex flex-col items-center justify-center text-center'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Target className='h-6 w-6 text-red-600' />
<Target className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.values.title')}
</h2>
<p className='max-w-2xl text-gray-600'>
{t('about.values.subtitle')}
</p>
</div>
<div className='grid gap-8 md:grid-cols-3'>
{[0, 1, 2].map((index) => (
<Card
key={index}
className='overflow-hidden transition-all hover:shadow-lg'
>
<CardContent className='p-6'>
<div className='mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100'>
<Star className='h-6 w-6 text-red-600' />
</div>
<h3 className='mb-2 text-xl font-bold'>
{t(`about.values.items.${index}.title`)}
</h3>
<p className='text-gray-600'>
{t(`about.values.items.${index}.description`)}
</p>
</CardContent>
</Card>
))}
</div> </div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.values.title')}
</h2>
<p className='max-w-2xl text-gray-600'>
{t('about.values.subtitle')}
</p>
</div> </div>
</section>
<div className='grid gap-8 md:grid-cols-3'>
{[0, 1, 2].map((index) => (
<Card
data-aos='flip-left'
data-aos-duration='600'
key={index}
className='overflow-hidden transition-all hover:shadow-lg'
>
<CardContent className='p-6'>
<div className='mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100'>
<Star className='h-6 w-6 text-red-600' />
</div>
<h3 className='mb-2 text-xl font-bold'>
{t(`about.values.items.${index}.title`)}
</h3>
<p className='text-gray-600'>
{t(`about.values.items.${index}.description`)}
</p>
</CardContent>
</Card>
))}
</div>
</Container>
{/* Our Team */} {/* Our Team */}
<section className='bg-gray-50 py-16'> <section className='bg-gray-50'>
<div className='container mx-auto'> <Container>
<div className='mb-12 flex flex-col items-center justify-center text-center'> <div className='mb-12 flex flex-col items-center justify-center text-center'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Users className='h-6 w-6 text-red-600' /> <Users className='h-6 w-6 text-red-600' />
@ -236,79 +252,59 @@ export default function AboutPage() {
{t('about.team.subtitle')} {t('about.team.subtitle')}
</p> </p>
</div> </div>
<div
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4'> data-aos='flip-left'
{[0, 1, 2, 3].map((index) => ( data-aos-duration='600'
className='grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4'
>
{content.team.map((member, index) => (
<div <div
key={index} key={index}
className='overflow-hidden rounded-lg bg-white shadow-md transition-transform hover:scale-105' className='overflow-hidden rounded-lg bg-white shadow-md transition-transform hover:scale-105'
> >
<div className='relative h-64 w-full'> <div className='relative h-64 w-full'>
<Image {member.photo && (
src={`/placeholder.svg?height=300&width=300&text=${t(`about.team.members.${index}.name`)}`} <Image
alt={t(`about.team.members.${index}.name`)} src={member.photo}
fill alt={t(`about.team.members.${index}.name`)}
className='object-cover' fill
/> className='object-cover'
/>
)}
</div> </div>
<div className='p-4 text-center'> <div className='p-4 text-center'>
<h3 className='text-lg font-bold'> <h3 className='text-lg font-bold'>{member.name}</h3>
{t(`about.team.members.${index}.name`)} <p className='text-gray-600'>{member.profession}</p>
</h3>
<p className='text-gray-600'>
{t(`about.team.members.${index}.position`)}
</p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </Container>
</section> </section>
{/* Testimonials */} {/* Testimonials */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='mb-12 flex flex-col items-center justify-center text-center'>
<div className='mb-12 flex flex-col items-center justify-center text-center'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Star className='h-6 w-6 text-red-600' />
<Star className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.testimonials.title')}
</h2>
<p className='max-w-2xl text-gray-600'>
{t('about.testimonials.subtitle')}
</p>
</div>
<div className='grid gap-8 md:grid-cols-3'>
{[0, 1, 2].map((index) => (
<Card
key={index}
className='overflow-hidden transition-all hover:shadow-lg'
>
<CardContent className='p-6'>
<div className='mb-4 flex'>
{Array(5)
.fill(0)
.map((_, i) => (
<Star
key={i}
className={`h-5 w-5 ${i < Number(t(`about.testimonials.items.${index}.rating`)) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`}
/>
))}
</div>
<p className='mb-4 text-gray-600 italic'>
"{t(`about.testimonials.items.${index}.text`)}"
</p>
<p className='font-semibold'>
{t(`about.testimonials.items.${index}.name`)}
</p>
</CardContent>
</Card>
))}
</div> </div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('about.testimonials.title')}
</h2>
<p className='max-w-2xl text-gray-600'>
{t('about.testimonials.subtitle')}
</p>
</div> </div>
</section>
<div data-aos='zoom-out-right' className='grid gap-8 md:grid-cols-3'>
{content.reviews.map((review, index) => (
<Review key={review.id} review={review} />
))}
</div>
<div className='mt-4 flex w-full justify-center'>
<ReviewForm />
</div>
</Container>
<CtaSection /> <CtaSection />
</main> </main>

View File

@ -10,27 +10,27 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { CharityPageData } from '@/app/api-utlities/@types/pages';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { useMediaController } from '@/shared/media/hooks/use-media-controller';
import { import {
Card, Card,
CardContent, CardContent,
CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/shared/shadcn-ui/card'; } from '@/shared/shadcn-ui/card';
import { CtaSection } from '@/widgets/cta-section'; import { CtaSection } from '@/widgets/cta-section';
export const metadata = { export interface CharityPageProps {
title: 'Благотворительность | GasNetwork - Сеть заправок в Таджикистане', content: CharityPageData;
description: }
'Благотворительные проекты и инициативы GasNetwork. Мы помогаем обществу и заботимся о будущем.',
};
export function CharityPage() { export function CharityPage({ content }: CharityPageProps) {
const { t } = useTextController(); const { t } = useTextController();
const { m } = useMediaController();
return ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col'>
<main className='flex-1'> <main className='flex-1'>
@ -38,158 +38,145 @@ export function CharityPage() {
<section className='relative'> <section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'> <div className='relative h-[400px] w-full overflow-hidden'>
<Image <Image
src='/placeholder.svg?height=500&width=1920&text=Благотворительный+фонд+GasNetwork' src={
m('charity.hero-section.banner') ||
'/placeholder.svg?height=500&width=1920&text=Благотворительный+фонд+Ориё'
}
alt={t('charity.hero.imageAlt')} alt={t('charity.hero.imageAlt')}
width={1920}
height={500}
className='object-cover' className='object-cover'
priority priority
fill
/> />
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'> <div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'>
<div className='container mx-auto'> <Container data-aos='fade-down' data-aos-duration='800'>
<div className='max-w-2xl space-y-6 text-white'> <div className='max-w-2xl space-y-6 text-white'>
<div className='inline-flex items-center justify-center rounded-full bg-red-600/20 p-2'> <div className='inline-flex items-center justify-center rounded-full bg-red-600/20 p-2'>
<Heart className='size-6 text-red-500' /> <Heart className='size-6 text-red-500' />
</div> </div>
<h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'> <h1 className='text-3xl font-bold tracking-tight sm:text-5xl md:text-6xl'>
{t('charity.hero.title')} {t('charity.hero.title')}
</h1> </h1>
<p className='text-xl text-gray-200'> <p className='text-lg text-gray-200 sm:text-xl'>
{t('charity.hero.subtitle')} {t('charity.hero.subtitle')}
</p> </p>
</div> </div>
</div> </Container>
</div> </div>
</div> </div>
</section> </section>
{/* Mission Section */} {/* Mission Section */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='grid items-center gap-12 md:grid-cols-2'>
<div className='grid items-center gap-12 md:grid-cols-2'> <div>
<div> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Heart className='h-6 w-6 text-red-600' />
<Heart className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('charity.mission.title')}
</h2>
<p className='mb-6 text-gray-600'>
{t('charity.mission.description1')}
</p>
<p className='mb-6 text-gray-600'>
{t('charity.mission.description2')}
</p>
<div className='space-y-4'>
{[0, 1, 2].map((index) => (
<div key={index} className='flex items-start'>
<CheckCircle className='mr-3 h-6 w-6 flex-shrink-0 text-red-600' />
<div>
<h3 className='text-lg font-medium'>
{t(`charity.mission.principles.${index}.title`)}
</h3>
<p className='text-gray-600'>
{t(`charity.mission.principles.${index}.description`)}
</p>
</div>
</div>
))}
</div>
</div> </div>
<div className='relative h-[500px] overflow-hidden rounded-xl shadow-xl'> <h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
<Image {t('charity.mission.title')}
src='/placeholder.svg?height=500&width=600&text=Наша+миссия' </h2>
alt={t('charity.mission.imageAlt')} <p className='mb-6 text-gray-600'>
fill {t('charity.mission.description1')}
className='object-cover' </p>
/> <p className='mb-6 text-gray-600'>
{t('charity.mission.description2')}
</p>
<div className='space-y-4'>
{[0, 1, 2].map((index) => (
<div
data-aos='fade-right'
key={index}
className='flex items-start'
>
<CheckCircle className='mr-3 h-6 w-6 flex-shrink-0 text-red-600' />
<div>
<h3 className='text-lg font-medium'>
{t(`charity.mission.principles.${index}.title`)}
</h3>
<p className='text-gray-600'>
{t(`charity.mission.principles.${index}.description`)}
</p>
</div>
</div>
))}
</div> </div>
</div> </div>
<div
data-aos='fade-right'
className='relative h-[500px] overflow-hidden rounded-xl shadow-xl'
>
<Image
src={
m('charity.second-section.banner') ||
'/placeholder.svg?height=500&width=600&text=Наша+миссия'
}
alt={t('charity.mission.imageAlt')}
fill
className='object-cover'
/>
</div>
</div> </div>
</section> </Container>
{/* Key Figures */} {/* Key Figures */}
<section className='bg-red-600 py-16 text-white'> <Container className='bg-red-600 text-white'>
<div className='container mx-auto'> <div className='mb-12 text-center'>
<div className='mb-12 text-center'> <h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'> {t('charity.stats.title')}
{t('charity.stats.title')} </h2>
</h2> <p className='mx-auto max-w-2xl text-white/80'>
<p className='mx-auto max-w-2xl text-white/80'> {t('charity.stats.subtitle')}
{t('charity.stats.subtitle')} </p>
</p>
</div>
<div className='grid grid-cols-1 gap-8 text-center md:grid-cols-3'>
{[0, 1, 2].map((index) => (
<div key={index} className='space-y-2'>
<h3 className='text-4xl font-bold'>
{t(`charity.stats.items.${index}.value`)}
</h3>
<p className='text-white/80'>
{t(`charity.stats.items.${index}.label`)}
</p>
</div>
))}
</div>
</div> </div>
</section> <div className='grid grid-cols-1 gap-8 text-center md:grid-cols-3'>
{[0, 1, 2].map((index) => (
<div key={index} className='space-y-2'>
<h3 className='text-4xl font-bold'>
{t(`charity.stats.items.${index}.value`)}
</h3>
<p className='text-white/80'>
{t(`charity.stats.items.${index}.label`)}
</p>
</div>
))}
</div>
</Container>
{/* Upcoming Events */} {/* Upcoming Events */}
<section className='bg-gray-50 py-16'> <Container className='bg-gray-50'>
<div className='container mx-auto'> <div className='mb-12 text-center'>
<div className='mb-12 text-center'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Calendar className='h-6 w-6 text-red-600' />
<Calendar className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('charity.events.title')}
</h2>
<p className='mx-auto max-w-2xl text-gray-600'>
{t('charity.events.subtitle')}
</p>
</div> </div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('charity.events.title')}
</h2>
<p className='mx-auto max-w-2xl text-gray-600'>
{t('charity.events.subtitle')}
</p>
</div>
<div className='grid gap-6 md:grid-cols-3'> <div className='grid gap-6 md:grid-cols-3'>
{[ {content.charities.map((event, index) => (
{ <Card
title: 'Благотворительный марафон', data-aos='zoom-in-up'
description: key={index}
'Ежегодный благотворительный марафон в поддержку детей с особыми потребностями.', className='flex flex-col justify-between overflow-hidden'
date: '15 июня 2023', >
location: 'Парк Рудаки, Душанбе', <div>
image: '/placeholder.svg?height=200&width=300&text=Марафон',
},
{
title: 'Экологическая акция',
description:
'Очистка берегов реки Варзоб от мусора и посадка деревьев.',
date: '22 июля 2023',
location: 'Река Варзоб, Душанбе',
image:
'/placeholder.svg?height=200&width=300&text=Экологическая+акция',
},
{
title: 'Сбор школьных принадлежностей',
description:
'Сбор школьных принадлежностей для детей из малообеспеченных семей к новому учебному году.',
date: '1-20 августа 2023',
location: 'Все заправки GasNetwork',
image:
'/placeholder.svg?height=200&width=300&text=Школьные+принадлежности',
},
].map((event, index) => (
<Card key={index} className='overflow-hidden'>
<div className='relative h-48 w-full'> <div className='relative h-48 w-full'>
<Image <Image
src={event.image || '/placeholder.svg'} src={event.image || '/placeholder.svg'}
alt={event.title} alt={event.name}
fill fill
className='object-cover' className='object-cover'
/> />
</div> </div>
<CardHeader> <CardHeader>
<CardTitle>{event.title}</CardTitle> <CardTitle className='text-xl lg:text-2xl'>
{event.name}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<p className='text-gray-600'>{event.description}</p> <p className='text-gray-600'>{event.description}</p>
@ -202,74 +189,72 @@ export function CharityPage() {
{event.location} {event.location}
</div> </div>
</CardContent> </CardContent>
<CardFooter> </div>
<Button className='w-full bg-red-600 hover:bg-red-700'> {/* <CardFooter>
{t(`charity.events.button`)} <Button className='w-full bg-red-600 hover:bg-red-700'>
</Button> {t(`charity.events.button`)}
</CardFooter> </Button>
</Card> </CardFooter> */}
))} </Card>
</div> ))}
</div> </div>
</section> </Container>
{/* How to Help */} {/* How to Help */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='mb-12 text-center'>
<div className='mb-12 text-center'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Users className='h-6 w-6 text-red-600' />
<Users className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('charity.help.title')}
</h2>
<p className='mx-auto max-w-2xl text-gray-600'>
{t('charity.help.subtitle')}
</p>
</div>
<div className='grid gap-3 md:grid-cols-2 md:gap-6 lg:grid-cols-4'>
{[
{
title: 'Сделать пожертвование',
description:
'Ваше пожертвование поможет нам реализовать больше проектов и помочь большему количеству людей.',
icon: <Landmark className='h-10 w-10 text-red-600' />,
},
{
title: 'Стать волонтером',
description:
'Присоединяйтесь к нашей команде волонтеров и помогайте нам в реализации благотворительных проектов.',
icon: <Users className='h-10 w-10 text-red-600' />,
},
{
title: 'Участвовать в мероприятиях',
description:
'Принимайте участие в наших благотворительных мероприятиях и акциях.',
icon: <Calendar className='h-10 w-10 text-red-600' />,
},
{
title: 'Распространять информацию',
description:
'Расскажите о нашем фонде и его деятельности своим друзьям и знакомым.',
icon: <Heart className='h-10 w-10 text-red-600' />,
},
].map((item, index) => (
<Card key={index} className='text-center'>
<CardHeader>
<div className='mb-4 flex justify-center'>{item.icon}</div>
<CardTitle className='break-words hyphens-auto'>
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className='text-gray-600'>{item.description}</p>
</CardContent>
</Card>
))}
</div> </div>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('charity.help.title')}
</h2>
<p className='mx-auto max-w-2xl text-gray-600'>
{t('charity.help.subtitle')}
</p>
</div> </div>
</section>
<div className='grid gap-3 md:grid-cols-2 md:gap-6 lg:grid-cols-4'>
{[
{
title: 'Сделать пожертвование',
description:
'Ваше пожертвование поможет нам реализовать больше проектов и помочь большему количеству людей.',
icon: <Landmark className='h-10 w-10 text-red-600' />,
},
{
title: 'Стать волонтером',
description:
'Присоединяйтесь к нашей команде волонтеров и помогайте нам в реализации благотворительных проектов.',
icon: <Users className='h-10 w-10 text-red-600' />,
},
{
title: 'Участвовать в мероприятиях',
description:
'Принимайте участие в наших благотворительных мероприятиях и акциях.',
icon: <Calendar className='h-10 w-10 text-red-600' />,
},
{
title: 'Распространять информацию',
description:
'Расскажите о нашем фонде и его деятельности своим друзьям и знакомым.',
icon: <Heart className='h-10 w-10 text-red-600' />,
},
].map((item, index) => (
<Card data-aos='zoom-in' key={index} className='text-center'>
<CardHeader>
<div className='mb-4 flex justify-center'>{item.icon}</div>
<CardTitle className='break-words hyphens-auto'>
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className='text-gray-600'>{item.description}</p>
</CardContent>
</Card>
))}
</div>
</Container>
<CtaSection /> <CtaSection />
</main> </main>
</div> </div>

View File

@ -3,69 +3,23 @@
import { Download, Eye } from 'lucide-react'; import { Download, Eye } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { CertificatesPageData } from '@/app/api-utlities/@types/pages';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { Card, CardContent } from '@/shared/shadcn-ui/card'; import { Card, CardContent } from '@/shared/shadcn-ui/card';
export function CertificatesPage() { export interface CertificatesPageProps {
content: CertificatesPageData;
}
export function CertificatesPage({ content }: CertificatesPageProps) {
const { t } = useTextController(); const { t } = useTextController();
// This data would typically come from an API or CMS
// We're keeping it as-is since it's dynamic content
const certificates = [
{
id: 1,
title: 'ISO 9001:2015',
description: 'Сертификат системы менеджмента качества',
image: '/placeholder.svg?height=400&width=300',
issueDate: '15.03.2022',
expiryDate: '15.03.2025',
},
{
id: 2,
title: 'ISO 14001:2015',
description: 'Сертификат экологического менеджмента',
image: '/placeholder.svg?height=400&width=300',
issueDate: '10.05.2022',
expiryDate: '10.05.2025',
},
{
id: 3,
title: 'OHSAS 18001',
description: 'Сертификат системы управления охраной труда',
image: '/placeholder.svg?height=400&width=300',
issueDate: '22.07.2022',
expiryDate: '22.07.2025',
},
{
id: 4,
title: 'Сертификат качества топлива',
description: 'Подтверждение соответствия топлива стандартам качества',
image: '/placeholder.svg?height=400&width=300',
issueDate: '05.01.2023',
expiryDate: '05.01.2024',
},
{
id: 5,
title: 'Сертификат соответствия',
description: 'Соответствие услуг национальным стандартам',
image: '/placeholder.svg?height=400&width=300',
issueDate: '18.09.2022',
expiryDate: '18.09.2025',
},
{
id: 6,
title: 'Лицензия на хранение ГСМ',
description: 'Разрешение на хранение горюче-смазочных материалов',
image: '/placeholder.svg?height=400&width=300',
issueDate: '30.11.2021',
expiryDate: '30.11.2026',
},
];
return ( return (
<> <main>
<main className='container mx-auto py-10'> <Container>
<div className='mb-10 text-center'> <div className='mb-10 text-center'>
<h1 className='mb-4 text-4xl font-bold'>{t('certificates.title')}</h1> <h1 className='mb-4 text-4xl font-bold'>{t('certificates.title')}</h1>
<p className='mx-auto max-w-2xl text-lg text-gray-600'> <p className='mx-auto max-w-2xl text-lg text-gray-600'>
@ -74,32 +28,33 @@ export function CertificatesPage() {
</div> </div>
<div className='grid gap-8 md:grid-cols-2 lg:grid-cols-3'> <div className='grid gap-8 md:grid-cols-2 lg:grid-cols-3'>
{certificates.map((certificate) => ( {content.certificates.map((certificate) => (
<Card <Card
data-aos='zoom-in'
key={certificate.id} key={certificate.id}
className='overflow-hidden transition-all duration-300 hover:shadow-lg' className='overflow-hidden transition-all duration-300 hover:shadow-lg'
> >
<div className='relative h-[300px] w-full overflow-hidden bg-gray-100'> <div className='relative h-[300px] w-full overflow-hidden bg-gray-100'>
<Image <Image
src={certificate.image || '/placeholder.svg'} src={certificate.image || '/placeholder.svg'}
alt={certificate.title} alt={certificate.name}
fill fill
className='object-contain p-4' className='object-contain p-4'
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw' sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/> />
</div> </div>
<CardContent className='p-6'> <CardContent className='p-6'>
<h3 className='mb-2 text-xl font-bold'>{certificate.title}</h3> <h3 className='mb-2 text-xl font-bold'>{certificate.name}</h3>
<p className='mb-4 text-gray-600'>{certificate.description}</p> <p className='mb-4 text-gray-600'>{certificate.description}</p>
<div className='mb-4 text-sm text-gray-500'> <div className='mb-4 text-sm text-gray-500'>
<p> <p>
{t('certificates.issueDate')}: {certificate.issueDate} {t('certificates.issueDate')}: {certificate.issuedAt}
</p> </p>
<p> <p>
{t('certificates.expiryDate')}: {certificate.expiryDate} {t('certificates.expiryDate')}: {certificate.validUntil}
</p> </p>
</div> </div>
<div className='flex gap-2'> {/* <div className='flex gap-2'>
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
@ -116,12 +71,12 @@ export function CertificatesPage() {
<Download size={16} /> <Download size={16} />
<span>{t('common.buttons.download')}</span> <span>{t('common.buttons.download')}</span>
</Button> </Button>
</div> </div> */}
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
</main> </Container>
</> </main>
); );
} }

View File

@ -2,20 +2,17 @@
import Image from 'next/image'; import Image from 'next/image';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useMediaController } from '@/shared/media/hooks/use-media-controller';
import { BenefitsSection } from '@/widgets/clients/ui/benefits-section'; import { BenefitsSection } from '@/widgets/clients/ui/benefits-section';
import { ServicesOverviewSection } from '@/widgets/clients/ui/services-overview-section'; import { ServicesOverviewSection } from '@/widgets/clients/ui/services-overview-section';
import { CtaSection } from '@/widgets/cta-section'; import { CtaSection } from '@/widgets/cta-section';
export const metadata = {
title: 'Клиентам | GasNetwork - Сеть заправок в Таджикистане',
description:
'Информация для клиентов: программа лояльности, топливные карты, сертификаты и способы оплаты.',
};
export function ClientsPage() { export function ClientsPage() {
const { t } = useTextController(); const { t } = useTextController();
const { m } = useMediaController();
return ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col'>
@ -24,24 +21,32 @@ export function ClientsPage() {
<section className='relative'> <section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'> <div className='relative h-[400px] w-full overflow-hidden'>
<Image <Image
src='/placeholder.svg?height=400&width=1920&text=Для+наших+клиентов' src={
m('clients.hero-section.banner') ||
'/placeholder.svg?height=400&width=1920&text=Для+наших+клиентов'
}
alt='Для наших клиентов' alt='Для наших клиентов'
width={1920}
height={400}
className='object-cover' className='object-cover'
priority priority
fill
/> />
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'> <div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'>
<div className='container mx-auto'> <Container className='py-0'>
<div className='max-w-2xl space-y-4 text-white'> <div
<h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'> data-aos='fade-down'
{t('clients.title')} data-aos-duration='1000'
</h1> className='container mx-auto'
<p className='text-lg text-gray-200'> >
{t('clients.description')} <div className='max-w-2xl space-y-4 text-white'>
</p> <h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'>
{t('clients.title')}
</h1>
<p className='text-lg text-gray-200'>
{t('clients.description')}
</p>
</div>
</div> </div>
</div> </Container>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,21 +1,20 @@
'use client'; 'use client';
import { Check, Percent } from 'lucide-react'; import { Percent } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Card, CardContent } from '@/shared/shadcn-ui/card'; import { useMediaController } from '@/shared/media/hooks/use-media-controller';
// import LoyaltyLevels from '@/widgets/clients/loyalty/ui/loyalty-levels';
import { CtaSection } from '@/widgets/cta-section'; import { CtaSection } from '@/widgets/cta-section';
export const metadata = { import ProgrammImg from '../../../../public/clients/loyatly/03a771e7-5d76-4111-a516-801aa925659f.jpg';
title: 'Программа лояльности | GasNetwork - Сеть заправок в Таджикистане',
description:
'Программа лояльности GasNetwork: накапливайте баллы и получайте скидки на топливо и услуги.',
};
export function LoyaltyPage() { export function LoyaltyPage() {
const { t } = useTextController(); const { t } = useTextController();
const { m } = useMediaController();
return ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col'>
@ -24,15 +23,17 @@ export function LoyaltyPage() {
<section className='relative'> <section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'> <div className='relative h-[400px] w-full overflow-hidden'>
<Image <Image
src='/placeholder.svg?height=400&width=1920&text=Программа+лояльности' src={
m('loyalty.hero-section.banner') ||
'/placeholder.svg?height=400&width=1920&text=Программа+лояльности'
}
alt='Программа лояльности' alt='Программа лояльности'
width={1920}
height={400}
className='object-cover' className='object-cover'
priority priority
fill
/> />
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'> <div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'>
<div className='container mx-auto'> <Container data-aos='fade-down' data-aos-duration='800'>
<div className='max-w-2xl space-y-4 text-white'> <div className='max-w-2xl space-y-4 text-white'>
<h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'> <h1 className='text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl'>
{t('clients.loyalty.title')} {t('clients.loyalty.title')}
@ -41,86 +42,88 @@ export function LoyaltyPage() {
{t('clients.loyalty.description')} {t('clients.loyalty.description')}
</p> </p>
</div> </div>
</div> </Container>
</div> </div>
</div> </div>
</section> </section>
{/* Program Overview */} {/* Program Overview */}
<section className='py-16'> <Container>
<div className='container mx-auto'> <div className='grid items-center gap-12 md:grid-cols-2'>
<div className='grid items-center gap-12 md:grid-cols-2'> <div data-aos='fade-right'>
<div> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <Percent className='h-6 w-6 text-red-600' />
<Percent className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('clients.loyalty.programm.about')}
</h2>
<p className='mb-6 text-gray-600'>
{t('clients.loyalty.programm.about-description')}
</p>
<p className='mb-6 text-gray-600'>
{t('clients.loyalty.programm.about-description-2')}
</p>
<div className='space-y-4'>
<div className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t('clients.loyalty.programm.conditions-1')}
</h3>
<p className='text-gray-600'>
{t('clients.loyalty.programm.conditions.description-1')}
</p>
</div>
</div>
<div className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t('clients.loyalty.programm.conditions-2')}
</h3>
<p className='text-gray-600'>
{t('clients.loyalty.programm.conditions.description-2')}
</p>
</div>
</div>
<div className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t('clients.loyalty.programm.conditions-3')}
</h3>
<p className='text-gray-600'>
{t('clients.loyalty.programm.conditions.description-3')}
</p>
</div>
</div>
</div>
</div> </div>
<div className='relative h-[400px] overflow-hidden rounded-xl shadow-xl'> <h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
<Image {t('clients.loyalty.programm.about')}
src='/placeholder.svg?height=400&width=600&text=Программа+лояльности' </h2>
alt='Программа лояльности' <p className='mb-6 text-gray-600'>
fill {t('clients.loyalty.programm.about-description')}
className='object-cover' </p>
/> <p className='mb-6 text-gray-600'>
{t('clients.loyalty.programm.about-description-2')}
</p>
<div className='space-y-4'>
<div className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t('clients.loyalty.programm.conditions-1')}
</h3>
<p className='text-gray-600'>
{t('clients.loyalty.programm.conditions.description-1')}
</p>
</div>
</div>
<div className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t('clients.loyalty.programm.conditions-2')}
</h3>
<p className='text-gray-600'>
{t('clients.loyalty.programm.conditions.description-2')}
</p>
</div>
</div>
<div className='flex items-start'>
<div className='mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-red-600'>
<span className='text-xs text-white'></span>
</div>
<div className='ml-3'>
<h3 className='text-lg font-medium'>
{t('clients.loyalty.programm.conditions-3')}
</h3>
<p className='text-gray-600'>
{t('clients.loyalty.programm.conditions.description-3')}
</p>
</div>
</div>
</div> </div>
</div> </div>
<div
data-aos='fade-up'
className='relative h-[400px] overflow-hidden rounded-xl shadow-xl'
>
<Image
src={m('loyalty.second-section.banner') || ProgrammImg}
alt='Программа лояльности'
fill
className='w-full object-contain p-2.5'
priority
/>
</div>
</div> </div>
</section> </Container>
{/* How It Works */} {/* How It Works */}
<section className='bg-gray-50 py-16'> <section className='bg-gray-50'>
<div className='container mx-auto'> <Container>
<div className='mb-12 text-center'> <div className='mb-12 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'> <h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('clients.loyalty.works.title')} {t('clients.loyalty.works.title')}
@ -129,9 +132,8 @@ export function LoyaltyPage() {
{t('clients.loyalty.works.description')} {t('clients.loyalty.works.description')}
</p> </p>
</div> </div>
<div className='grid gap-8 sm:grid-cols-2 lg:grid-cols-4'>
<div className='grid gap-8 md:grid-cols-4'> <div data-aos='zoom-in-up' className='text-center'>
<div className='text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'> <div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'>
1 1
</div> </div>
@ -142,7 +144,7 @@ export function LoyaltyPage() {
{t('clients.loyalty.works.stage.description-1')} {t('clients.loyalty.works.stage.description-1')}
</p> </p>
</div> </div>
<div className='text-center'> <div data-aos='zoom-in-up' className='text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'> <div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'>
2 2
</div> </div>
@ -153,7 +155,7 @@ export function LoyaltyPage() {
{t('clients.loyalty.works.stage.description-2')} {t('clients.loyalty.works.stage.description-2')}
</p> </p>
</div> </div>
<div className='text-center'> <div data-aos='zoom-in-up' className='text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'> <div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'>
3 3
</div> </div>
@ -164,7 +166,7 @@ export function LoyaltyPage() {
{t('clients.loyalty.works.stage.description-3')} {t('clients.loyalty.works.stage.description-3')}
</p> </p>
</div> </div>
<div className='text-center'> <div data-aos='zoom-in-up' className='text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'> <div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-2xl font-bold text-white'>
4 4
</div> </div>
@ -176,150 +178,10 @@ export function LoyaltyPage() {
</p> </p>
</div> </div>
</div> </div>
</div> </Container>
</section> </section>
{/* Loyalty Levels */} {/* <LoyaltyLevels /> */}
<section className='py-16'>
<div className='container mx-auto'>
<div className='mb-12 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('clients.loyalty.works.levels.title')}
</h2>
<p className='mx-auto max-w-2xl text-gray-600'>
{t('clients.loyalty.works.levels.description')}
</p>
</div>
<div className='grid gap-8 md:grid-cols-3'>
<Card className='overflow-hidden border-t-4 border-t-gray-400 transition-all hover:shadow-lg'>
<CardContent className='p-6'>
<h3 className='mb-4 text-center text-2xl font-bold'>
{t('clients.loyalty.works.levels.card-1.title')}
</h3>
<div className='mb-6 text-center'>
<span className='text-4xl font-bold'>
{t('clients.loyalty.works.levels.card-1.percent')}
</span>
<p className='text-sm text-gray-600'>
{t('clients.loyalty.works.levels.card.mark')}
</p>
</div>
<ul className='space-y-2'>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-1.bonus-1')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-1.bonus-2')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-1.bonus-3')}
</span>
</li>
</ul>
</CardContent>
</Card>
<Card className='overflow-hidden border-t-4 border-t-yellow-500 transition-all hover:shadow-lg'>
<CardContent className='p-6'>
<h3 className='mb-4 text-center text-2xl font-bold'>
{t('clients.loyalty.works.levels.card-2.title')}
</h3>
<div className='mb-6 text-center'>
<span className='text-4xl font-bold'>
{t('clients.loyalty.works.levels.card-2.percent')}
</span>
<p className='text-sm text-gray-600'>
{t('clients.loyalty.works.levels.card.mark')}
</p>
</div>
<ul className='space-y-2'>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-1.bonus-1')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-2.bonus-2')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-3.bonus-3')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-4.bonus-4')}
</span>
</li>
</ul>
</CardContent>
</Card>
<Card className='overflow-hidden border-t-4 border-t-red-600 transition-all hover:shadow-lg'>
<CardContent className='p-6'>
<h3 className='mb-4 text-center text-2xl font-bold'>
{t('clients.loyalty.works.levels.card-3.title')}
</h3>
<div className='mb-6 text-center'>
<span className='text-4xl font-bold'>
{t('clients.loyalty.works.levels.card-3.percent')}
</span>
<p className='text-sm text-gray-600'>
{t('clients.loyalty.works.levels.card.mark')}
</p>
</div>
<ul className='space-y-2'>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-3.bonus-1')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-3.bonus-2')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-3.bonus-3')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-3.bonus-4')}
</span>
</li>
<li className='flex items-center'>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>
{t('clients.loyalty.works.levels.card-3.bonus-5')}
</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</section>
<CtaSection /> <CtaSection />
</main> </main>

View File

@ -4,9 +4,11 @@ import { deleteCookie, getCookie } from 'cookies-next';
import { Building2, Fuel, User } from 'lucide-react'; import { Building2, Fuel, User } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { LoginForm } from '@/features/auth/login-form'; import { LoginForm } from '@/features/auth/login-form';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { import {
@ -40,20 +42,101 @@ const tabs = [
}, },
]; ];
export default function LoginPage() { function LoginPageTabs() {
const { t } = useTextController(); const { t } = useTextController();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const defaultTab = searchParams.get('tab') || 'bonus'; const defaultTab = searchParams.get('tab') || 'bonus';
const handleTabChange = (tabType: string) => { const handleTabChange = (tabType: string) => {
router.push(`?tab=${tabType}`, undefined, { shallow: true }); router.push(`?tab=${tabType}`, undefined);
}; };
return ( return (
<div className='flex min-h-screen flex-col items-center justify-center'> <Tabs
<main className='flex-1'> defaultValue={defaultTab}
<div className='container max-w-6xl py-16'> value={defaultTab}
onValueChange={handleTabChange}
className='w-full'
>
<TabsList className='mb-8 flex h-fit w-full flex-col sm:flex-row'>
{tabs.map((tab) => {
return (
<TabsTrigger
key={tab.label}
value={tab.type}
className='w-full cursor-pointer text-base'
>
<tab.Icon className='mr-2 h-4 w-4' /> {t(tab.label)}
</TabsTrigger>
);
})}
</TabsList>
{tabs.map((tab) => {
const tabCookieName = `${tab.type}__token`;
const authenticationCookie = getCookie(tabCookieName);
if (authenticationCookie) {
return (
<TabsContent key={tab.label} value={tab.type}>
<Card>
<CardHeader>
<CardTitle>{t(tab.title)}</CardTitle>
</CardHeader>
<CardContent className='flex justify-center gap-2 space-y-4'>
<Link
href={
tab.type === 'bonus'
? '/customer-dashboard'
: '/corporate-dashboard'
}
>
<Button className='flex items-center'>Открыть</Button>
</Link>
<Button
variant='outline'
className='flex items-center gap-2'
onClick={() => {
deleteCookie(tabCookieName);
window.location.reload();
}}
>
Выйти
</Button>
</CardContent>
</Card>
</TabsContent>
);
}
return (
<TabsContent key={tab.label} value={tab.type}>
<Card>
<CardHeader>
<CardTitle>{t(tab.title)}</CardTitle>
<CardDescription>{t(tab.description)}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<LoginForm type={tab.type} />
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
);
}
export default function LoginPage() {
const { t } = useTextController();
return (
<main className='flex min-h-screen flex-col items-center justify-center'>
<div className='flex-1'>
<Container className='max-w-6xl'>
<div className='mb-12 flex flex-col items-center text-center'> <div className='mb-12 flex flex-col items-center text-center'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> <div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Fuel className='h-6 w-6 text-red-600' /> <Fuel className='h-6 w-6 text-red-600' />
@ -64,94 +147,25 @@ export default function LoginPage() {
<p className='max-w-2xl text-gray-600'>{t('auth.description')}</p> <p className='max-w-2xl text-gray-600'>{t('auth.description')}</p>
</div> </div>
<div className='mx-auto max-w-lg'> <div data-aos='zoom-in' className='mx-auto max-w-lg'>
<Tabs <Suspense>
defaultValue={defaultTab} <LoginPageTabs />
value={defaultTab} </Suspense>
onValueChange={handleTabChange}
className='w-full'
>
<TabsList className='mb-8 flex h-fit w-full flex-col sm:flex-row'>
{tabs.map((tab) => {
return (
<TabsTrigger
key={tab.label}
value={tab.type}
className='w-full text-base'
>
<tab.Icon className='mr-2 h-4 w-4' /> {t(tab.label)}
</TabsTrigger>
);
})}
</TabsList>
{tabs.map((tab) => {
const tabCookieName = `${tab.type}__token`;
const authenticationCookie = getCookie(tabCookieName);
if (authenticationCookie) {
return (
<TabsContent key={tab.label} value={tab.type}>
<Card>
<CardHeader>
<CardTitle>{t(tab.title)}</CardTitle>
</CardHeader>
<CardContent className='flex justify-center gap-2 space-y-4'>
<Link
href={
tab.type === 'bonus'
? '/customer-dashboard'
: '/corporate-dashboard'
}
>
<Button className='flex items-center'>
Открыть
</Button>
</Link>
<Button
variant='outline'
className='flex items-center gap-2'
onClick={() => {
deleteCookie(tabCookieName);
window.location.reload();
}}
>
Выйти
</Button>
</CardContent>
</Card>
</TabsContent>
);
}
return (
<TabsContent key={tab.label} value={tab.type}>
<Card>
<CardHeader>
<CardTitle>{t(tab.title)}</CardTitle>
<CardDescription>{t(tab.description)}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<LoginForm type={tab.type} />
</CardContent>
</Card>
</TabsContent>
);
})}
</Tabs>
<div className='mt-8 text-center text-sm text-gray-500'> <div className='mt-8 text-center text-sm text-gray-500'>
<p> <p>
{t('auth.loginIssues')}{' '} {t('auth.loginIssues')}{' '}
<Link href='/contact' className='text-red-600 hover:underline'> <Link
href={`mailto:${t('auth.loginForm.contactUs.mail')}`}
className='text-red-600 hover:underline'
>
{t('auth.contactLink')} {t('auth.contactLink')}
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
</div> </Container>
</main> </div>
</div> </main>
); );
} }

26
src/proxy.ts Normal file
View File

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
export function proxy(req: NextRequest) {
const url = req.nextUrl.clone();
const path = url.pathname;
if (
path.startsWith('/customer-dashboard') ||
path.startsWith('/corporate-dashboard')
) {
const token = req.cookies.get(
`${path.includes('customer') ? 'bonus' : 'corporate'}__token`,
);
if (!token) {
url.pathname = '/login';
return NextResponse.redirect(url);
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/customer-dashboard/:path*', '/corporate-dashboard/:path*'],
};

View File

@ -1,4 +1,5 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { has } from 'lodash';
import { toast } from 'sonner'; import { toast } from 'sonner';
const baseQuery = fetchBaseQuery({ const baseQuery = fetchBaseQuery({
@ -22,7 +23,15 @@ export const baseAPI = createApi({
if (result.error) { if (result.error) {
switch (result.error.status) { switch (result.error.status) {
case 401: case 401:
toast.error('Login credentials error'); if (
has(result.error.data, 'error') &&
result.error.data.error === 'Credentials error'
) {
toast.error('Login credentials error');
break;
}
window.location.href = '/login';
break; break;
case 500: case 500:

3
src/shared/api/tags.ts Normal file
View File

@ -0,0 +1,3 @@
export enum FetchTags {
TAYLOR = 'taylor',
}

View File

@ -0,0 +1,20 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
import { FetchTags } from './tags';
const baseQuery = fetchBaseQuery({
baseUrl: process.env.TAYLOR_API_ENDPOINT,
headers: {
Authorization: process.env.TAYLOR_API_TOKEN || '',
schema: 'readable',
},
next: {
tags: [FetchTags.TAYLOR],
},
});
export const taylorAPI = createApi({
reducerPath: 'taylorAPI',
baseQuery,
endpoints: () => ({}),
});

View File

@ -0,0 +1,13 @@
import { createQueryBuilder } from '@taylordb/query-builder';
import { TaylorDatabase } from '../types/database.types';
// Initialize TaylorDB query builder instance
// Note: If you have generated types from taylor.types.ts, you can import them here
// import { TaylorDatabase } from '@/path/to/taylor.types';
export const taylorQueryBuilder = createQueryBuilder<TaylorDatabase>({
baseId: process.env.TAYLOR_BASE_ID || '',
baseUrl: process.env.TAYLOR_API_ENDPOINT || '',
apiKey: process.env.TAYLOR_API_TOKEN || '',
});

View File

@ -1,11 +1,21 @@
'use client';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useMediaController } from '../media/hooks/use-media-controller';
export const Logo = () => { export const Logo = () => {
const { m } = useMediaController();
return ( return (
<Link className='flex items-center gap-2' href={'/'}> <Link className='flex items-center gap-2' href={'/'}>
<Image src='/logo.svg' alt='oriyo-logo' width={24} height={24} /> <Image
<span className='text-xl font-bold'>Ориё</span> src={m('logo') || '/logo-new.png'}
alt='oriyo-logo'
width={110}
height={40}
/>
</Link> </Link>
); );
}; };

View File

@ -1,74 +1,66 @@
'use client'; 'use client';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useIntersectionObserver } from '../hooks/use-intersection-observer';
import AnimatedCounter from './animated-counter'; import AnimatedCounter from './animated-counter';
const stats = [
{
value: 'about.stats.items.1.value',
suffix: 'about.stats.items.1.suffix',
label: 'about.stats.items.1.label',
},
{
value: 'about.stats.items.2.value',
suffix: 'about.stats.items.2.suffix',
label: 'about.stats.items.2.label',
},
{
value: 'about.stats.items.3.value',
suffix: 'about.stats.items.3.suffix',
label: 'about.stats.items.3.label',
decimals: 1,
},
];
export default function AboutCounter() { export default function AboutCounter() {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLDivElement>(null);
const { t } = useTextController(); const { t } = useTextController();
const { sectionRef, isVisible } = useIntersectionObserver<HTMLDivElement>();
useEffect(() => { const toNumber = (value: string) => {
const observer = new IntersectionObserver( return Number(t(value));
(entries) => { };
const [entry] = entries;
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{
threshold: 0.1,
},
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => {
observer.disconnect();
};
}, []);
return ( return (
<div ref={sectionRef} className='my-8 grid grid-cols-1 gap-6 text-center'> <div
<div className='transform rounded-lg bg-white p-3 shadow-md transition-transform hover:scale-105 sm:p-6'> ref={sectionRef}
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'> className='my-4 grid grid-cols-1 gap-6 text-center md:my-8'
<Users className='h-6 w-6 text-red-600' /> >
{stats.map((stat, index) => (
<div
key={index}
className='transform rounded-lg bg-white p-3 shadow-md transition-transform hover:scale-105 sm:p-6'
>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Users className='h-6 w-6 text-red-600' />
</div>
<h3 className='text-2xl font-bold text-gray-900'>
{isVisible ? (
<AnimatedCounter
end={toNumber(stat.value)}
suffix={t(stat.suffix)}
decimals={stat.decimals || 0}
/>
) : (
`0${t(stat.suffix)}`
)}
</h3>
<p className='text-gray-600'>{t(stat.label)}</p>
</div> </div>
<h3 className='text-2xl font-bold text-gray-900'> ))}
{isVisible ? <AnimatedCounter end={150} suffix='+' /> : '0+'}
</h3>
<p className='text-gray-600'>{t('about.stats.items.2.label')}</p>
</div>
<div className='transform rounded-lg bg-white p-3 shadow-md transition-transform hover:scale-105 sm:p-6'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Users className='h-6 w-6 text-red-600' />
</div>
<h3 className='text-2xl font-bold text-gray-900'>
{isVisible ? <AnimatedCounter end={5} suffix='M+' /> : '0M+'}
</h3>
<p className='text-gray-600'>{t('about.stats.items.4.label')}</p>
</div>
<div className='transform rounded-lg bg-white p-3 shadow-md transition-transform hover:scale-105 sm:p-6'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Users className='h-6 w-6 text-red-600' />
</div>
<h3 className='text-2xl font-bold text-gray-900'>
{isVisible ? (
<AnimatedCounter end={98} suffix='%' decimals={1} />
) : (
'0%'
)}
</h3>
<p className='text-gray-600'>{t('about.stats.items.5.label')}</p>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,91 @@
'use client';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
export const AppStoreButtons = ({ className = '' }: { className?: string }) => {
const { t } = useTextController();
const playStoreLink = t('play.google.com');
const appStoreLink = t('app.store');
return (
<div className={`flex flex-wrap gap-4 ${className}`}>
{/* Google Play Button */}
<a
href={playStoreLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-3 rounded-xl bg-black px-5 py-2.5 text-white transition-all hover:scale-105 hover:bg-gray-900 shadow-lg'
>
<svg
viewBox='0 0 32 32'
className='w-7 h-7'
xmlns='http://www.w3.org/2000/svg'
fill='none'
>
<mask id='mask0_87_8320' maskUnits='userSpaceOnUse' x='7' y='3' width='24' height='26' style={{ maskType: 'alpha' }}>
<path d='M30.0484 14.4004C31.3172 15.0986 31.3172 16.9014 30.0484 17.5996L9.75627 28.7659C8.52052 29.4459 7 28.5634 7 27.1663L7 4.83374C7 3.43657 8.52052 2.55415 9.75627 3.23415L30.0484 14.4004Z' fill='#C4C4C4'/>
</mask>
<g mask='url(#mask0_87_8320)'>
<path d='M7.63473 28.5466L20.2923 15.8179L7.84319 3.29883C7.34653 3.61721 7 4.1669 7 4.8339V27.1664C7 27.7355 7.25223 28.2191 7.63473 28.5466Z' fill='url(#paint0_linear_87_8320)'/>
<path d='M30.048 14.4003C31.3169 15.0985 31.3169 16.9012 30.048 17.5994L24.9287 20.4165L20.292 15.8175L24.6923 11.4531L30.048 14.4003Z' fill='url(#paint1_linear_87_8320)'/>
<path d='M24.9292 20.4168L20.2924 15.8179L7.63477 28.5466C8.19139 29.0232 9.02389 29.1691 9.75635 28.766L24.9292 20.4168Z' fill='url(#paint2_linear_87_8320)'/>
<path d='M7.84277 3.29865L20.2919 15.8177L24.6922 11.4533L9.75583 3.23415C9.11003 2.87878 8.38646 2.95013 7.84277 3.29865Z' fill='url(#paint3_linear_87_8320)'/>
</g>
<defs>
<linearGradient id='paint0_linear_87_8320' x1='15.6769' y1='10.874' x2='7.07106' y2='19.5506' gradientUnits='userSpaceOnUse'>
<stop stopColor='#00C3FF'/>
<stop offset='1' stopColor='#1BE2FA'/>
</linearGradient>
<linearGradient id='paint1_linear_87_8320' x1='20.292' y1='15.8176' x2='31.7381' y2='15.8176' gradientUnits='userSpaceOnUse'>
<stop stopColor='#FFCE00'/>
<stop offset='1' stopColor='#FFEA00'/>
</linearGradient>
<linearGradient id='paint2_linear_87_8320' x1='7.36932' y1='30.1004' x2='22.595' y2='17.8937' gradientUnits='userSpaceOnUse'>
<stop stopColor='#DE2453'/>
<stop offset='1' stopColor='#FE3944'/>
</linearGradient>
<linearGradient id='paint3_linear_87_8320' x1='8.10725' y1='1.90137' x2='22.5971' y2='13.7365' gradientUnits='userSpaceOnUse'>
<stop stopColor='#11D574'/>
<stop offset='1' stopColor='#01F176'/>
</linearGradient>
</defs>
</svg>
<div className='flex flex-col items-start leading-none'>
<span className='text-[10px] uppercase font-medium opacity-80 mb-0.5'>Get it on</span>
<span className='text-base font-bold'>Google Play</span>
</div>
</a>
{/* App Store Button */}
<a
href={appStoreLink}
target='_blank'
rel='noopener noreferrer'
className='flex items-center gap-3 rounded-xl bg-black px-5 py-2.5 text-white transition-all hover:scale-105 hover:bg-gray-900 shadow-lg'
>
<svg
viewBox='0 0 32 32'
className='w-7 h-7'
xmlns='http://www.w3.org/2000/svg'
fill='none'
>
<circle cx='16' cy='16' r='14' fill='url(#paint0_linear_87_8317)'/>
<path d='M18.4468 8.65403C18.7494 8.12586 18.5685 7.45126 18.0428 7.14727C17.5171 6.84328 16.8456 7.02502 16.543 7.55318L16.0153 8.47442L15.4875 7.55318C15.1849 7.02502 14.5134 6.84328 13.9877 7.14727C13.462 7.45126 13.2811 8.12586 13.5837 8.65403L14.748 10.6864L11.0652 17.1149H8.09831C7.49173 17.1149 7 17.6089 7 18.2183C7 18.8277 7.49173 19.3217 8.09831 19.3217H18.4324C18.523 19.0825 18.6184 18.6721 18.5169 18.2949C18.3644 17.7279 17.8 17.1149 16.8542 17.1149H13.5997L18.4468 8.65403Z' fill='white'/>
<path d='M11.6364 20.5419C11.449 20.3328 11.0292 19.9987 10.661 19.8888C10.0997 19.7211 9.67413 19.8263 9.45942 19.9179L8.64132 21.346C8.33874 21.8741 8.51963 22.5487 9.04535 22.8527C9.57107 23.1567 10.2425 22.975 10.5451 22.4468L11.6364 20.5419Z' fill='white'/>
<path d='M22.2295 19.3217H23.9017C24.5083 19.3217 25 18.8277 25 18.2183C25 17.6089 24.5083 17.1149 23.9017 17.1149H20.9653L17.6575 11.3411C17.4118 11.5757 16.9407 12.175 16.8695 12.8545C16.778 13.728 16.9152 14.4636 17.3271 15.1839C18.7118 17.6056 20.0987 20.0262 21.4854 22.4468C21.788 22.975 22.4594 23.1567 22.9852 22.8527C23.5109 22.5487 23.6918 21.8741 23.3892 21.346L22.2295 19.3217Z' fill='white'/>
<defs>
<linearGradient id='paint0_linear_87_8317' x1='16' y1='2' x2='16' y2='30' gradientUnits='userSpaceOnUse'>
<stop stopColor='#2AC9FA'/>
<stop offset='1' stopColor='#1F65EB'/>
</linearGradient>
</defs>
</svg>
<div className='flex flex-col items-start leading-none'>
<span className='text-[10px] uppercase font-medium opacity-80 mb-0.5'>Download on the</span>
<span className='text-base font-bold'>App Store</span>
</div>
</a>
</div>
);
};

View File

@ -0,0 +1,18 @@
'use client';
import { ComponentProps } from 'react';
import { cn } from '../lib/utils';
interface ContainerProps extends ComponentProps<'div'> {}
export function Container({ children, className, ...props }: ContainerProps) {
return (
<div
className={cn('container mx-auto px-2.5 py-8 sm:py-16', className)}
{...props}
>
{children}
</div>
);
}

View File

@ -0,0 +1,9 @@
'use client'
export default function Loader() {
return (
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-14 w-14 border-4 border-black border-t-transparent"></div>
</div>
)
}

View File

@ -5,10 +5,14 @@ import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Discounts } from '@/app/api-utlities/@types/index';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { Card, CardContent } from '@/shared/shadcn-ui/card'; import { Card, CardContent } from '@/shared/shadcn-ui/card';
import PromoImg from '../../../public/main/#promotions/995b9daa-959f-4bd5-9135-ef7c47148f2c.jpg';
const promotions = [ const promotions = [
{ {
id: 1, id: 1,
@ -41,7 +45,11 @@ const promotions = [
}, },
]; ];
export default function PromotionSlider() { interface PromotionSliderProps {
discounts: Discounts;
}
export default function PromotionSlider({ discounts }: PromotionSliderProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [visibleItems, setVisibleItems] = useState(3); const [visibleItems, setVisibleItems] = useState(3);
@ -85,7 +93,7 @@ export default function PromotionSlider() {
transform: `translateX(-${currentIndex * (100 / visibleItems)}%)`, transform: `translateX(-${currentIndex * (100 / visibleItems)}%)`,
}} }}
> >
{promotions.map((promo) => ( {discounts.map((promo) => (
<div <div
key={promo.id} key={promo.id}
className='w-full flex-none p-2 sm:w-1/2 lg:w-1/3' className='w-full flex-none p-2 sm:w-1/2 lg:w-1/3'
@ -93,24 +101,28 @@ export default function PromotionSlider() {
data-aos-duration='700' data-aos-duration='700'
> >
<Card className='h-full overflow-hidden transition-shadow hover:shadow-lg'> <Card className='h-full overflow-hidden transition-shadow hover:shadow-lg'>
<div className='relative h-48'> <div className='relative h-72'>
<Image <div className='rounded-lg'>
src={promo.image || '/placeholder.svg'} <Image
alt={promo.title} src={PromoImg}
fill alt={promo.name}
className='object-cover' fill
/> className='rounded-lg object-contain p-2'
/>
</div>
</div> </div>
<CardContent className='p-4'> <CardContent className='p-4'>
<h3 className='mb-2 text-lg font-bold'>{promo.title}</h3> <h3 className='mb-2 text-lg font-bold'>{promo.name}</h3>
<p className='mb-3 text-sm text-gray-600'> <p className='mb-3 text-sm text-gray-600'>
{promo.description} {promo.description}
</p> </p>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<span className='text-xs text-gray-500'> <span className='text-xs text-gray-500'>
Действует до: {promo.validUntil} {promo.expiresAt
? `Действует до: ${promo.expiresAt}`
: null}
</span> </span>
<Link href='#'> {/* <Link href='#'>
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
@ -118,31 +130,35 @@ export default function PromotionSlider() {
> >
{t('common.buttons.readMore')} {t('common.buttons.readMore')}
</Button> </Button>
</Link> </Link> */}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
))} ))}
</div> </div>
<Button {discounts.length > 3 && (
variant='outline' <>
size='icon' <Button
className='absolute top-1/2 left-0 z-10 -translate-y-1/2 border-gray-200 bg-white shadow-lg' variant='outline'
onClick={prevSlide} size='icon'
> className='absolute top-1/2 left-0 z-10 -translate-y-1/2 border-gray-200 bg-white shadow-lg'
<ChevronLeft className='h-4 w-4' /> onClick={prevSlide}
<span className='sr-only'>Предыдущий</span> >
</Button> <ChevronLeft className='h-4 w-4' />
<Button <span className='sr-only'>Предыдущий</span>
variant='outline' </Button>
size='icon' <Button
className='absolute top-1/2 right-0 z-10 -translate-y-1/2 border-gray-200 bg-white shadow-lg' variant='outline'
onClick={nextSlide} size='icon'
> className='absolute top-1/2 right-0 z-10 -translate-y-1/2 border-gray-200 bg-white shadow-lg'
<ChevronRight className='h-4 w-4' /> onClick={nextSlide}
<span className='sr-only'>Следующий</span> >
</Button> <ChevronRight className='h-4 w-4' />
<span className='sr-only'>Следующий</span>
</Button>
</>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,16 @@
import { Star } from 'lucide-react';
export const Rating = ({ rating }: { rating: number }) => {
return (
<>
{Array(5)
.fill(0)
.map((_, i) => (
<Star
key={i}
className={`h-5 w-5 ${i < Number(rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`}
/>
))}
</>
);
};

View File

@ -0,0 +1,24 @@
'use client';
import { Reviews } from '@/app/api-utlities/@types';
import { Rating } from '@/shared/components/rating';
import { Card, CardContent } from '@/shared/shadcn-ui/card';
type ReviewProps = {
review: Reviews[number];
};
export const Review = ({ review }: ReviewProps) => {
return (
<Card className='overflow-hidden transition-all hover:shadow-lg'>
<CardContent className='p-6'>
<div className='mb-4 flex'>
<Rating rating={review.rating} />
</div>
<p className='mb-4 text-gray-600 italic'>"{review.review}"</p>
<p className='font-semibold'>{review.fullname}</p>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,20 @@
import { Loader2 } from 'lucide-react';
import { cn } from '../lib/utils';
interface SpinnerProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
}
export function Spinner({ className, size = 'md' }: SpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
};
return (
<Loader2 className={cn(`animate-spin ${sizeClasses[size]}`, className)} />
);
}

View File

@ -0,0 +1,28 @@
import { cn } from '../lib/utils';
import { Spinner } from './spinner';
interface TableLoadingOverlayProps {
isLoading: boolean;
message?: string;
className?: string;
}
export default function TableLoadingOverlay({
isLoading,
message = 'Загрузка данных...',
className,
}: TableLoadingOverlayProps) {
if (!isLoading) return null;
return (
<div
className={cn(
'absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 backdrop-blur-[1px] transition-opacity duration-200',
className,
)}
>
<Spinner size='lg' className='mb-2 text-red-600' />
<p className='font-medium text-gray-700'>{message}</p>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';
export const useIntersectionObserver = <T extends HTMLElement>() => {
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<T>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{
threshold: 0.1,
},
);
if (sectionRef.current) {
observer.observe(sectionRef.current);
}
return () => {
observer.disconnect();
};
}, []);
return { sectionRef, isVisible };
};

View File

@ -1,12 +0,0 @@
import { baseAPI } from '@/shared/api/base-api';
import { TextItem } from '@/shared/types/text.types';
export const textControlApi = baseAPI.injectEndpoints({
endpoints: (builder) => ({
fetchText: builder.query<TextItem[], void>({
query: () => '/text',
}),
}),
});
export const { useFetchTextQuery } = textControlApi;

View File

@ -20,9 +20,9 @@ export function TextControlProvider({
textItems, textItems,
}: { }: {
children: ReactNode; children: ReactNode;
textItems: TextItem[]; textItems?: TextItem[];
}) { }) {
const textMap = textItems.reduce( const textMap = textItems?.reduce(
(pr, cr) => { (pr, cr) => {
pr[cr.key] = cr.value; pr[cr.key] = cr.value;
@ -33,7 +33,7 @@ export function TextControlProvider({
// Translation function for flat structure // Translation function for flat structure
const t = (key: string): string => { const t = (key: string): string => {
if (textMap[key]) { if (textMap?.[key]) {
return textMap[key]; return textMap[key];
} }

View File

@ -7,7 +7,9 @@ import { TextControlContext } from '../context/text-control-provider';
export function useTextController() { export function useTextController() {
const context = useContext(TextControlContext); const context = useContext(TextControlContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider'); throw new Error(
'useTextController must be used within a TextControlProvider',
);
} }
if (typeof context.t !== 'function') { if (typeof context.t !== 'function') {

View File

@ -0,0 +1,9 @@
import { formatInTimeZone } from 'date-fns-tz';
export const formatDate = (
date: Date | string,
formatStr: string = 'dd.MM.yyyy HH:mm',
) => {
const utcDate = new Date(date);
return formatInTimeZone(utcDate, 'UTC', formatStr);
};

View File

@ -0,0 +1,24 @@
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { presentMedia } from '@/app/api-utlities/presenters';
import { mediaRequest } from '@/app/api-utlities/requests/common';
import { taylorAPI } from '@/shared/api/taylor-api';
import { MediaItem } from '@/shared/types/media.type';
export const mediaControlApi = taylorAPI.injectEndpoints({
endpoints: (builder) => ({
fetchMedia: builder.query<MediaItem[], void>({
query: () => ({
url: '',
method: 'POST',
body: {
query: jsonToGraphQLQuery({ query: mediaRequest }),
},
}),
transformResponse: (response: any) => {
return presentMedia(response.data.mediaKontentSajta);
},
}),
}),
});

View File

@ -0,0 +1,42 @@
'use client';
import { createContext, type ReactNode } from 'react';
import { MediaItem } from '@/shared/types/media.type';
export type MediaMap = Record<string, MediaItem>;
type MediaControlContextType = {
m: (key: string) => string | null;
};
export const MediaControlContext = createContext<
MediaControlContextType | undefined
>(undefined);
export function MediaControlProvider({
children,
mediaItems,
}: {
children: ReactNode;
mediaItems?: MediaItem[];
}) {
const mediaMap = mediaItems?.reduce((pr, cr) => {
pr[cr.key] = cr;
return pr;
}, {} as MediaMap);
const getMedia = (key: string): string | null => {
if (mediaMap?.[key]) {
return mediaMap[key].photo;
}
console.warn(`Media key not found: ${key}`);
return null;
};
return (
<MediaControlContext.Provider value={{ m: getMedia }}>
{children}
</MediaControlContext.Provider>
);
}

View File

@ -0,0 +1,20 @@
'use client';
import { useContext } from 'react';
import { MediaControlContext } from '../context/media-control.provider';
export function useMediaController() {
const context = useContext(MediaControlContext);
if (context === undefined) {
throw new Error(
'useMediaController must be used within a MediaControlProvider',
);
}
if (typeof context.m !== 'function') {
throw new Error('Media function (m) is not available');
}
return context;
}

View File

@ -10,7 +10,20 @@ interface AosInitProps {
export const AosProvider = ({ children }: AosInitProps) => { export const AosProvider = ({ children }: AosInitProps) => {
useEffect(() => { useEffect(() => {
AOS.init({ once: false, mirror: false, }); const timer = setTimeout(() => {
AOS.init({
mirror: false,
once: false,
offset: 100,
duration: 600,
easing: 'ease-out-quart',
});
}, 500);
return () => {
clearTimeout(timer);
AOS.refreshHard();
};
}, []); }, []);
return <>{children}</>; return <>{children}</>;

View File

@ -1,10 +1,13 @@
'use client'; 'use client';
import { TooltipProvider } from '@radix-ui/react-tooltip';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { TextControlProvider } from '../language'; import { TextControlProvider } from '../language';
import { MediaControlProvider } from '../media/context/media-control.provider';
import { store } from '../store'; import { store } from '../store';
import { ThemeProvider } from '../theme/theme-provider'; import { ThemeProvider } from '../theme/theme-provider';
import { MediaItem } from '../types/media.type';
import { TextItem } from '../types/text.types'; import { TextItem } from '../types/text.types';
import { AosProvider } from './aos-provider'; import { AosProvider } from './aos-provider';
import { Toaster } from './toaster'; import { Toaster } from './toaster';
@ -12,23 +15,32 @@ import { Toaster } from './toaster';
type ProvidersProps = { type ProvidersProps = {
children: React.ReactNode; children: React.ReactNode;
textItems: TextItem[]; textItems: TextItem[];
mediaItems: MediaItem[];
}; };
export const Providers = ({ children, textItems }: ProvidersProps) => { export const Providers = ({
children,
textItems,
mediaItems,
}: ProvidersProps) => {
return ( return (
<Provider store={store}> <Provider store={store}>
<TextControlProvider textItems={textItems}> <TextControlProvider textItems={textItems}>
<ThemeProvider <MediaControlProvider mediaItems={mediaItems}>
attribute='class' <ThemeProvider
defaultTheme='light' attribute='class'
enableSystem defaultTheme='light'
disableTransitionOnChange enableSystem
> disableTransitionOnChange
<AosProvider> >
{children} <TooltipProvider>
<Toaster /> <AosProvider>
</AosProvider> {children}
</ThemeProvider> <Toaster />
</AosProvider>
</TooltipProvider>
</ThemeProvider>
</MediaControlProvider>
</TextControlProvider> </TextControlProvider>
</Provider> </Provider>
); );

View File

@ -0,0 +1,31 @@
'use client';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@ -0,0 +1,32 @@
'use client';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -4,13 +4,16 @@ import { createWrapper } from 'next-redux-wrapper';
import { baseAPI } from '@/shared/api/base-api'; import { baseAPI } from '@/shared/api/base-api';
import { taylorAPI } from '../api/taylor-api';
import { rootReducer } from './root-reducer'; import { rootReducer } from './root-reducer';
export const makeStore = () => export const makeStore = () =>
configureStore({ configureStore({
reducer: rootReducer, reducer: rootReducer,
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseAPI.middleware), getDefaultMiddleware()
.concat(baseAPI.middleware)
.concat(taylorAPI.middleware),
devTools: process.env.NODE_ENV === 'development', devTools: process.env.NODE_ENV === 'development',
}); });

View File

@ -2,6 +2,9 @@ import { combineReducers } from '@reduxjs/toolkit';
import { baseAPI } from '@/shared/api/base-api'; import { baseAPI } from '@/shared/api/base-api';
import { taylorAPI } from '../api/taylor-api';
export const rootReducer = combineReducers({ export const rootReducer = combineReducers({
[baseAPI.reducerPath]: baseAPI.reducer, [baseAPI.reducerPath]: baseAPI.reducer,
[taylorAPI.reducerPath]: taylorAPI.reducer,
}); });

View File

@ -0,0 +1,548 @@
/**
* Copyright (c) 2025 TaylorDB
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
interface FileInformation {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
destination: string;
filename: string;
path: string;
size: number;
format: string;
width: number;
height: number;
}
interface UploadResponse {
collectionName: string;
fileInformation: FileInformation;
metadata: {
thumbnails: any[];
clips: any[];
};
baseId: string;
storageAdaptor: string;
_id: string;
__v: number;
}
export interface AttachmentColumnValue {
url: string;
fileType: string;
size: number;
}
export class Attachment {
public readonly collectionName: string;
public readonly fileInformation: FileInformation;
public readonly metadata: { thumbnails: any[]; clips: any[] };
public readonly baseId: string;
public readonly storageAdaptor: string;
public readonly _id: string;
constructor(data: UploadResponse) {
this.collectionName = data.collectionName;
this.fileInformation = data.fileInformation;
this.metadata = data.metadata;
this.baseId = data.baseId;
this.storageAdaptor = data.storageAdaptor;
this._id = data._id;
}
toColumnValue(): AttachmentColumnValue {
return {
url: this.fileInformation.path,
fileType: this.fileInformation.mimetype,
size: this.fileInformation.size,
};
}
}
type IsWithinOperatorValue =
| 'pastWeek'
| 'pastMonth'
| 'pastYear'
| 'nextWeek'
| 'nextMonth'
| 'nextYear'
| 'daysFromNow'
| 'daysAgo'
| 'currentWeek'
| 'currentMonth'
| 'currentYear';
type DefaultDateFilterValue =
| (
| 'today'
| 'tomorrow'
| 'yesterday'
| 'oneWeekAgo'
| 'oneWeekFromNow'
| 'oneMonthAgo'
| 'oneMonthFromNow'
)
| ['exactDay' | 'exactTimestamp', string]
| ['daysAgo' | 'daysFromNow', number];
type DateFilters = {
'=': DefaultDateFilterValue;
'!=': DefaultDateFilterValue;
'<': DefaultDateFilterValue;
'>': DefaultDateFilterValue;
'<=': DefaultDateFilterValue;
'>=': DefaultDateFilterValue;
isWithIn:
| IsWithinOperatorValue
| { value: 'daysAgo' | 'daysFromNow'; date: number };
isEmpty: boolean;
isNotEmpty: boolean;
};
type DateAggregations = {
empty: number;
filled: number;
unique: number;
percentEmpty: number;
percentFilled: number;
percentUnique: number;
min: number | null;
max: number | null;
daysRange: number | null;
monthRange: number | null;
};
type TextFilters = {
'=': string;
'!=': string;
caseEqual: string;
hasAnyOf: string[];
contains: string;
startsWith: string;
endsWith: string;
doesNotContain: string;
isEmpty: never;
isNotEmpty: never;
};
type LinkFilters = {
hasAnyOf: number[];
hasAllOf: number[];
isExactly: number[];
'=': number;
hasNoneOf: number[];
contains: string;
doesNotContain: string;
isEmpty: never;
isNotEmpty: never;
};
type SelectFilters<O extends readonly string[]> = {
hasAnyOf: O[number][];
hasAllOf: O[number][];
isExactly: O[number][];
'=': O[number];
hasNoneOf: O[number][];
contains: string;
doesNotContain: string;
isEmpty: never;
isNotEmpty: never;
};
type LinkAggregations = {
empty: number;
filled: number;
percentEmpty: number;
percentFilled: number;
};
type NumberFilters = {
'=': number;
'!=': number;
'>': number;
'>=': number;
'<': number;
'<=': number;
hasAnyOf: number[];
hasNoneOf: number[];
isEmpty: never;
isNotEmpty: never;
};
type NumberAggregations = {
sum: number;
average: number;
median: number;
min: number | null;
max: number | null;
range: number;
standardDeviation: number;
histogram: Record<string, number>;
empty: number;
filled: number;
unique: number;
percentEmpty: number;
percentFilled: number;
percentUnique: number;
};
type CheckboxFilters = {
'=': number;
};
/**
*
* Column types
*
*/
export type ColumnType<
S,
U,
I,
R extends boolean,
F extends { [key: string]: any } = object,
A extends { [key: string]: any } = object,
> = {
raw: S;
insert: I;
update: U;
filters: F;
aggregations: A;
isRequired: R;
};
export type DateColumnType<R extends boolean> = ColumnType<
string,
string,
string,
R,
DateFilters,
DateAggregations
>;
export type TextColumnType<R extends boolean> = ColumnType<
string,
string,
string,
R,
TextFilters
>;
export type ALinkColumnType<
T extends string,
S,
U,
I,
R extends boolean,
F extends { [key: string]: any } = LinkFilters,
A extends LinkAggregations = LinkAggregations,
> = ColumnType<S, U, I, R, F, A> & {
linkedTo: T;
};
export type LinkColumnType<
T extends string,
R extends boolean,
> = ALinkColumnType<
T,
object,
number | number[] | { newIds: number[]; deletedIds: number[] },
number | number[],
R
>;
export type AttachmentColumnType<R extends boolean> = ALinkColumnType<
'attachmentTable',
AttachmentColumnValue[],
Attachment[] | { newIds: number[]; deletedIds: number[] } | number[],
Attachment[] | number[],
R
>;
export type NumberColumnType<R extends boolean> = ColumnType<
number,
number,
number,
R,
NumberFilters,
NumberAggregations
>;
export type CheckboxColumnType<R extends boolean> = ColumnType<
boolean,
boolean,
boolean,
R,
CheckboxFilters
>;
export type AutoGeneratedNumberColumnType = ColumnType<
number,
never,
never,
false,
NumberFilters,
NumberAggregations
>;
export type AutoGeneratedDateColumnType = ColumnType<
string,
never,
never,
false,
DateFilters,
DateAggregations
>;
export type SingleSelectColumnType<
O extends readonly string[],
R extends boolean,
> = ALinkColumnType<
'selectTable',
O[number],
O[number] | O[number][],
O[number] | O[number][],
R,
SelectFilters<O>
>;
export type TableRaws<T extends keyof TaylorDatabase> = {
[K in keyof TaylorDatabase[T]]: TaylorDatabase[T][K] extends ColumnType<
infer S,
any,
any,
infer R,
any,
any
>
? R extends true
? S
: S | undefined
: never;
};
export type TableInserts<T extends keyof TaylorDatabase> = {
[K in keyof TaylorDatabase[T]]: TaylorDatabase[T][K] extends ColumnType<
any,
infer I,
any,
infer R,
any,
any
>
? R extends true
? I
: I | undefined
: never;
};
export type TableUpdates<T extends keyof TaylorDatabase> = {
[K in keyof TaylorDatabase[T]]: TaylorDatabase[T][K] extends ColumnType<
any,
any,
infer U,
any,
any,
any
>
? U
: never;
};
export type SelectTable = {
id: AutoGeneratedNumberColumnType;
name: TextColumnType<true>;
color: TextColumnType<true>;
};
export type AttachmentTable = {
id: AutoGeneratedNumberColumnType;
name: TextColumnType<true>;
metadata: TextColumnType<true>;
size: NumberColumnType<true>;
fileType: TextColumnType<true>;
url: TextColumnType<true>;
};
export type CollaboratorsTable = {
id: AutoGeneratedNumberColumnType;
name: TextColumnType<true>;
emailAddress: TextColumnType<true>;
avatar: TextColumnType<true>;
};
export type TaylorDatabase = {
/**
*
*
* Internal tables, these tables can not be queried directly.
*
*/
selectTable: SelectTable;
attachmentTable: AttachmentTable;
collaboratorsTable: CollaboratorsTable;
vakansii: VakansiiTable;
partnyory: PartnyoryTable;
azs: AzsTable;
akcii: AkciiTable;
istoriyaKompanii: IstoriyaKompaniiTable;
komanda: KomandaTable;
otzyvy: OtzyvyTable;
tekstovyjKontentSajta: TekstovyjKontentSajtaTable;
sertifikaty: SertifikatyTable;
mediaKontentSajta: MediaKontentSajtaTable;
blagotvoritelnyjFond: BlagotvoritelnyjFondTable;
};
export const VakansiiTipOptions = ['Офис', 'Заправки'] as const;
export const VakansiiLokaciyaOptions = ['Душанбе'] as const;
type VakansiiTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
zagolovok: TextColumnType<false>;
tip: SingleSelectColumnType<typeof VakansiiTipOptions, false>;
lokaciya: SingleSelectColumnType<typeof VakansiiLokaciyaOptions, false>;
tegi: LinkColumnType<'selectTable', false>;
};
type PartnyoryTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
nazvanie: TextColumnType<false>;
izobrozhenie: AttachmentColumnType<false>;
};
export const AzsChasyRabotyOptions = ['Круглосуточно'] as const;
export const AzsRegionOptions = [
'Душанбе',
'Бохтар',
'Худжанд',
'Регар',
'Вахдат',
'А.Джоми',
'Обикиик',
'Кулоб',
'Дахана',
'Ёвон',
'Панч',
'Исфара',
'Мастчох',
'Хисор',
] as const;
type AzsTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
imya: TextColumnType<false>;
adress: TextColumnType<false>;
opisanie: TextColumnType<false>;
chasyRaboty: SingleSelectColumnType<typeof AzsChasyRabotyOptions, false>;
lat: TextColumnType<false>;
long: TextColumnType<false>;
avtomojka: CheckboxColumnType<false>;
dt: CheckboxColumnType<false>;
ai92: CheckboxColumnType<false>;
ai95: CheckboxColumnType<false>;
z100: CheckboxColumnType<false>;
propan: CheckboxColumnType<false>;
zaryadnayaStanciya: CheckboxColumnType<false>;
miniMarket: CheckboxColumnType<false>;
tualet: CheckboxColumnType<false>;
region: SingleSelectColumnType<typeof AzsRegionOptions, false>;
foto: AttachmentColumnType<false>;
};
type AkciiTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
zagolovok: TextColumnType<false>;
opisanie: TextColumnType<false>;
do: DateColumnType<false>;
foto: AttachmentColumnType<false>;
};
type IstoriyaKompaniiTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
zagolovok: TextColumnType<false>;
god: NumberColumnType<false>;
opisanie: TextColumnType<false>;
};
type KomandaTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
polnoeImya: TextColumnType<false>;
foto: AttachmentColumnType<false>;
zvanie: TextColumnType<false>;
};
export const OtzyvyStatusOptions = ['Опубликовано', 'Не публиковать'] as const;
type OtzyvyTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
polnoeImya: TextColumnType<false>;
otzyv: TextColumnType<false>;
rejting: NumberColumnType<false>;
status: SingleSelectColumnType<typeof OtzyvyStatusOptions, false>;
};
type TekstovyjKontentSajtaTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
klyuchNeIzmenyat: TextColumnType<true>;
znachenie: TextColumnType<true>;
opisanie: LinkColumnType<'selectTable', false>;
};
type SertifikatyTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
nazvanie: TextColumnType<false>;
opisanie: TextColumnType<false>;
dataVydachi: DateColumnType<false>;
dejstvitelenDo: DateColumnType<false>;
foto: AttachmentColumnType<false>;
};
export const MediaKontentSajtaStranicaOptions = [
'Главная',
'О нас',
'Благотворительность',
'Общая',
'Клиенты',
'Программа лояльности',
] as const;
type MediaKontentSajtaTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
mestopolozheniya: TextColumnType<false>;
klyuchNeIzmenyat: TextColumnType<true>;
foto: AttachmentColumnType<false>;
stranica: SingleSelectColumnType<
typeof MediaKontentSajtaStranicaOptions,
false
>;
};
type BlagotvoritelnyjFondTable = {
id: NumberColumnType<false>;
createdAt: AutoGeneratedDateColumnType;
updatedAt: AutoGeneratedDateColumnType;
zagolovok: TextColumnType<false>;
opisanie: TextColumnType<false>;
data: DateColumnType<false>;
lokaciya: TextColumnType<false>;
foto: AttachmentColumnType<false>;
};

View File

@ -0,0 +1,5 @@
export interface MediaItem {
key: string; // _klyuchNeIzmenya
name: string; // _name
photo: string | null;
}

View File

@ -3,64 +3,19 @@
import { Calendar, ChevronDown, ChevronUp } from 'lucide-react'; import { Calendar, ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { HistoryItems } from '@/app/api-utlities/@types';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { Card, CardContent } from '@/shared/shadcn-ui/card'; import { Card, CardContent } from '@/shared/shadcn-ui/card';
const timelineEvents = [ export interface CompanyTimelineProps {
{ timeline: HistoryItems;
year: '2008', }
title: 'Основание компании',
description:
'GasNetwork была основана с открытием первых трех заправочных станций в Душанбе. С самого начала компания поставила перед собой цель предоставлять качественное топливо и отличный сервис.',
},
{
year: '2010',
title: 'Расширение сети',
description:
'Открытие еще пяти заправочных станций в различных регионах Таджикистана. Начало формирования единого стандарта обслуживания на всех станциях сети.',
},
{
year: '2012',
title: 'Внедрение программы лояльности',
description:
'Запуск первой в Таджикистане программы лояльности для клиентов сети заправок. Введение карт постоянного клиента с накопительной системой бонусов.',
},
{
year: '2014',
title: 'Модернизация оборудования',
description:
'Масштабная программа по обновлению оборудования на всех заправочных станциях сети. Внедрение современных технологий для повышения качества обслуживания.',
},
{
year: '2016',
title: 'Открытие 15-й заправки',
description:
'Значительное расширение сети с открытием юбилейной 15-й заправочной станции. GasNetwork становится одной из крупнейших сетей заправок в Таджикистане.',
},
{
year: '2018',
title: 'Запуск мобильного приложения',
description:
'Разработка и запуск мобильного приложения для клиентов сети. Возможность отслеживать бонусы, находить ближайшие заправки и получать специальные предложения.',
},
{
year: '2020',
title: 'Создание благотворительного фонда',
description:
'Основание благотворительного фонда GasNetwork для поддержки социальных проектов в Таджикистане. Начало активной социальной деятельности компании.',
},
{
year: '2023',
title: 'Современное развитие',
description:
'Сегодня GasNetwork - это 25+ современных заправочных станций по всему Таджикистану, более 150 сотрудников и тысячи довольных клиентов ежедневно.',
},
];
export function CompanyTimeline() { export function CompanyTimeline({ timeline }: CompanyTimelineProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const displayEvents = expanded ? timelineEvents : timelineEvents.slice(0, 4); const displayEvents = expanded ? timeline : timeline.slice(0, 4);
const { t } = useTextController(); const { t } = useTextController();
@ -80,7 +35,7 @@ export function CompanyTimeline() {
<Calendar className='size-5 text-red-600' /> <Calendar className='size-5 text-red-600' />
</div> </div>
<h3 className='text-xl font-bold'>{event.year}</h3> <h3 className='text-xl font-bold'>{event.year}</h3>
<h4 className='mb-2 text-lg font-semibold'>{event.title}</h4> <h4 className='mb-2 text-lg font-semibold'>{event.name}</h4>
</div> </div>
<div <div
@ -108,7 +63,7 @@ export function CompanyTimeline() {
))} ))}
</div> </div>
{timelineEvents.length > 4 && ( {timeline.length > 4 && (
<div className='mt-8 text-center'> <div className='mt-8 text-center'>
<Button <Button
variant='outline' variant='outline'

View File

@ -4,6 +4,8 @@ import { ChevronLeft, ChevronRight, Maximize } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import { useState } from 'react';
import { Stations } from '@/app/api-utlities/@types';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
import { import {
@ -15,55 +17,15 @@ import {
DialogTrigger, DialogTrigger,
} from '@/shared/shadcn-ui/dialog'; } from '@/shared/shadcn-ui/dialog';
interface Station { export interface StationGalleryProps {
id: number; stations: Stations;
name: string;
image: string;
location: string;
} }
const stations: Array<Station> = [ export function StationGallery({ stations }: StationGalleryProps) {
{
id: 1,
name: 'АЗС Душанбе-Центр',
image: '/placeholder.svg?height=400&width=600&text=АЗС+Душанбе-Центр',
location: 'ул. Рудаки 150, Душанбе',
},
{
id: 2,
name: 'АЗС Худжанд',
image: '/placeholder.svg?height=400&width=600&text=АЗС+Худжанд',
location: 'ул. Ленина 45, Худжанд',
},
{
id: 3,
name: 'АЗС Куляб',
image: '/placeholder.svg?height=400&width=600&text=АЗС+Куляб',
location: 'ул. Сомони 78, Куляб',
},
{
id: 4,
name: 'АЗС Бохтар',
image: '/placeholder.svg?height=400&width=600&text=АЗС+Бохтар',
location: 'ул. Айни 23, Бохтар',
},
{
id: 5,
name: 'АЗС Хорог',
image: '/placeholder.svg?height=400&width=600&text=АЗС+Хорог',
location: 'ул. Горная 12, Хорог',
},
{
id: 6,
name: 'АЗС Истаравшан',
image: '/placeholder.svg?height=400&width=600&text=АЗС+Истаравшан',
location: 'ул. Исмоили Сомони 34, Истаравшан',
},
];
export function StationGallery() {
const [currentImage, setCurrentImage] = useState(0); const [currentImage, setCurrentImage] = useState(0);
const [selectedStation, setSelectedStation] = useState<Station | null>(null); const [selectedStation, setSelectedStation] = useState<Stations[0] | null>(
null,
);
const nextImage = () => { const nextImage = () => {
setCurrentImage((prev) => (prev === stations.length - 1 ? 0 : prev + 1)); setCurrentImage((prev) => (prev === stations.length - 1 ? 0 : prev + 1));
@ -76,7 +38,7 @@ export function StationGallery() {
const { t } = useTextController(); const { t } = useTextController();
return ( return (
<div className='space-y-8'> <div className='space-y-8 px-2'>
<div className='relative h-[400px] overflow-hidden rounded-xl shadow-xl md:h-[500px]'> <div className='relative h-[400px] overflow-hidden rounded-xl shadow-xl md:h-[500px]'>
<Image <Image
src={stations[currentImage].image || '/placeholder.svg'} src={stations[currentImage].image || '/placeholder.svg'}
@ -86,7 +48,7 @@ export function StationGallery() {
/> />
<div className='absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/70 to-transparent p-6 text-white'> <div className='absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/70 to-transparent p-6 text-white'>
<h3 className='text-2xl font-bold'>{stations[currentImage].name}</h3> <h3 className='text-2xl font-bold'>{stations[currentImage].name}</h3>
<p className='text-white/80'>{stations[currentImage].location}</p> <p className='text-white/80'>{stations[currentImage].address}</p>
</div> </div>
<Button <Button
@ -125,7 +87,7 @@ export function StationGallery() {
<DialogHeader> <DialogHeader>
<DialogTitle>{stations[currentImage].name}</DialogTitle> <DialogTitle>{stations[currentImage].name}</DialogTitle>
<DialogDescription> <DialogDescription>
{stations[currentImage].location} {stations[currentImage].address}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='relative h-[60vh] w-full'> <div className='relative h-[60vh] w-full'>

View File

@ -4,41 +4,49 @@ import { Users } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import AboutCounter from '@/shared/components/about-counter'; import AboutCounter from '@/shared/components/about-counter';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useMediaController } from '@/shared/media/hooks/use-media-controller';
export const AboutSection = () => { export const AboutSection = () => {
const { t } = useTextController(); const { t } = useTextController();
const { m } = useMediaController();
return ( return (
<section id='about' className='px-2 py-8 sm:py-16'> <section id='about'>
<div className='container mx-auto'> <Container>
<div className='grid items-center gap-12 md:grid-cols-2'> <div className='text-justify'>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Users className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('home.about.title')}
</h2>
<p className='mb-3 text-gray-600 sm:mb-6'>
{t('home.about.description1')}
</p>
<p className='mb-3 text-gray-600 sm:mb-6'>
{t('home.about.description2')}
</p>
</div>
<div className='my-4 grid items-center gap-6 sm:my-8 md:grid-cols-2 md:gap-12'>
<div> <div>
<div className='mb-4 inline-flex items-center justify-center rounded-full bg-red-100 p-2'>
<Users className='h-6 w-6 text-red-600' />
</div>
<h2 className='mb-6 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('home.about.title')}
</h2>
<p className='mb-6 text-gray-600'>{t('home.about.description1')}</p>
<p className='mb-6 text-gray-600'>{t('home.about.description2')}</p>
<AboutCounter /> <AboutCounter />
<Features /> <Features />
</div> </div>
<div <div
className='relative h-[400px] overflow-hidden rounded-xl shadow-xl' className='relative h-[400px] overflow-hidden rounded-xl md:h-full'
data-aos='zoom-in-down' data-aos='zoom-in-down'
> >
<Image <Image
src='/placeholder.svg?height=400&width=600' src={m('main.price-board') || ''}
alt='About our company' alt='About our company'
fill fill
className='object-cover' className='w-full object-contain p-2.5'
/> />
</div> </div>
</div> </div>
</div> </Container>
</section> </section>
); );
}; };

View File

@ -0,0 +1,47 @@
'use client';
import Image from 'next/image';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useMediaController } from '@/shared/media/hooks/use-media-controller';
import { AppStoreButtons } from '@/shared/components/app-store-buttons';
export const AppDownloadSection = () => {
const { t } = useTextController();
const { m } = useMediaController();
return (
<section className='bg-gray-50 py-16 sm:py-24 overflow-hidden'>
<Container>
<div className='flex flex-col items-center text-center space-y-8'>
<div className='space-y-4'>
<h2 className='text-3xl sm:text-4xl font-bold tracking-tight text-gray-900'>
{t('common.name')} всегда с вами
</h2>
<p className='text-lg text-gray-600 max-w-2xl mx-auto'>
Заправляйтесь быстрее, копите баллы и следите за акциями в нашем мобильном приложении. Ваш верный помощник на дорогах Таджикистана.
</p>
</div>
<AppStoreButtons className='justify-center' />
<div className='flex flex-wrap items-center justify-center gap-6 text-sm font-medium text-gray-500'>
<div className='flex items-center gap-1.5 transition-colors hover:text-red-600'>
<svg className='w-5 h-5 text-green-500' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
Бонусная карта в телефоне
</div>
<div className='flex items-center gap-1.5 transition-colors hover:text-red-600'>
<svg className='w-5 h-5 text-green-500' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
История заправок
</div>
</div>
</div>
</Container>
</section>
);
};

View File

@ -4,25 +4,32 @@ import { ChevronRight, Heart } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller'; import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useMediaController } from '@/shared/media/hooks/use-media-controller';
import { Button } from '@/shared/shadcn-ui/button'; import { Button } from '@/shared/shadcn-ui/button';
export const CharitySection = () => { export const CharitySection = () => {
const { t } = useTextController(); const { t } = useTextController();
const { m } = useMediaController();
return ( return (
<section id='charity' className='px-2 py-8 sm:py-16'> <section id='charity'>
<div className='container mx-auto'> <Container>
<div className='grid items-center gap-12 md:grid-cols-2'> <div className='grid items-center gap-12 md:grid-cols-2'>
<div <div
className='relative order-2 h-[400px] w-full overflow-hidden rounded-xl shadow-xl md:order-1' className='relative order-2 h-[400px] w-full overflow-hidden rounded-xl shadow-xl md:order-1'
data-aos='zoom-in' data-aos='zoom-in'
> >
<Image <Image
src='/placeholder.svg?height=400&width=600' src={
m('home.charity.banner') ||
'/placeholder.svg?height=400&width=600'
}
alt='Charity Foundation' alt='Charity Foundation'
fill fill
className='object-cover' className='object-cover'
loader={({ src }) => src}
/> />
</div> </div>
<div className='order-1 md:order-2'> <div className='order-1 md:order-2'>
@ -59,7 +66,7 @@ export const CharitySection = () => {
</Link> </Link>
</div> </div>
</div> </div>
</div> </Container>
</section> </section>
); );
}; };

View File

@ -0,0 +1,99 @@
'use client';
import { Check } from 'lucide-react';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language';
import { Card, CardContent } from '@/shared/shadcn-ui/card';
const loyaltyLevels = [
{
id: 'card-1',
borderColor: 'gray-400',
title: 'clients.loyalty.works.levels.card-1.title',
percent: 'clients.loyalty.works.levels.card-1.percent',
bonuses: [
'clients.loyalty.works.levels.card-1.bonus-1',
'clients.loyalty.works.levels.card-1.bonus-2',
'clients.loyalty.works.levels.card-1.bonus-3',
],
},
{
id: 'card-2',
borderColor: 'yellow-500',
title: 'clients.loyalty.works.levels.card-2.title',
percent: 'clients.loyalty.works.levels.card-2.percent',
bonuses: [
'clients.loyalty.works.levels.card-2.bonus-1',
'clients.loyalty.works.levels.card-2.bonus-2',
'clients.loyalty.works.levels.card-2.bonus-3',
'clients.loyalty.works.levels.card-2.bonus-4',
],
},
{
id: 'card-3',
borderColor: 'red-600',
title: 'clients.loyalty.works.levels.card-3.title',
percent: 'clients.loyalty.works.levels.card-3.percent',
bonuses: [
'clients.loyalty.works.levels.card-3.bonus-1',
'clients.loyalty.works.levels.card-3.bonus-2',
'clients.loyalty.works.levels.card-3.bonus-3',
'clients.loyalty.works.levels.card-3.bonus-4',
'clients.loyalty.works.levels.card-3.bonus-5',
],
},
];
const LoyaltyLevels = () => {
const { t } = useTextController();
return (
<Container>
<div className='mb-12 text-center'>
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>
{t('clients.loyalty.works.levels.title')}
</h2>
<p className='mx-auto max-w-2xl text-gray-600'>
{t('clients.loyalty.works.levels.description')}
</p>
</div>
<div className='grid gap-8 md:grid-cols-3'>
{loyaltyLevels.map((level) => (
<Card
key={level.id}
data-aos='flip-left'
data-aos-duration='500'
className={`overflow-hidden border-t-4 border-t-${level.borderColor} transition-all hover:shadow-lg`}
>
<CardContent className='p-6'>
<h3 className='mb-4 text-center text-2xl font-bold'>
{t(level.title)}
</h3>
<div className='mb-6 text-center'>
<span className='text-4xl font-bold'>{t(level.percent)}</span>
<p className='text-sm text-gray-600'>
{t('clients.loyalty.works.levels.card.mark')}
</p>
</div>
<ul className='space-y-2'>
{level.bonuses.map((bonus, index) => (
<li
key={`${level.id}-bonus-${index}`}
className='flex items-center'
>
<Check className='mr-2 h-5 w-5 text-green-500' />
<span>{t(bonus)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</Container>
);
};
export default LoyaltyLevels;

Some files were not shown because too many files have changed in this diff Show More