Compare commits

..

No commits in common. "dev" and "fix-bugs" have entirely different histories.

100 changed files with 1929 additions and 5289 deletions

View File

@ -1,32 +0,0 @@
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

@ -8,11 +8,6 @@ const nextConfig: NextConfig = {
hostname: 'media.bambooapp.ai',
pathname: '/files/**',
},
{
protocol: 'https',
hostname: 'taylordb.ai',
pathname: '/media/**',
},
],
},
};

View File

@ -7,7 +7,7 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint ."
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
@ -23,27 +23,24 @@
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.8",
"@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.2.6",
"@reduxjs/toolkit": "^2.7.0",
"@taylordb/query-builder": "^0.10.1",
"aos": "^2.3.4",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookies-next": "^5.1.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"json-to-graphql-query": "^2.3.0",
"lodash": "^4.17.21",
"lucide-react": "^0.501.0",
"next": "16.0.10",
"next": "15.3.1",
"next-redux-wrapper": "^8.1.0",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "19.2.3",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.1",
"react-redux": "^9.2.0",
"sonner": "^2.0.3",
@ -55,6 +52,7 @@
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/js": "^9.25.0",
"@tailwindcss/postcss": "^4",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
@ -64,11 +62,11 @@
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/lodash": "^4.17.16",
"@types/node": "^20.17.30",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/parser": "^8.30.1",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"eslint-config-next": "15.3.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import-x": "^4.10.6",
"eslint-plugin-react": "^7.37.5",
@ -80,11 +78,5 @@
"tailwindcss": "^4",
"typescript": "^5",
"typescript-eslint": "^8.30.1"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
}
}

2434
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

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

View File

@ -0,0 +1,8 @@
import { HistoryItems, Reviews, Stations, TeamMembers } from '.';
export type AboutUsPageData = {
team: TeamMembers;
history: HistoryItems;
stations: Stations;
reviews: Reviews;
};

View File

@ -1,6 +1,4 @@
import {
presentCertificates,
presentCharities,
presentDiscounts,
presentHistoryItems,
presentJobs,
@ -22,90 +20,67 @@ export interface Select {
}
export type Discount = Root<{
zagolovok: string;
opisanie: string;
do: string;
foto: Image[];
_name: string;
_opisanie: string;
_do: string;
_foto: Image[];
}>;
export type Job = Root<{
id: number;
zagolovok: string;
tip: Select[];
lokaciya: Select[];
tegi: Select[];
_name: string;
_type: Select[];
_localtio: Select[];
_tags: Select[];
}>;
export type Partner = Root<{
id: number;
nazvanie: string;
izobrozhenie: Image[];
_name: string;
_image: Image[];
}>;
export type Station = Root<{
imya: string;
opisanie: string;
adress: string;
chasyRaboty: Select[];
lat: number;
long: number;
avtomojka: boolean;
ai92: boolean;
ai95: boolean;
z100: boolean;
propan: boolean;
zaryadnayaStanciya: boolean;
dt: boolean;
miniMarket: boolean;
tualet: boolean;
region: Select[];
foto: Image[];
_name: string;
_opisanie: string;
_adress: string;
_chasyRaboty: Select;
_lat: number;
_long: number;
_avtomojka: boolean;
_dtCopy: boolean;
_ai92Copy: boolean;
_ai95Copy: boolean;
_z100Copy: boolean;
_propanCopy: boolean;
_zaryadnayaStanci: boolean;
_miniMarketCop: boolean;
_region: Select[];
_foto: Image[];
}>;
export type TextResponse = Root<{
klyuchNeIzmenyat: string;
znachenie: string | null;
}>;
export type MediaResponse = Root<{
mestopolozheniya: string;
foto: Image[];
klyuchNeIzmenyat: string;
_name: string;
_znachenie: string | null;
}>;
export type Team = Root<{
foto: Image[];
zvanie: string;
polnoeImya: string;
_foto: Image[];
_zvanie: string;
_name: string;
}>;
export type History = Root<{
zagolovok: string;
god: string;
opisanie: string;
_name: 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[];
_name: string;
_otzyv: string;
_rejting: number;
}>;
export type TeamMembers = ReturnType<typeof presentTeamMembers>;
@ -115,5 +90,3 @@ 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,8 @@
import { Discounts, Jobs, Partners, Stations } from '.';
export type MainPageData = {
discounts: Discounts;
jobs: Jobs;
partners: Partners;
stations: Stations;
};

View File

@ -1,33 +0,0 @@
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

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

View File

@ -1,13 +1,10 @@
import { isEmpty } from 'lodash';
import {
Certificate,
Charity,
Discount,
History,
Image,
Job,
MediaResponse,
Partner,
Review,
Select,
@ -25,102 +22,73 @@ export const presentSelect = (selectItems: Select[]) =>
export const presentPartners = (partners: Partner) =>
partners.records.map((record, index) => ({
id: index + 1,
name: record.nazvanie,
poster: presentImage(record.izobrozhenie),
name: record._name,
poster: presentImage(record._image),
}));
export const presentJobs = (jobs: Job) =>
jobs.records.map((job, index) => ({
id: index + 1,
name: job.zagolovok,
tags: job.tegi.map((tag) => tag.name),
location: presentSelect(job.lokaciya),
type: presentSelect(job.tip),
name: job._name,
tags: job._tags.map((tag) => tag.name),
location: presentSelect(job._localtio),
type: presentSelect(job._type),
}));
export const presentTeamMembers = (members: Team) =>
members.records.map((member) => ({
name: member.polnoeImya,
photo: presentImage(member.foto),
profession: member.zvanie,
name: member._name,
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,
name: item._name,
year: item._god,
description: item._opisanie,
}));
export const presentDiscounts = (discounts: Discount) =>
discounts.records.map((discount, index) => ({
id: index + 1,
name: discount.zagolovok,
description: discount.opisanie,
expiresAt: discount.do,
image: presentImage(discount.foto),
name: discount._name,
description: discount._opisanie,
expiresAt: discount._do,
image: presentImage(discount._foto),
}));
export const presentStations = (stations: Station) =>
stations.records.map((station, index) => ({
id: index + 1,
name: station.imya,
description: station.opisanie,
address: station.adress,
workingHours: presentSelect(station.chasyRaboty),
latitude: station.lat,
longitude: station.long,
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: presentSelect(station.region),
image: presentImage(station.foto),
name: station._name,
description: station._opisanie,
address: station._adress,
workingHours: station._chasyRaboty?.name || null,
latitude: station._lat,
longitude: station._long,
carWash: station._avtomojka || false,
ai92: station._dtCopy || false,
ai95: station._ai92Copy || false,
z100: station._ai95Copy || false,
propan: station._z100Copy || false,
electricCharge: station._propanCopy || false,
miniMarket: station._zaryadnayaStanci || false,
toilet: station._miniMarketCop || false,
region: presentSelect(station._region),
image: presentImage(station._foto),
}));
export const presentTexts = (texts: TextResponse) =>
texts.records.map((item) => ({
key: item.klyuchNeIzmenyat,
value: item.znachenie,
key: item._name,
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),
fullname: review._name,
review: review._otzyv,
rating: review._rejting,
}));

View File

@ -1,238 +0,0 @@
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

@ -0,0 +1,13 @@
import {
historyRequest,
reviewsRequest,
stationsWithImageRequest,
teamRequest,
} from './common';
export const aboutUsPageRequest = {
...teamRequest,
...historyRequest,
...stationsWithImageRequest,
...reviewsRequest,
};

View File

@ -1,29 +1,28 @@
import { EnumType, VariableType } from 'json-to-graphql-query';
import { EnumType } from 'json-to-graphql-query';
export const stationsRequest = {
azs: {
_azs: {
records: {
imya: true,
opisanie: true,
adress: true,
chasyRaboty: {
_name: 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: {
_lat: true,
_long: true,
_avtomojka: true,
_dtCopy: true, // ai92
_ai92Copy: true, // ai95
_ai95Copy: true, // z100
_z100Copy: true, // propan
_propanCopy: true, // electricCharge
_zaryadnayaStanci: true, // miniMarket
_miniMarketCop: true, // toilet
_region: {
name: true,
},
foto: {
_foto: {
url: true,
},
},
@ -31,13 +30,13 @@ export const stationsRequest = {
};
export const stationsWithImageRequest = {
azs: {
_azs: {
__args: {
filtersSet: {
conjunction: new EnumType('and'),
filtersSet: [
{
field: new EnumType('foto'),
field: new EnumType('_foto'),
operator: 'isNotEmpty',
value: [],
},
@ -46,27 +45,26 @@ export const stationsWithImageRequest = {
},
records: {
imya: true,
opisanie: true,
adress: true,
chasyRaboty: {
_name: 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: {
_lat: true,
_long: true,
_avtomojka: true,
_dtCopy: true, // ai92
_ai92Copy: true, // ai95
_ai95Copy: true, // z100
_z100Copy: true, // propan
_propanCopy: true, // electricCharge
_zaryadnayaStanci: true, // miniMarket
_miniMarketCop: true, // toilet
_region: {
name: true,
},
foto: {
_foto: {
url: true,
},
},
@ -74,10 +72,10 @@ export const stationsWithImageRequest = {
};
export const partnersRequest = {
partnyory: {
_partners: {
records: {
nazvanie: true,
izobrozhenie: {
_name: true,
_image: {
url: true,
},
},
@ -85,16 +83,16 @@ export const partnersRequest = {
};
export const jobsRequest = {
vakansii: {
_vacancies: {
records: {
zagolovok: true,
tegi: {
_name: true,
_tags: {
name: true,
},
tip: {
_type: {
name: true,
},
lokaciya: {
_localtio: {
name: true,
},
},
@ -102,123 +100,56 @@ export const jobsRequest = {
};
export const discountsRequest = {
akcii: {
_akcii: {
records: {
zagolovok: true,
opisanie: true,
do: true,
foto: {
name: true,
_name: true,
_opisanie: true,
_do: true,
_foto: {
url: true,
},
},
},
};
export const textsRequest = {
tekstovyjKontentSajta: {
_kontentSajta: {
records: {
znachenie: true,
klyuchNeIzmenyat: true,
},
},
};
export const mediaRequest = {
mediaKontentSajta: {
records: {
mestopolozheniya: true,
foto: {
id: true,
url: true,
},
klyuchNeIzmenyat: true,
_name: true,
_znachenie: true,
},
},
};
export const teamRequest = {
komanda: {
_komanda: {
records: {
foto: {
_foto: {
url: true,
},
zvanie: true,
polnoeImya: true,
_zvanie: true,
_name: true,
},
},
};
export const historyRequest = {
istoriyaKompanii: {
_istoriya: {
records: {
zagolovok: true,
god: true,
opisanie: true,
_name: true,
_god: true,
_opisanie: true,
},
},
};
export const reviewsRequest = {
otzyvy: {
__args: {
filtersSet: {
conjunction: new EnumType('and'),
filtersSet: [
{
field: new EnumType('status'),
operator: 'contains',
value: 'Опубликовано',
},
],
},
},
_otzyvy: {
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,
_name: true,
_otzyv: true,
_rejting: true,
},
},
};

View File

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

View File

@ -1,11 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,8 @@
import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } 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 oriyoClient from '@/app/api-utlities/utilities/oriyo.client';
import { authorizationMiddleware } from '../../middlewares/auth.middleware';
import { validationErrorHandler } from '../../middlewares/error-handler.middleware';
const validatedSchema = z.object({
@ -16,12 +12,30 @@ const validatedSchema = z.object({
page: z.coerce.number(),
});
const routeHandler = async (req: NextRequest, requestCookie: RequestCookie) => {
const validatedRequest = validatedSchema.parse(getParams(req));
const routeHandler = async (req: NextRequest) => {
const bonusTokenData = req.cookies.get('bonus__token');
const { card_id, token } = JSON.parse(requestCookie.value);
if (!bonusTokenData) {
return NextResponse.json(
{ error: 'User does not have access' },
{ status: 401 },
);
}
const oriyoResponse = await oriyoClient.get('/bonuscardts', {
const params = Array.from(req.nextUrl.searchParams.entries()).reduce(
(pr, cr) => {
pr[cr[0]] = cr[1];
return pr;
},
{} as Record<string, string>,
);
const validatedRequest = validatedSchema.parse(params);
const { card_id, token } = JSON.parse(bonusTokenData.value);
const oriyoResponse = await oriyoClient.get('/client/transactions', {
params: {
card_id,
token,
@ -35,32 +49,15 @@ const routeHandler = async (req: NextRequest, requestCookie: RequestCookie) => {
},
});
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' },
},
);
const parsedResponse = JSON.parse(oriyoResponse.data);
if (oriyoResponse.status === 401) {
throw new AuthorizationError();
if (parsedResponse.error) {
return NextResponse.json({ message: 'Fetch error' }, { status: 400 });
}
if (oriyoResponse.data.error) throw oriyoResponse.data;
return new Response(JSON.stringify(oriyoResponse.data), {
return new Response(oriyoResponse.data, {
headers: { 'Content-Type': 'application/json' },
});
};
export const GET = validationErrorHandler(
authorizationMiddleware(routeHandler, 'bonus__token'),
);
export const GET = validationErrorHandler(routeHandler);

View File

@ -1,16 +0,0 @@
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

@ -1,18 +1,23 @@
import { omit } from 'lodash';
import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } 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);
const routeHandler = async (req: NextRequest) => {
const bonusTokenData = req.cookies.get('corporate__token');
if (!bonusTokenData) {
return NextResponse.json(
{ error: 'User does not have access' },
{ status: 401 },
);
}
const parsedData = JSON.parse(bonusTokenData.value);
return new Response(JSON.stringify(omit(parsedData, 'token')), {
headers: { 'Content-Type': 'application/json' },
});
};
export const GET = validationErrorHandler(
authorizationMiddleware(routeHandler, 'corporate__token'),
);
export const GET = validationErrorHandler(routeHandler);

View File

@ -1,65 +0,0 @@
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

@ -1,33 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { fetchMainPageContent } from '@/features/pages/services/pages.service';
import { mainPageApi } from '@/features/pages/api/pages.api';
import { makeStore } from '@/shared/store';
import { AboutSection } from '@/widgets/about-section';
import { AppDownloadSection } from '@/widgets/app-download-section';
import { CharitySection } from '@/widgets/charity-section';
import { CtaSection } from '@/widgets/cta-section';
import { HeroSection } from '@/widgets/hero-section';
@ -12,14 +13,17 @@ import { StatsSection } from '@/widgets/stats-section';
import { VacanciesSection } from '@/widgets/vacancies-section';
export default async function Home() {
const data = await fetchMainPageContent();
const store = makeStore();
if (!data) return null;
const { data, isLoading, error } = await store.dispatch(
mainPageApi.endpoints.fetchMainPageContent.initiate(),
);
if (isLoading || !data) return null;
return (
<main>
<HeroSection />
<AppDownloadSection />
<StatsSection />
<MapSection stations={data.stations} />
<AboutSection />

View File

@ -1,10 +1,10 @@
import { baseAPI } from '@/shared/api/base-api';
import { ClientInfo } from '../model/types/bonus-client-info.type';
import {
BonusTransactionRequest,
BonusTransactionResponse,
} from '../model/types/bonus-transactions.type';
ClientInfo,
TransactionRequest,
TransactionResponse,
} from '../model/types/bonus-client-info.type';
export const bonusApi = baseAPI.injectEndpoints({
endpoints: (builder) => ({
@ -16,8 +16,8 @@ export const bonusApi = baseAPI.injectEndpoints({
},
}),
fetchBonusTransactions: builder.query<
BonusTransactionResponse,
BonusTransactionRequest
TransactionResponse,
TransactionRequest
>({
query: (request) => {
return {

View File

@ -6,3 +6,29 @@ export interface ClientInfo {
end_date: string;
bonuses: string;
}
export interface TransactionResponse {
transactions: Transaction[];
card_id: string;
current_page: number;
limit: number;
total_records: number;
total_pages: number;
}
export interface Transaction {
id: number;
date_create: string;
station: string;
product_name: string;
amount: string;
price_real: string;
sum_real: string;
}
export interface TransactionRequest {
start_date?: string;
end_date?: string;
page: number;
limit: number;
}

View File

@ -1,24 +0,0 @@
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

@ -1,10 +1,6 @@
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) => ({
@ -15,21 +11,7 @@ export const corporateApi = baseAPI.injectEndpoints({
};
},
}),
fetchCorporateTransactions: builder.query<
CorporateTransactionResponse,
CorporateTransactionRequest
>({
query: (request) => {
return {
url: '/corporate/transactions',
params: request,
};
},
}),
}),
});
export const {
useFetchMyCorporateInfoQuery,
useFetchCorporateTransactionsQuery,
} = corporateApi;
export const { useFetchMyCorporateInfoQuery } = corporateApi;

View File

@ -1,24 +0,0 @@
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

@ -372,7 +372,7 @@ export default function GasStationMap({ stations }: GasStationMapProps) {
// Все доступные фильтры
const allFilters = [
'ДТ',
// 'ДТ', -> нет значения в интерфейсе - TODO: поправить
'АИ-92',
'АИ-95',
'Z-100 Power',
@ -386,7 +386,6 @@ export default function GasStationMap({ stations }: GasStationMapProps) {
// Маппинг фильтров на поля Station
const filterToFieldMap: { [key: string]: keyof Stations[number] } = {
'АИ-92': 'ai92',
ДТ: 'dt',
'АИ-95': 'ai95',
'Z-100 Power': 'z100',
Пропан: 'propan',

View File

@ -1,11 +1,6 @@
import {
GeolocationControl,
Map,
Placemark,
YMaps,
} from '@pbe/react-yandex-maps';
import { Map, Placemark, YMaps } from '@pbe/react-yandex-maps';
import { isEmpty } from 'lodash';
import { useEffect, useRef } from 'react';
import { Dispatch, SetStateAction } from 'react';
import { Point } from '../model';
@ -22,29 +17,6 @@ export const YandexMap = ({
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={{
@ -66,9 +38,6 @@ export const YandexMap = ({
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;
@ -89,14 +58,6 @@ export const YandexMap = ({
/>
);
})}
<GeolocationControl
options={{
position: {
bottom: 20,
right: 20,
},
}}
/>
</Map>
</YMaps>
);

View File

@ -0,0 +1,59 @@
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { AboutUsPageData } from '@/app/api-utlities/@types/about-us';
import { MainPageData } from '@/app/api-utlities/@types/main';
import {
presentDiscounts,
presentHistoryItems,
presentJobs,
presentPartners,
presentReviews,
presentStations,
presentTeamMembers,
} from '@/app/api-utlities/presenters';
import { aboutUsPageRequest } from '@/app/api-utlities/requests/about-us-page.request';
import { mainPageRequest } from '@/app/api-utlities/requests/main-page.request';
import { taylorAPI } from '@/shared/api/taylor-api';
export const mainPageApi = taylorAPI.injectEndpoints({
endpoints: (builder) => ({
fetchMainPageContent: builder.query<MainPageData, void>({
query: () => ({
url: '',
method: 'POST',
body: {
query: jsonToGraphQLQuery({ query: mainPageRequest }),
},
}),
transformResponse: (response: any) => {
return {
partners: presentPartners(response.data._partners),
jobs: presentJobs(response.data._vacancies),
discounts: presentDiscounts(response.data._akcii),
stations: presentStations(response.data._azs),
};
},
}),
fetchAboutUsPageContent: builder.mutation<AboutUsPageData, void>({
query: () => ({
url: '',
method: 'POST',
body: {
query: jsonToGraphQLQuery({ query: aboutUsPageRequest }),
},
}),
transformResponse: (response: any) => {
return {
team: presentTeamMembers(response.data._komanda),
history: presentHistoryItems(response.data._istoriya),
stations: presentStations(response.data._azs),
reviews: presentReviews(response.data._otzyvy),
};
},
}),
}),
});

View File

@ -1,193 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,217 +0,0 @@
'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,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
export function proxy(req: NextRequest) {
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const path = url.pathname;

View File

@ -1,17 +1,13 @@
'use client';
import { deleteCookie } from 'cookies-next';
import { subMonths } from 'date-fns';
import { Building2, LogOut, Wallet } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import {
useFetchCorporateTransactionsQuery,
useFetchMyCorporateInfoQuery,
} from '@/entities/corporate/api/corporate.api';
import { CorporateTransactionRequest } from '@/entities/corporate/model/types/corporate-transactions.type';
import { useFetchMyCorporateInfoQuery } from '@/entities/corporate/api/corporate.api';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { formatDate } from '@/shared/lib/format-date';
import { Button } from '@/shared/shadcn-ui/button';
import {
Card,
@ -20,30 +16,90 @@ import {
CardHeader,
CardTitle,
} from '@/shared/shadcn-ui/card';
import { TableCell, TableHead, TableRow } from '@/shared/shadcn-ui/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() {
const { t } = useTextController();
const [startDate, setStartDate] = useState<Date | undefined>(
subMonths(new Date(), 1),
);
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
const [filteredTransactions, setFilteredTransactions] =
useState(transactions);
const { data } = useFetchMyCorporateInfoQuery({});
// Filter transactions by date range
const filterTransactions = () => {
if (!startDate || !endDate) return;
const [request, setTransactionFetchRequest] =
useState<CorporateTransactionRequest>({
limit: 10,
page: 1,
const filtered = transactions.filter((transaction) => {
const transactionDate = new Date(transaction.date);
return transactionDate >= startDate && transactionDate <= endDate;
});
const {
data: transactionsResponse,
refetch,
isFetching,
} = useFetchCorporateTransactionsQuery(request);
setFilteredTransactions(filtered);
};
useEffect(() => {
refetch();
}, [request]);
const { t } = useTextController();
const { data, isLoading } = useFetchMyCorporateInfoQuery({});
return (
<div className='flex min-h-screen flex-col px-2.5'>
@ -165,66 +221,11 @@ export function CorporateDashboard() {
</Card>
</div>
<TransactionsTable
isLoading={isFetching}
renderHeaders={() => (
<TableRow>
<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}
/>
{/* <CardsList totalCards={companyData.numberOfCards} /> */}
{/* Transactions */}
<TransactionsTable />
</div>
</main>
</div>

View File

@ -2,17 +2,11 @@
import { deleteCookie } from 'cookies-next';
import { ArrowUpRight, Clock, CreditCard, LogOut, User } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
useFetchBonusTransactionsQuery,
useFetchMyBonusInfoQuery,
} from '@/entities/bonus/api/bonus.api';
import { BonusTransactionRequest } from '@/entities/bonus/model/types/bonus-transactions.type';
import { useFetchMyBonusInfoQuery } from '@/entities/bonus/api/bonus.api';
import Loader from '@/shared/components/loader';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { formatDate } from '@/shared/lib/format-date';
import { Button } from '@/shared/shadcn-ui/button';
import {
Card,
@ -21,31 +15,14 @@ import {
CardHeader,
CardTitle,
} from '@/shared/shadcn-ui/card';
import { TableCell, TableHead, TableRow } from '@/shared/shadcn-ui/table';
import { TransactionsTable } from '@/widgets/transactions-table';
export function CustomerDashboard() {
const { t } = useTextController();
const [request, setTransactionFetchRequest] =
useState<BonusTransactionRequest>({
limit: 10,
page: 1,
});
const { data, isLoading } = useFetchMyBonusInfoQuery({});
const {
data: transactionsResponse,
refetch,
isFetching: isTransactionLoading,
} = useFetchBonusTransactionsQuery(request);
useEffect(() => {
refetch();
}, [request]);
return (
<div className='flex min-h-screen flex-col px-2.5'>
<main className='flex-1 py-10'>
@ -154,59 +131,7 @@ export function CustomerDashboard() {
</Card>
</div>
<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}
/>
<TransactionsTable />
</div>
</main>
</div>

View File

@ -2,31 +2,29 @@
import { Fuel, History, MapPin, Star, Target, Users } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { AboutUsPageData } from '@/app/api-utlities/@types/pages';
import { ReviewForm } from '@/features/review-form/ui';
import { AboutUsPageData } from '@/app/api-utlities/@types/about-us';
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 { useMediaController } from '@/shared/media/hooks/use-media-controller';
import { Button } from '@/shared/shadcn-ui/button';
import { Card, CardContent } from '@/shared/shadcn-ui/card';
import { CompanyTimeline } from '@/widgets/about-page/company-timeline';
import { StationGallery } from '@/widgets/about-page/station-gallery';
import { CtaSection } from '@/widgets/cta-section';
export const metadata = {
title: 'about.metadata.title',
description: 'about.metadata.description',
};
export interface AboutPageProps {
content: AboutUsPageData;
}
export default function AboutPage({ content }: AboutPageProps) {
const { t } = useTextController();
const { m } = useMediaController();
return (
<div className='flex min-h-screen flex-col'>
@ -35,14 +33,12 @@ export default function AboutPage({ content }: AboutPageProps) {
<section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'>
<Image
src={
m('about.hero-section.banner') ||
'/placeholder.svg?height=400&width=1920&text=Наша+История'
}
src='/placeholder.svg?height=400&width=1920&text=Наша+История'
alt={t('about.hero.imageAlt')}
width={1920}
height={400}
className='object-cover'
priority
fill
/>
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30 px-2'>
<div
@ -106,10 +102,7 @@ export default function AboutPage({ content }: AboutPageProps) {
className='relative h-[500px] overflow-hidden rounded-xl shadow-xl'
>
<Image
src={
m('about.second-section.banner') ||
'/placeholder.svg?height=500&width=600&text=Главный+офис'
}
src='/placeholder.svg?height=500&width=600&text=Главный+офис'
alt={t('about.overview.imageAlt')}
fill
className='object-cover'
@ -190,12 +183,10 @@ export default function AboutPage({ content }: AboutPageProps) {
<p className='mx-auto mb-6 max-w-2xl text-gray-600'>
{t('about.stations.description')}
</p>
<Link href='/#stations'>
<Button className='bg-red-600 hover:bg-red-700'>
{t('about.stations.buttonText')}{' '}
<MapPin className='ml-2 h-4 w-4' />
</Button>
</Link>
{/* <Button className='bg-red-600 hover:bg-red-700'>
{t('about.stations.buttonText')}{' '}
<MapPin className='ml-2 h-4 w-4' />
</Button> */}
</div>
</Container>
</section>
@ -214,11 +205,13 @@ export default function AboutPage({ content }: AboutPageProps) {
</p>
</div>
<div className='grid gap-8 md:grid-cols-3'>
<div
data-aos='flip-up'
data-aos-duration='600'
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'
>
@ -253,7 +246,7 @@ export default function AboutPage({ content }: AboutPageProps) {
</p>
</div>
<div
data-aos='flip-left'
data-aos='flip-down'
data-aos-duration='600'
className='grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4'
>
@ -298,12 +291,27 @@ export default function AboutPage({ content }: AboutPageProps) {
<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} />
<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(review.rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`}
/>
))}
</div>
<p className='mb-4 text-gray-600 italic'>"{review.review}"</p>
<p className='font-semibold'>{review.fullname}</p>
</CardContent>
</Card>
))}
</div>
<div className='mt-4 flex w-full justify-center'>
<ReviewForm />
</div>
</Container>
<CtaSection />

View File

@ -10,11 +10,8 @@ import {
} from 'lucide-react';
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 { useMediaController } from '@/shared/media/hooks/use-media-controller';
import {
Card,
CardContent,
@ -24,13 +21,41 @@ import {
import { CtaSection } from '@/widgets/cta-section';
export interface CharityPageProps {
content: CharityPageData;
}
export const metadata = {
title: 'Благотворительность | GasNetwork - Сеть заправок в Таджикистане',
description:
'Благотворительные проекты и инициативы GasNetwork. Мы помогаем обществу и заботимся о будущем.',
};
export function CharityPage({ content }: CharityPageProps) {
const events = [
{
title: 'Благотворительный марафон',
description:
'Ежегодный благотворительный марафон в поддержку детей с особыми потребностями.',
date: '15 июня 2023',
location: 'Парк Рудаки, Душанбе',
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=Школьные+принадлежности',
},
];
export function CharityPage() {
const { t } = useTextController();
const { m } = useMediaController();
return (
<div className='flex min-h-screen flex-col'>
<main className='flex-1'>
@ -38,14 +63,12 @@ export function CharityPage({ content }: CharityPageProps) {
<section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'>
<Image
src={
m('charity.hero-section.banner') ||
'/placeholder.svg?height=500&width=1920&text=Благотворительный+фонд+Ориё'
}
src='/placeholder.svg?height=500&width=1920&text=Благотворительный+фонд+GasNetwork'
alt={t('charity.hero.imageAlt')}
width={1920}
height={500}
className='object-cover'
priority
fill
/>
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'>
<Container data-aos='fade-down' data-aos-duration='800'>
@ -107,10 +130,7 @@ export function CharityPage({ content }: CharityPageProps) {
className='relative h-[500px] overflow-hidden rounded-xl shadow-xl'
>
<Image
src={
m('charity.second-section.banner') ||
'/placeholder.svg?height=500&width=600&text=Наша+миссия'
}
src='/placeholder.svg?height=500&width=600&text=Наша+миссия'
alt={t('charity.mission.imageAlt')}
fill
className='object-cover'
@ -158,7 +178,7 @@ export function CharityPage({ content }: CharityPageProps) {
</div>
<div className='grid gap-6 md:grid-cols-3'>
{content.charities.map((event, index) => (
{events.map((event, index) => (
<Card
data-aos='zoom-in-up'
key={index}
@ -168,14 +188,14 @@ export function CharityPage({ content }: CharityPageProps) {
<div className='relative h-48 w-full'>
<Image
src={event.image || '/placeholder.svg'}
alt={event.name}
alt={event.title}
fill
className='object-cover'
/>
</div>
<CardHeader>
<CardTitle className='text-xl lg:text-2xl'>
{event.name}
{event.title}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>

View File

@ -3,20 +3,69 @@
import { Download, Eye } from 'lucide-react';
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 { Button } from '@/shared/shadcn-ui/button';
import { Card, CardContent } from '@/shared/shadcn-ui/card';
export interface CertificatesPageProps {
content: CertificatesPageData;
}
export function CertificatesPage({ content }: CertificatesPageProps) {
export function CertificatesPage() {
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',
},
];
certificates.length = 0;
return (
<main>
<Container>
@ -28,7 +77,7 @@ export function CertificatesPage({ content }: CertificatesPageProps) {
</div>
<div className='grid gap-8 md:grid-cols-2 lg:grid-cols-3'>
{content.certificates.map((certificate) => (
{certificates.map((certificate) => (
<Card
data-aos='zoom-in'
key={certificate.id}
@ -37,24 +86,24 @@ export function CertificatesPage({ content }: CertificatesPageProps) {
<div className='relative h-[300px] w-full overflow-hidden bg-gray-100'>
<Image
src={certificate.image || '/placeholder.svg'}
alt={certificate.name}
alt={certificate.title}
fill
className='object-contain p-4'
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
</div>
<CardContent className='p-6'>
<h3 className='mb-2 text-xl font-bold'>{certificate.name}</h3>
<h3 className='mb-2 text-xl font-bold'>{certificate.title}</h3>
<p className='mb-4 text-gray-600'>{certificate.description}</p>
<div className='mb-4 text-sm text-gray-500'>
<p>
{t('certificates.issueDate')}: {certificate.issuedAt}
{t('certificates.issueDate')}: {certificate.issueDate}
</p>
<p>
{t('certificates.expiryDate')}: {certificate.validUntil}
{t('certificates.expiryDate')}: {certificate.expiryDate}
</p>
</div>
{/* <div className='flex gap-2'>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
@ -71,7 +120,7 @@ export function CertificatesPage({ content }: CertificatesPageProps) {
<Download size={16} />
<span>{t('common.buttons.download')}</span>
</Button>
</div> */}
</div>
</CardContent>
</Card>
))}

View File

@ -4,15 +4,19 @@ 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 { BenefitsSection } from '@/widgets/clients/ui/benefits-section';
import { ServicesOverviewSection } from '@/widgets/clients/ui/services-overview-section';
import { CtaSection } from '@/widgets/cta-section';
export const metadata = {
title: 'Клиентам | GasNetwork - Сеть заправок в Таджикистане',
description:
'Информация для клиентов: программа лояльности, топливные карты, сертификаты и способы оплаты.',
};
export function ClientsPage() {
const { t } = useTextController();
const { m } = useMediaController();
return (
<div className='flex min-h-screen flex-col'>
@ -21,14 +25,12 @@ export function ClientsPage() {
<section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'>
<Image
src={
m('clients.hero-section.banner') ||
'/placeholder.svg?height=400&width=1920&text=Для+наших+клиентов'
}
src='/placeholder.svg?height=400&width=1920&text=Для+наших+клиентов'
alt='Для наших клиентов'
width={1920}
height={400}
className='object-cover'
priority
fill
/>
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'>
<Container className='py-0'>

View File

@ -1,20 +1,24 @@
'use client';
import { Percent } from 'lucide-react';
import { Check, Percent } from 'lucide-react';
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 { Card, CardContent } from '@/shared/shadcn-ui/card';
// import LoyaltyLevels from '@/widgets/clients/loyalty/ui/loyalty-levels';
import { CtaSection } from '@/widgets/cta-section';
import ProgrammImg from '../../../../public/clients/loyatly/03a771e7-5d76-4111-a516-801aa925659f.jpg';
export const metadata = {
title: 'Программа лояльности | GasNetwork - Сеть заправок в Таджикистане',
description:
'Программа лояльности GasNetwork: накапливайте баллы и получайте скидки на топливо и услуги.',
};
export function LoyaltyPage() {
const { t } = useTextController();
const { m } = useMediaController();
return (
<div className='flex min-h-screen flex-col'>
@ -23,14 +27,12 @@ export function LoyaltyPage() {
<section className='relative'>
<div className='relative h-[400px] w-full overflow-hidden'>
<Image
src={
m('loyalty.hero-section.banner') ||
'/placeholder.svg?height=400&width=1920&text=Программа+лояльности'
}
src='/placeholder.svg?height=400&width=1920&text=Программа+лояльности'
alt='Программа лояльности'
width={1920}
height={400}
className='object-cover'
priority
fill
/>
<div className='absolute inset-0 flex items-center bg-gradient-to-r from-black/70 to-black/30'>
<Container data-aos='fade-down' data-aos-duration='800'>
@ -111,7 +113,7 @@ export function LoyaltyPage() {
className='relative h-[400px] overflow-hidden rounded-xl shadow-xl'
>
<Image
src={m('loyalty.second-section.banner') || ProgrammImg}
src={ProgrammImg}
alt='Программа лояльности'
fill
className='w-full object-contain p-2.5'
@ -181,7 +183,157 @@ export function LoyaltyPage() {
</Container>
</section>
{/* <LoyaltyLevels /> */}
{/* Loyalty Levels */}
<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'>
<Card
data-aos='flip-left'
data-aos-duration='500'
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
data-aos='flip-left'
data-aos-duration='500'
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-2.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-2.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-2.bonus-4')}
</span>
</li>
</ul>
</CardContent>
</Card>
<Card
data-aos='flip-left'
data-aos-duration='500'
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>
</Container>
<CtaSection />
</main>

View File

@ -155,10 +155,7 @@ export default function LoginPage() {
<div className='mt-8 text-center text-sm text-gray-500'>
<p>
{t('auth.loginIssues')}{' '}
<Link
href={`mailto:${t('auth.loginForm.contactUs.mail')}`}
className='text-red-600 hover:underline'
>
<Link href='/contact' className='text-red-600 hover:underline'>
{t('auth.contactLink')}
</Link>
</p>

View File

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

View File

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

View File

@ -1,15 +1,9 @@
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],
},
});

View File

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

View File

@ -1,66 +1,74 @@
'use client';
import { Users } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useIntersectionObserver } from '../hooks/use-intersection-observer';
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() {
const { t } = useTextController();
const { sectionRef, isVisible } = useIntersectionObserver<HTMLDivElement>();
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLDivElement>(null);
const toNumber = (value: string) => {
return Number(t(value));
};
const { t } = useTextController();
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 (
<div
ref={sectionRef}
className='my-4 grid grid-cols-1 gap-6 text-center md:my-8'
>
{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 ref={sectionRef} className='my-8 grid grid-cols-1 gap-6 text-center'>
<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={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>
);
}

View File

@ -1,91 +0,0 @@
'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

@ -122,7 +122,7 @@ export default function PromotionSlider({ discounts }: PromotionSliderProps) {
? `Действует до: ${promo.expiresAt}`
: null}
</span>
{/* <Link href='#'>
<Link href='#'>
<Button
variant='outline'
size='sm'
@ -130,7 +130,7 @@ export default function PromotionSlider({ discounts }: PromotionSliderProps) {
>
{t('common.buttons.readMore')}
</Button>
</Link> */}
</Link>
</div>
</CardContent>
</Card>

View File

@ -1,16 +0,0 @@
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

@ -1,24 +0,0 @@
'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

@ -1,20 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -0,0 +1,25 @@
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { presentTexts } from '@/app/api-utlities/presenters';
import { textsRequest } from '@/app/api-utlities/requests/common';
import { taylorAPI } from '@/shared/api/taylor-api';
import { TextItem } from '@/shared/types/text.types';
export const textControlApi = taylorAPI.injectEndpoints({
endpoints: (builder) => ({
fetchText: builder.query<TextItem[], void>({
query: () => ({
url: '',
method: 'POST',
body: {
query: jsonToGraphQLQuery({ query: textsRequest }),
},
}),
transformResponse: (response: any) => {
return presentTexts(response.data._kontentSajta);
},
}),
}),
});

View File

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

View File

@ -1,9 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,42 +0,0 @@
'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

@ -1,20 +0,0 @@
'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

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

View File

@ -1,22 +0,0 @@
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

@ -1,32 +0,0 @@
'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

@ -1,548 +0,0 @@
/**
* 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

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

View File

@ -6,43 +6,36 @@ import Image from 'next/image';
import AboutCounter from '@/shared/components/about-counter';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { useMediaController } from '@/shared/media/hooks/use-media-controller';
export const AboutSection = () => {
const { t } = useTextController();
const { m } = useMediaController();
return (
<section id='about'>
<Container>
<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 className='grid items-center gap-12 md:grid-cols-2'>
<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 />
<Features />
</div>
<div
className='relative h-[400px] overflow-hidden rounded-xl md:h-full'
className='relative h-[400px] overflow-hidden rounded-xl shadow-xl'
data-aos='zoom-in-down'
>
<Image
src={m('main.price-board') || ''}
src='/placeholder.svg?height=400&width=600'
alt='About our company'
fill
className='w-full object-contain p-2.5'
className='object-cover'
/>
</div>
</div>

View File

@ -1,47 +0,0 @@
'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

@ -6,12 +6,10 @@ import Link from 'next/link';
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 { Button } from '@/shared/shadcn-ui/button';
export const CharitySection = () => {
const { t } = useTextController();
const { m } = useMediaController();
return (
<section id='charity'>
@ -22,14 +20,10 @@ export const CharitySection = () => {
data-aos='zoom-in'
>
<Image
src={
m('home.charity.banner') ||
'/placeholder.svg?height=400&width=600'
}
src='/placeholder.svg?height=400&width=600'
alt='Charity Foundation'
fill
className='object-cover'
loader={({ src }) => src}
/>
</div>
<div className='order-1 md:order-2'>

View File

@ -1,99 +0,0 @@
'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;

View File

@ -5,7 +5,6 @@ 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';
interface Benefit {
title: string;
@ -33,7 +32,6 @@ const benefits: Array<Benefit> = [
export const BenefitsSection = () => {
const { t } = useTextController();
const { m } = useMediaController();
return (
<section className='bg-gray-50'>
@ -74,10 +72,7 @@ export const BenefitsSection = () => {
className='relative order-1 h-[400px] overflow-hidden rounded-xl shadow-xl md:order-2'
>
<Image
src={
m('clients.third-section.banner') ||
'/placeholder.svg?height=400&width=600&text=Преимущества+для+клиентов'
}
src='/placeholder.svg?height=400&width=600&text=Преимущества+для+клиентов'
alt='Преимущества для клиентов'
fill
className='object-cover'

View File

@ -61,13 +61,13 @@ export const ServicesOverviewSection = () => {
</p>
</div>
<div
data-aos='flip-up'
data-aos-duration='600'
className='grid gap-3 md:grid-cols-2 md:gap-6 lg:grid-cols-3'
>
{servicesOverview.map(({ description, Icon, contentText, title }) => {
return (
<Card
data-aos='flip-left'
data-aos-duration='600'
key={title}
className='overflow-hidden transition-all hover:shadow-lg'
>

View File

@ -1,7 +1,10 @@
'use client';
import Link from 'next/link';
import { Container } from '@/shared/components/container';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button';
export const CtaSection = () => {
const { t } = useTextController();
@ -14,9 +17,13 @@ export const CtaSection = () => {
{t('home.cta.title')}
</h2>
<p className='mb-8 max-w-2xl'>{t('home.cta.description')}</p>
<h4 className='text-xl font-bold'>
{t('common.buttons.purchaseCardAtGasStations')}
</h4>
<div className='flex flex-col gap-4 sm:flex-row'>
<Link href='#'>
<Button variant='secondary'>
{t('common.buttons.purchaseCardAtGasStations')}
</Button>
</Link>
</div>
</div>
</Container>
</section>

View File

@ -4,8 +4,6 @@ import { Fuel, Mail, MapPin, Phone } from 'lucide-react';
import Link from 'next/link';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { AppStoreButtons } from '@/shared/components/app-store-buttons';
import { Logo } from '@/shared/assets/logo';
export const Footer = () => {
const { t } = useTextController();
@ -14,14 +12,15 @@ export const Footer = () => {
<footer className='bg-gray-900 px-4 py-12 text-white'>
<div className='containe mx-autor'>
<div className='grid grid-cols-1 gap-8 md:gap-4 md:grid-cols-3'>
<div className='flex md:justify-center'>
<div className='flex md:justify-center md:items-center'>
<div>
<div className='mb-4 flex items-center gap-2'>
<Logo/>
<Fuel className='h-6 w-6 text-red-500' />
<span className='text-xl font-bold'>{t('common.name')}</span>
</div>
<p className='mb-4 text-gray-400'>{t('home.hero.description')}</p>
<div className='mb-6 flex space-x-4'>
<a href={t('social.facebook')} target='_blank' className='text-gray-400 hover:text-white'>
<div className='flex space-x-4'>
<a href='#' className='text-gray-400 hover:text-white'>
<svg
className='h-6 w-6'
fill='currentColor'
@ -35,7 +34,7 @@ export const Footer = () => {
/>
</svg>
</a>
<a href={t('social.instagram')} target='_blank' className='text-gray-400 hover:text-white'>
<a href='#' className='text-gray-400 hover:text-white'>
<svg
className='h-6 w-6'
fill='currentColor'
@ -49,11 +48,20 @@ export const Footer = () => {
/>
</svg>
</a>
<a href='#' className='text-gray-400 hover:text-white'>
<svg
className='h-6 w-6'
fill='currentColor'
viewBox='0 0 24 24'
aria-hidden='true'
>
<path d='M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84' />
</svg>
</a>
</div>
<AppStoreButtons className='mt-4' />
</div>
</div>
<div className='flex md:justify-center'>
<div className='flex md:justify-center md:items-center'>
<div>
<h3 className='mb-4 text-lg font-semibold'>
{t('common.footer.contacts')}
@ -74,7 +82,7 @@ export const Footer = () => {
</div>
</div>
</div>
<div className='flex md:justify-center'>
<div className='flex md:justify-center md:items-center'>
<div>
<h3 className='mb-4 text-lg font-semibold'>
{t('common.footer.navigation')}
@ -92,7 +100,7 @@ export const Footer = () => {
</li>
<li>
<Link
href='/clients'
href='/clients/loyalty'
className='text-gray-400 hover:text-white'
>
{t('common.navigation.clients')}

View File

@ -46,7 +46,7 @@ export function MobileNav() {
<Collapsible open={clientsOpen} onOpenChange={setClientsOpen}>
<CollapsibleTrigger className='data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up flex w-full items-center justify-between text-lg font-medium transition-all hover:text-red-600'>
<Link href='/clients' onClick={() => setOpen(false)}><span>{t('common.navigation.clients')}</span></Link>
<span>{t('common.navigation.clients')}</span>
{clientsOpen ? (
<ChevronDown className='h-5 w-5' />
) : (

View File

@ -3,66 +3,35 @@
import { MapPin } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect } from 'react';
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';
import { Button } from '@/shared/shadcn-ui/button';
export const HeroSection = () => {
const { t } = useTextController();
const { m } = useMediaController();
useEffect(() => {
const iframe = document.getElementById('xtraseats-iframe');
iframe?.addEventListener('load', () => {
window.addEventListener('message', function (event) {
// Optional: validate event.origin === "https://app.realpromo.io"
if (iframe && event.data.iframeHeight) {
if (iframe) {
iframe.style.height = event.data.iframeHeight + 10 + 'px';
}
}
});
});
}, []);
return (
<section className='relative'>
<div className='relative h-[550px] w-full overflow-hidden bg-black sm:h-[500px] xl:h-[650px]'>
<div
style={{
width: '100%',
height: '50%',
position: 'absolute',
// top: -60,
// right: 20,
}}
className='sm:!-top-16 sm:-right-40 sm:!h-[70vh] sm:!w-[100vh] md:!-top-10 md:!-right-30 xl:!top-0 xl:!right-0'
>
<Image
src={m('home.hero-section.banner') || '/oriyo_bg.jpeg'}
alt='Oriyo Station'
fill
className='object-cover sm:scale-110 md:scale-120 xl:scale-140'
priority
/>
</div>
<div className='absolute inset-0 flex items-end bg-gradient-to-r from-black/70 to-black/30 px-2 pb-12 sm:items-center sm:pb-0'>
<div className='relative h-[400px] w-full overflow-hidden sm:h-[500px] xl:h-[700px]'>
<Image
src='/oriyo_bg.jpeg'
alt='Gas Station Network'
fill
className='object-cover'
priority
/>
<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 className='max-w-lg space-y-4 text-white'>
<div className='animate-fade animate-duration-[3000ms] animate-ease-in-out'>
<h1 className='text-5xl font-bold tracking-tight md:text-6xl'>
<h1 className='text-4xl font-bold tracking-tight md:text-6xl'>
{t('home.hero.title')}
</h1>
</div>
<p className='text-gray-200 sm:text-lg'>
{t('home.hero.description')}
</p>
<div className='mt-6 flex gap-2 sm:flex-row sm:gap-4'>
<div className='mt-6 flex flex-col gap-2 sm:flex-row sm:gap-4'>
<Link href='#stations'>
<Button className='bg-red-600 hover:bg-red-700'>
{t('common.buttons.findStation')}{' '}

View File

@ -1,118 +0,0 @@
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/shared/lib/utils';
import { ButtonProps, buttonVariants } from '@/shared/shadcn-ui/button';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role='navigation'
aria-label='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
isActive && 'bg-accent pointer-events-none',
className,
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className='h-4 w-4' />
<span>Предыдущая</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Следующая</span>
<ChevronRight className='h-4 w-4' />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@ -1,7 +1,6 @@
'use client';
import { Handshake } from 'lucide-react';
import Link from 'next/link';
import { useMemo } from 'react';
import { Partners } from '@/app/api-utlities/@types';
@ -46,7 +45,7 @@ export const PartnersSection = ({ partners }: PartnersSectionProps) => {
<PartnersSlider
partners={partnersFirstHalf}
autoplaySpeed={3000}
className='sm:mb-12'
className='mb-12'
/>
<PartnersSlider
@ -62,15 +61,9 @@ export const PartnersSection = ({ partners }: PartnersSectionProps) => {
<p className='mx-auto mb-6 max-w-2xl text-gray-600'>
{t('home.partners.becomePartnerText')}
</p>
<Link
href={`mailto:${t('common.partners.email')}?subject=${t('home.partners.becomePartnerTextEmailSubject')}`}
target='_blank'
rel='noopener noreferrer'
>
<Button className='bg-red-600 hover:bg-red-700'>
{t('common.buttons.contactUs')}
</Button>
</Link>
<Button className='bg-red-600 hover:bg-red-700'>
{t('common.buttons.contactUs')}
</Button>
</div>
</div>
</section>

View File

@ -40,11 +40,11 @@ export function PartnersSlider({
allowTouchMove={true}
breakpoints={{
320: {
slidesPerView: 4,
slidesPerView: 2,
spaceBetween: 5,
},
640: {
slidesPerView: 4,
slidesPerView: 3,
spaceBetween: 20,
},
768: {

View File

@ -1,63 +1,69 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Container } from '@/shared/components/container';
import { useIntersectionObserver } from '@/shared/hooks/use-intersection-observer';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import AnimatedCounter from '../shared/components/animated-counter';
const stats = [
{
key: 'stations',
value: 'home.stats.stations.value',
suffix: 'home.stats.stations.suffix',
label: 'home.stats.stations',
},
{
key: 'daily',
value: 'home.stats.daily.value',
suffix: 'home.stats.daily.suffix',
label: 'home.stats.daily',
},
{
key: 'years',
value: 'home.stats.years.value',
suffix: '',
label: 'home.stats.years',
},
{
key: 'mode',
value: 'home.stats.mode.value',
suffix: 'home.stats.mode.suffix',
label: 'home.stats.mode',
},
];
export function StatsSection() {
const { t } = useTextController();
const { sectionRef, isVisible } = useIntersectionObserver<HTMLDivElement>();
const [isVisible, setIsVisible] = useState(false);
const sectionRef = useRef<HTMLDivElement>(null);
const toNumber = (value: string) => Number(t(value));
const { t } = useTextController();
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 (
<section ref={sectionRef} className='bg-red-600 text-white'>
<Container>
<div className='grid grid-cols-2 gap-4 text-center sm:gap-8 md:grid-cols-4'>
{stats.map(({ key, value, suffix, label }) => (
<div key={key} className='space-y-2'>
<h3 className='text-3xl font-bold'>
{isVisible ? (
<AnimatedCounter
end={toNumber(value)}
suffix={t(suffix) || undefined}
/>
) : (
`0${t(suffix) || ''}`
)}
</h3>
<p className='text-sm text-white/80'>{t(label)}</p>
</div>
))}
<div className='space-y-2'>
<h3 className='text-3xl font-bold'>
{isVisible ? <AnimatedCounter end={25} suffix='+' /> : '0+'}
</h3>
<p className='text-sm text-white/80'>{t('home.stats.stations')}</p>
</div>
<div className='space-y-2'>
<h3 className='text-3xl font-bold'>
{isVisible ? <AnimatedCounter end={10000} suffix='+' /> : '0+'}
</h3>
<p className='text-sm text-white/80'>{t('home.stats.daily')}</p>
</div>
<div className='space-y-2'>
<h3 className='text-3xl font-bold'>
{isVisible ? <AnimatedCounter end={15} /> : '0'}
</h3>
<p className='text-sm text-white/80'>{t('home.stats.years')}</p>
</div>
<div className='space-y-2'>
<h3 className='text-3xl font-bold'>
{isVisible ? <AnimatedCounter end={24} suffix='/7' /> : '0/7'}
</h3>
<p className='text-sm text-white/80'>{t('home.stats.mode')}</p>
</div>
</div>
</Container>
</section>

View File

@ -0,0 +1,186 @@
'use client';
import { format, subMonths } from 'date-fns';
import { ru } from 'date-fns/locale';
import { CalendarIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFetchBonusTransactionsQuery } from '@/entities/bonus/api/bonus.api';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button';
import { Calendar } from '@/shared/shadcn-ui/calendar';
import { Label } from '@/shared/shadcn-ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/shared/shadcn-ui/popover';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/shared/shadcn-ui/table';
export const TransactionsTable = () => {
const [startDate, setStartDate] = useState<Date | undefined>(
subMonths(new Date(), 1),
);
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
const { data, refetch } = useFetchBonusTransactionsQuery({
limit: 100,
page: 1,
...(startDate ? { start_date: format(startDate, 'yyyy-MM-dd') } : {}),
...(endDate ? { end_date: format(endDate, 'yyyy-MM-dd') } : {}),
});
// Filter transactions by date range
const filterTransactions = () => {
if (!startDate || !endDate) return;
refetch();
};
const { t } = useTextController();
useEffect(() => {}, [startDate, endDate]);
if (!data) return null;
return (
<div className='space-y-6'>
<div className='flex flex-col items-start justify-between gap-4 md:flex-row md:items-center'>
<h2 className='text-2xl font-bold'>
{t('corporate.transactions.title')}
</h2>
<div className='flex w-full flex-col gap-4 md:w-auto md:flex-row'>
<div className='grid sm:grid-cols-2 gap-2'>
<div className='flex items-center gap-2'>
<Label htmlFor='start-date'>
{t('corporate.transactions.dateFrom')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{startDate
? format(startDate, 'PP', { locale: ru })
: 'Выберите дату'}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={startDate}
onSelect={setStartDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className='flex items-center gap-2'>
<Label htmlFor='end-date'>
{t('corporate.transactions.dateTo')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{endDate
? format(endDate, 'PP', { locale: ru })
: t('corporate.transactions.selectDate')}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={endDate}
onSelect={setEndDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<Button
className='mt-auto bg-red-600 hover:bg-red-700'
onClick={filterTransactions}
>
{t('corporate.transactions.applyButton')}
</Button>
</div>
</div>
<div className='rounded-md border'>
<Table>
<TableHeader>
<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>
</TableHeader>
<TableBody>
{data.transactions.length > 0 ? (
data.transactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell>
{format(new Date(transaction.date_create), 'dd.MM.yyyy')}
</TableCell>
<TableCell>{transaction.station}</TableCell>
<TableCell>{transaction.product_name}</TableCell>
<TableCell className='text-right'>
{transaction.price_real}
</TableCell>
<TableCell className='text-right'>
{transaction.amount} {t('corporate.currency')}
</TableCell>
<TableCell className='text-right font-medium'>
{transaction.sum_real} {t('corporate.currency')}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={6}
className='py-6 text-center text-gray-500'
>
{t('corporate.transactions.noTransactions')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};

View File

@ -1 +0,0 @@
export { TransactionsTable } from "./ui/transactions-table";

View File

@ -1,121 +0,0 @@
import { ChangeEvent } from 'react';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '../../pagination';
interface TablePaginationProps {
currentPage: number;
itemsPerPage: number;
totalPages: number;
totalOperations?: number;
transactionsQuantity: number;
itemsPerPageOptions: number[];
onPageChange: (page: number) => void;
onItemsPerPageChange: (e: ChangeEvent<HTMLSelectElement>) => void;
}
export const TransactionsTablePagination = ({
currentPage,
itemsPerPage,
totalPages,
totalOperations = 0,
itemsPerPageOptions,
transactionsQuantity,
onPageChange,
onItemsPerPageChange,
}: TablePaginationProps) => {
const { t } = useTextController();
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5; // Maximum number of visible pages
const halfVisible = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, currentPage + halfVisible);
if (currentPage <= halfVisible) {
endPage = Math.min(totalPages, maxVisiblePages);
} else if (currentPage + halfVisible >= totalPages) {
startPage = Math.max(1, totalPages - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
return (
<>
{transactionsQuantity > 0 && (
<div className='flex flex-col items-center justify-between gap-4 sm:flex-row'>
<div className='text-sm text-gray-500'>
Показано {transactionsQuantity} из {totalOperations} операций
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => onPageChange(currentPage - 1)}
className={
currentPage === 1 ? 'pointer-events-none opacity-50' : ''
}
/>
</PaginationItem>
{getPageNumbers().map((page, index) => (
<PaginationItem key={index}>
<PaginationLink
isActive={currentPage === page}
onClick={() => onPageChange(page as number)}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => onPageChange(currentPage + 1)}
className={
currentPage === totalPages
? 'pointer-events-none opacity-50'
: ''
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<div className='flex items-center gap-2'>
<span className='text-sm text-gray-500'>
{t('transactions.entries')}
</span>
<select
className='rounded border p-1 text-sm'
value={itemsPerPage}
onChange={onItemsPerPageChange}
>
{itemsPerPageOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
)}
</>
);
};

View File

@ -1,121 +0,0 @@
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { CalendarIcon, Printer, X } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import { Button } from '@/shared/shadcn-ui/button';
import { Calendar } from '@/shared/shadcn-ui/calendar';
import { Label } from '@/shared/shadcn-ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/shared/shadcn-ui/popover';
interface TransactionsTableHeaderProps {
startDate: Date | undefined;
setStartDate: Dispatch<SetStateAction<Date | undefined>>;
endDate: Date | undefined;
setEndDate: Dispatch<SetStateAction<Date | undefined>>;
}
export const TransactionsTableHeader = ({
startDate,
setStartDate,
endDate,
setEndDate,
}: TransactionsTableHeaderProps) => {
const { t } = useTextController();
return (
<div className='flex flex-col items-start justify-between gap-4 md:flex-row md:items-center'>
<div className='flex items-center justify-between max-md:w-full'>
<h2 className='text-2xl font-bold'>
{t('corporate.transactions.title')}
</h2>
<Button
size={'icon'}
variant={'outline'}
onClick={() => window.print()}
className='flex md:hidden'
>
<Printer />
</Button>
</div>
<div className='flex w-full flex-col justify-center gap-4 md:w-auto md:flex-row'>
<Button
size={'icon'}
variant={'outline'}
onClick={() => window.print()}
className='hidden md:flex'
>
<Printer />
</Button>
<div className='grid gap-2 sm:grid-cols-2'>
<div className='flex items-center gap-2'>
<Label htmlFor='start-date'>
{t('corporate.transactions.dateFrom')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{startDate
? format(startDate, 'PP', { locale: ru })
: 'Выберите дату'}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={startDate}
onSelect={setStartDate}
initialFocus
/>
</PopoverContent>
<Button variant='ghost' onClick={() => setStartDate(undefined)}>
<X className='mr-2 h-4 w-4' />
</Button>
</Popover>
</div>
<div className='flex items-center gap-2'>
<Label htmlFor='end-date'>
{t('corporate.transactions.dateTo')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{endDate
? format(endDate, 'PP', { locale: ru })
: t('corporate.transactions.selectDate')}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={endDate}
onSelect={setEndDate}
initialFocus
/>
</PopoverContent>
<Button variant='ghost' onClick={() => setEndDate(undefined)}>
<X className='mr-2 h-4 w-4' />
</Button>
</Popover>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,123 +0,0 @@
import { format } from 'date-fns';
import { ChangeEvent, useEffect, useState } from 'react';
import TableLoadingOverlay from '@/shared/components/table-loading-overlay';
import { useTextController } from '@/shared/language/hooks/use-text-controller';
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from '@/shared/shadcn-ui/table';
import { TransactionsTablePagination } from './table-pagination';
import { TransactionsTableHeader } from './transactions-table-header';
export interface TransactionRequest {
start_date?: string;
end_date?: string;
page: number;
limit: number;
}
export interface TransactionsTableProps<T> {
data: {
transactions: T[];
total_pages: number;
total_records: number;
};
isLoading: boolean;
onChange: (request: TransactionRequest) => void;
renderHeaders: () => React.ReactNode;
renderRow: (transaction: T, index: number) => React.ReactNode;
itemsPerPageOptions?: number[];
}
export const TransactionsTable = <T,>({
data,
isLoading,
onChange,
renderHeaders,
renderRow,
itemsPerPageOptions = [5, 10, 20, 50],
}: TransactionsTableProps<T>) => {
const [startDate, setStartDate] = useState<Date | undefined>(
new Date(new Date().setMonth(new Date().getMonth() - 1)),
);
const [endDate, setEndDate] = useState<Date | undefined>(new Date());
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(itemsPerPageOptions[0]);
const handlePageChange = (page: number) => {
if (page < 1 || page > data.total_pages) return;
setCurrentPage(page);
};
const handleItemsPerPageChange = (e: ChangeEvent<HTMLSelectElement>) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1); // Reset to first page when changing items per page
};
const filterTransactions = () => {
if (!startDate || !endDate) return;
setCurrentPage(1); // Reset to the first page when applying filters
};
const { t } = useTextController();
useEffect(() => {
onChange({
limit: itemsPerPage,
page: currentPage,
...(startDate ? { start_date: format(startDate, 'yyyy-MM-dd') } : {}),
...(endDate ? { end_date: format(endDate, 'yyyy-MM-dd') } : {}),
});
}, [startDate, endDate, itemsPerPage, currentPage]);
if (!data) return null;
return (
<div className='relative space-y-6'>
<TableLoadingOverlay isLoading={isLoading} />
<TransactionsTableHeader
startDate={startDate}
setStartDate={setStartDate}
endDate={endDate}
setEndDate={setEndDate}
/>
<div className='relative rounded-md border'>
<Table>
<TableHeader>{renderHeaders()}</TableHeader>
<TableBody>
{data.transactions.length > 0 ? (
data.transactions.map((transaction, index) =>
renderRow(transaction, index),
)
) : (
<TableRow>
<TableCell
colSpan={6}
className='py-6 text-center text-gray-500'
>
{t('corporate.transactions.no-data')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<TransactionsTablePagination
currentPage={currentPage}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
totalPages={data.total_pages}
transactionsQuantity={data.transactions.length}
totalOperations={data.total_records}
onPageChange={handlePageChange}
onItemsPerPageChange={handleItemsPerPageChange}
/>
</div>
);
};

View File

@ -1,7 +1,6 @@
'use client';
import { Briefcase } from 'lucide-react';
import Link from 'next/link';
import { Jobs } from '@/app/api-utlities/@types/index';
@ -131,11 +130,9 @@ const Vacancy = ({ jobTitle, location, tags }: VacancyProps) => {
})}
</div>
</div>
<Link href={`mailto:${t('home.vacancies.vacancy.applyToMail')}`}>
<Button variant='outline' size='sm'>
{t('common.buttons.apply')}
</Button>
</Link>
<Button variant='outline' size='sm'>
{t('common.buttons.apply')}
</Button>
</div>
</div>
</CardContent>

View File

@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -15,45 +11,23 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"types": [
"node"
],
"types": ["node"],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@/entities/*": [
"./src/entities/*"
],
"@/features/*": [
"./src/features/*"
],
"@/shared/*": [
"./src/shared/*"
],
"@/widgets/*": [
"./src/widgets/*"
],
"@/pages-templates/*": [
"./src/pages-templates/*"
]
"@/*": ["./src/*"],
"@/entities/*": ["./src/entities/*"],
"@/features/*": ["./src/features/*"],
"@/shared/*": ["./src/shared/*"],
"@/widgets/*": ["./src/widgets/*"],
"@/pages-templates/*": ["./src/pages-templates/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
}