Compare commits

...

10 Commits

Author SHA1 Message Date
ahasani 730e2d8ca8 feat: add certificate gallery and certifications section components
- Implemented CertificateGallery component to display certification images in a gallery format.
- Created CertificationsSection component to showcase certifications with titles and descriptions.
- Enhanced Gallery component to support animations and keyboard navigation.
- Introduced Select component for dropdown selection with images.
- Added utility functions for API responses and pagination handling.
- Implemented localization functions for text and content.
- Created a custom hook for detecting clicks outside of a component.
2026-07-19 07:59:16 +03:30
ahasani 478b550c51 update 2026-05-30 14:20:42 +03:30
ahasani 3cab7e3ee5 update 2026-05-30 14:16:02 +03:30
ahasani aae6a4b49b update 2026-05-25 09:12:22 +03:30
zahravaziri 19e53d4b97 fix project slider 2025-07-01 12:40:24 +03:30
zahravaziri 584bc5522e Merge remote-tracking branch 'origin/main' into navbar 2025-07-01 12:30:39 +03:30
zahravaziri 123df850a8 fix slider post and add skeleton 2025-07-01 12:25:50 +03:30
zahravaziri 21c52a84ed change design language modal 2025-06-30 17:06:27 +03:30
ahasani 6c9dd0b731 docker update 2025-06-30 14:31:08 +03:30
zahravaziri 1d5b25354d create modal language selector 2025-06-30 13:45:33 +03:30
78 changed files with 9232 additions and 486 deletions
+5
View File
@@ -2,3 +2,8 @@
node_modules
npm-debug.log
.next/cache
.next
.git
.gitignore
README.md
*.log
+4
View File
@@ -42,3 +42,7 @@ next-env.d.ts
contactMessages.json
public/sitemap.xml
data/
node_modules/
+28 -21
View File
@@ -1,28 +1,35 @@
# Build stage
FROM node:20-alpine AS builder
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN npm config set registry https://hub.megan.ir/npm
RUN npm i -g pnpm
RUN pnpm install --frozen-lockfile
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm config set registry https://hub.megan.ir/npm
RUN npm install -g pnpm
RUN pnpm build
ENV PORT=5000
ENV VERCEL_PROJECT_PRODUCTION_URL=http://194.59.214.243:5000
ENV NEXT_PUBLIC_API_BASE_URL=http://194.59.214.243:5000/api
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ARG PORT=5000
ENV NODE_ENV=production
ENV PORT=5000
ENV VERCEL_PROJECT_PRODUCTION_URL=http://194.59.214.243:5000
ENV NEXT_PUBLIC_API_BASE_URL=http://194.59.214.243:5000/api
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
VOLUME ["/app/contactMessages.json"]
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=$PORT
EXPOSE 5000
CMD ["npm", "start"]
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN touch /app/contactMessages.json && chown nextjs:nodejs /app/contactMessages.json
USER nextjs
EXPOSE ${PORT}
CMD ["node", "server.js"]
+18
View File
@@ -0,0 +1,18 @@
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
PORT: ${PORT:-5000}
image: pasargad-bricks:latest
container_name: pasargad-bricks
restart: unless-stopped
ports:
- "${PORT:-3000}:${PORT:-3000}"
environment:
NODE_ENV: production
env_file:
- .env
volumes:
- ./data/contactMessages.json:/app/data/contactMessages.json
+7
View File
@@ -102,9 +102,16 @@ declare const messages: {
"com_contact_textarea_error": "Message must be at least 10 characters long.",
"com_contact_button_submit": "Submit Message",
"pages_notFound_title": "404 Not Found Page",
"com_modal_language_title": "Please select language",
"com_modal_language_close": "Close",
"pages_notFound_description": "You can either head back to the Homepage or try a search.",
"pages_notFound_button": "Go To Home",
"com_about_certifications_title": "Our Certifications",
"com_about_certifications_title_bold": "& Standards",
"com_about_certifications_description": "We maintain the highest quality standards through internationally recognized certifications.",
"page_about_metadata_title": "About Us",
"page_about_metadata_description": "Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques.",
"page_about_description": "Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques. Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques. Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques. Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques.",
+7
View File
@@ -99,9 +99,16 @@
"com_contact_textarea_error": "Message must be at least 10 characters long.",
"com_contact_button_submit": "Submit Message",
"pages_notFound_title": "404 Not Found Page",
"com_modal_language_title": "Please select language",
"com_modal_language_close": "Close",
"pages_notFound_description": "You can either head back to the Homepage or try a search.",
"pages_notFound_button": "Go To Home",
"com_about_certifications_title": "Our Certifications",
"com_about_certifications_title_bold": "& Standards",
"com_about_certifications_description": "We maintain the highest quality standards through internationally recognized certifications.",
"page_about_metadata_title": "About Us",
"page_about_metadata_description": "Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques.",
"page_about_description": "Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques. Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques. Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques. Learn more about our commitment to producing high-quality bricks using both traditional and modern manufacturing techniques.",
+7
View File
@@ -98,10 +98,17 @@
"com_contact_phone_error": "شماره تماس معتبر نیست (مثلاً: 09123456789)",
"com_contact_textarea_error": "پیام باید حداقل ۱۰ کاراکتر باشد",
"com_contact_button_submit": "ارسال پیام",
"com_modal_language_title": "لطفاً زبان را انتخاب کنید",
"com_modal_language_close": "بستن",
"pages_notFound_title": "صفحه مورد نظر پیدا نشد.",
"pages_notFound_description": "صفحه‌ای که به دنبال آن هستید وجود ندارد یا منتقل شده است. لطفاً آدرس را بررسی کنید یا به صفحه اصلی بازگردید.",
"pages_notFound_button": "بازگشت به صفحه اصلی",
"com_about_certifications_title": "گواهینامه‌ها",
"com_about_certifications_title_bold": "و استانداردهای ما",
"com_about_certifications_description": "ما بالاترین استانداردهای کیفیت را از طریق گواهینامه‌های معتبر بین‌المللی حفظ می‌کنیم.",
"page_about_metadata_title": "درباره ما",
"page_about_metadata_description": "با ما بیشتر آشنا شوید؛ متعهد به تولید آجرهای باکیفیت با بهره‌گیری از فناوری‌های نوین و روش‌های ساخت پیشرفته.",
"page_about_description": "با ما بیشتر آشنا شوید؛ متعهد به تولید آجرهای باکیفیت با بهره‌گیری از فناوری‌های نوین و روش‌های ساخت پیشرفته. با ما بیشتر آشنا شوید؛ متعهد به تولید آجرهای باکیفیت با بهره‌گیری از فناوری‌های نوین و روش‌های ساخت پیشرفته. با ما بیشتر آشنا شوید؛ متعهد به تولید آجرهای باکیفیت با بهره‌گیری از فناوری‌های نوین و روش‌های ساخت پیشرفته. با ما بیشتر آشنا شوید؛ متعهد به تولید آجرهای باکیفیت با بهره‌گیری از فناوری‌های نوین و روش‌های ساخت پیشرفته.",
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
siteUrl: process.env.SITE_URL || 'http://localhost:3000',
siteUrl: process.env.SITE_URL || 'http://localhost:4000',
generateRobotsTxt: true,
changefreq: 'weekly',
priority: 0.7,
+3 -1
View File
@@ -7,6 +7,8 @@ const withNextIntl = createNextIntlPlugin({
},
});
const config: NextConfig = {};
const config: NextConfig = {
output: 'standalone',
};
export default withNextIntl(config);
+46
View File
@@ -25,6 +25,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^16.5.0",
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"eslint-config-prettier": "^10.1.5",
@@ -2588,6 +2589,51 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-cli": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz",
"integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6",
"dotenv": "^17.1.0",
"dotenv-expand": "^12.0.0",
"minimist": "^1.2.6"
},
"bin": {
"dotenv": "cli.js"
}
},
"node_modules/dotenv-cli/node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+12 -12
View File
@@ -1,15 +1,4 @@
{
"name": "pasargad_bricks",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"sitemap": "ts-node scripts/generate-sitemap.ts",
"postbuild": "npm run sitemap"
},
"dependencies": {
"axios": "^1.9.0",
"leaflet": "^1.9.4",
@@ -28,6 +17,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^16.5.0",
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"eslint-config-prettier": "^10.1.5",
@@ -40,5 +30,15 @@
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.3"
}
},
"name": "pasargad_bricks",
"private": true,
"scripts": {
"build": "next build",
"dev": "dotenv -- next dev --turbopack",
"lint": "next lint",
"sitemap": "ts-node scripts/generate-sitemap.ts",
"start": "dotenv -- next start"
},
"version": "0.1.0"
}
+4682
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+2 -2
View File
@@ -3,7 +3,7 @@ User-agent: *
Allow: /
# Host
Host: http://localhost:3000
Host: http://localhost:4000
# Sitemaps
Sitemap: http://localhost:3000/sitemap.xml
Sitemap: http://localhost:4000/sitemap.xml
+3 -2
View File
@@ -1,4 +1,5 @@
import '@/app/globals.css';
import { isRtl } from '@/config';
import { notFound } from 'next/navigation';
import { NextIntlClientProvider } from 'next-intl';
import type { IPageParams } from '@/models/layout.d';
@@ -17,7 +18,7 @@ export default async function ErrorLayout({
}>) {
const paramsData = await params;
const locale = paramsData.locale;
const fontClass = locale === 'fa' ? 'font-fa' : 'font-en';
const fontClass = isRtl(locale) ? 'font-fa' : 'font-en';
let messages;
try {
messages = (await import(`../../../../messages/${locale}.json`)).default;
@@ -26,7 +27,7 @@ export default async function ErrorLayout({
}
return (
<html lang={locale} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<html lang={locale} dir={isRtl(locale) ? 'rtl' : 'ltr'}>
<body className={`min-h-svh w-full overflow-x-hidden ${fontClass}`}>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
+2
View File
@@ -1,6 +1,7 @@
import images from '@/assets/images/images';
import InnerPageBanner from '@/components/layout/innerPages/banner';
import AboutDescription from '@/components/pages/about/AboutDescription';
import CertificationsSection from '@/components/pages/about/CertificationsSection';
import AboutTopInfo from '@/components/pages/about/TopInfo';
import HomeStorySection from '@/components/pages/home/HomeStorySection';
import { IPageParams } from '@/models/layout';
@@ -36,6 +37,7 @@ export default async function AboutUs({
<AboutTopInfo />
<AboutDescription className="mb-8 md:mb-14" locale={locale} />
<HomeStorySection />
<CertificationsSection locale={locale} />
</main>
);
}
+16 -4
View File
@@ -7,6 +7,7 @@ import InnerPageBanner from '@/components/layout/innerPages/banner';
import { IPageParams } from '@/models/layout';
import { getPost } from '@/services/blog';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
interface IParams {
id: string;
@@ -20,7 +21,14 @@ export async function generateMetadata({
const paramsData = await params;
const { id: postId, locale } = paramsData;
const t = await getTranslations({ locale });
const postData = await getPost(postId);
const postData = await getPost(postId).catch(() => null);
if (!postData) {
return {
title: t('page_blog'),
description: t('page_blog_metadata_description'),
};
}
return {
title: postData.title,
@@ -35,11 +43,15 @@ export default async function PostPage({
}) {
const paramsData = await params;
const t = await getTranslations({ locale: paramsData.locale });
const { id: postId, locale } = await params;
const { id: postId, locale } = paramsData;
const routes = routeFactory(t, locale);
const postData = await getPost(postId);
const postData = await getPost(postId).catch((error) => {
if (error?.response?.status === 404) {
notFound();
}
throw error;
});
const breadcrumbItems = [
{
+3 -6
View File
@@ -6,7 +6,7 @@ import { NextIntlClientProvider } from 'next-intl';
import type { IPageParams } from '@/models/layout.d';
import Head from 'next/head';
import { getTranslations } from 'next-intl/server';
import { host } from '@/config';
import { host, isRtl } from '@/config';
export async function generateMetadata({ params }: { params: IPageParams }) {
const { locale } = await params;
@@ -41,7 +41,7 @@ export default async function RootLayout({
const paramsData = await params;
const locale = paramsData.locale;
const fontClass = locale === 'fa' ? 'font-fa' : 'font-en';
const fontClass = isRtl(locale) ? 'font-fa' : 'font-en';
let messages;
try {
@@ -51,12 +51,9 @@ export default async function RootLayout({
}
return (
<html lang={locale} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<html lang={locale} dir={isRtl(locale) ? 'rtl' : 'ltr'}>
<Head>
<meta name="robots" content="index, follow" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:site_name" content="کارخانه آجرپزی پاسارگاد" />
<meta name="author" content="پاسارگاد" />
{/* <link rel="icon" href="/favicon.ico" /> */}
<link
rel="icon"
+2
View File
@@ -1,4 +1,5 @@
import Header from '@/components/layout/header';
import ModalLanguageSelector from '@/components/layout/languageModal';
import AboutTopInfo from '@/components/pages/about/TopInfo';
import HomePageBlog from '@/components/pages/home/HomePageBlog';
import HomeStorySection from '@/components/pages/home/HomeStorySection';
@@ -24,6 +25,7 @@ export default async function Home({ params }: { params: IPageParams }) {
const t = await getTranslations({ locale });
return (
<main className="flex flex-col items-center justify-between">
<ModalLanguageSelector />
<Header />
<AboutTopInfo />
<HomePageProducts locale={locale} />
+19 -18
View File
@@ -1,28 +1,29 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { posts } from '../data';
import { getLocaleFromCookies } from '@/lib/locale';
import { localizeContent, localizeText } from '@/lib/domain';
import { notFound, ok } from '@/lib/api-response';
export async function GET(request: Request, context: any) {
const params = await context.params;
const postId = params.id;
type RouteContext = {
params: {
id: string;
};
};
const cookieStore = await cookies();
const lang = cookieStore.get('locale')?.value || 'en';
export async function GET(request: Request, context: RouteContext) {
const postId = context.params.id;
const locale = await getLocaleFromCookies();
let post = posts.find((post) => post.id === postId);
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
return notFound();
}
if (lang === 'fa') {
post = {
...post,
title: post.fa_title || post.title,
content: post.fa_content || post.content,
};
}
post = {
...post,
title: localizeText(locale, post.title, post.fa_title),
content: localizeContent(locale, post.content, post.fa_content),
};
return NextResponse.json({
data: post,
});
return ok(post);
}
+57 -15
View File
@@ -27,21 +27,21 @@ export const posts: IPostData[] = [
content: [
{
type: 'title',
data: 'روش‌های پایدار برای کاهش ضایعات در تولید صنعتی',
data: 'Sustainable practices to reduce waste in industrial production',
},
{
type: 'content',
data: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.',
data: 'Sustainable manufacturing focuses on minimizing waste, optimizing resource use, and improving process efficiency. By adopting circular design, material recovery, and energy-efficient processes, manufacturers can reduce environmental impact while maintaining productivity and cost-effectiveness.',
},
{
type: 'bullet',
data: [
'روش‌های پایدار برای کاهش ضایعات در تولید صنعتی',
'رباتیک پیشرفته و تحول فرآیندهای صنعتی',
'مزایای اصلی استفاده از رباتیک در تولید',
'استفاده از تحلیل داده برای تولید هوشمندتر',
'کاهش هزینه‌ها با اتوماسیون',
'مزایای راه‌حل‌های سفارشی‌سازی در تولید',
'Sustainable practices to reduce waste in industrial production',
'Advanced robotics transforming industrial processes',
'Key benefits of using robotics in manufacturing',
'Using data analytics for smarter production',
'Reducing costs through automation',
'Advantages of customized manufacturing solutions',
],
},
{
@@ -50,20 +50,41 @@ export const posts: IPostData[] = [
src: '/images/project2.jpeg',
alt: '',
thumbTitle:
'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.',
'Sample image illustrating sustainable manufacturing practices.',
},
},
{
type: 'numberList',
data: [
'روش‌های پایدار برای کاهش ضایعات در تولید صنعتی',
'رباتیک پیشرفته و تحول فرآیندهای صنعتی',
'مزایای اصلی استفاده از رباتیک در تولید',
'استفاده از تحلیل داده برای تولید هوشمندتر',
'کاهش هزینه‌ها با اتوماسیون',
'مزایای راه‌حل‌های سفارشی‌سازی در تولید',
'Sustainable practices to reduce waste in industrial production',
'Advanced robotics transforming industrial processes',
'Key benefits of using robotics in manufacturing',
'Using data analytics for smarter production',
'Reducing costs through automation',
'Advantages of customized manufacturing solutions',
],
},
{
type: 'quote',
data: {
text: 'Sustainability starts with measurable, small operational improvements.',
author: 'Pasargad Bricks Team',
},
},
{
type: 'divider',
},
{
type: 'subtitle',
data: 'Explore related projects',
},
{
type: 'cta',
data: {
label: 'View Projects',
link: '/projects',
},
},
],
fa_content: [
{
@@ -118,6 +139,27 @@ export const posts: IPostData[] = [
'مزایای راه‌حل‌های سفارشی‌سازی در تولید',
],
},
{
type: 'quote',
data: {
text: 'پایداری با بهبودهای کوچک اما مداوم در عملیات آغاز می‌شود.',
author: 'تیم آجر پاسارگاد',
},
},
{
type: 'divider',
},
{
type: 'subtitle',
data: 'مشاهده پروژه‌های مرتبط',
},
{
type: 'cta',
data: {
label: 'مشاهده پروژه‌ها',
link: '/projects',
},
},
],
},
{
+9 -19
View File
@@ -1,33 +1,23 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { generatePosts, IPostData } from './data';
import { generatePosts } from './data';
import { getLocaleFromCookies } from '@/lib/locale';
import { localizeText } from '@/lib/domain';
import { buildPagination, getPaginationParams } from '@/lib/pagination';
import { okPaginated } from '@/lib/api-response';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const lang = (await cookies()).get('lang')?.value || 'fa';
const page = parseInt(searchParams.get('page') || '1', 10);
const perPage = 10;
const locale = await getLocaleFromCookies();
const { page, perPage, start, end } = getPaginationParams(request, 10);
const posts = generatePosts();
const start = (page - 1) * perPage;
const end = start + perPage;
const paginatedPosts = posts.slice(start, end);
const data = paginatedPosts.map((post) => {
return {
id: post.id,
imageSrc: post.imageSrc,
title: post.fa_title || post.title,
title: localizeText(locale, post.title, post.fa_title),
};
});
return NextResponse.json({
data,
pagination: {
page,
perPage,
total: posts.length,
totalPages: Math.ceil(posts.length / perPage),
},
});
return okPaginated(data, buildPagination(page, perPage, posts.length));
}
+6 -6
View File
@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, readFile } from 'fs/promises';
import { mkdir, writeFile, readFile } from 'fs/promises';
import path from 'path';
import { serverError } from '@/lib/api-response';
const filePath = path.resolve(process.cwd(), 'contactMessages.json');
const filePath = path.resolve(process.cwd(), 'data', 'contactMessages.json');
export async function POST(request: NextRequest) {
try {
await mkdir(path.dirname(filePath), { recursive: true });
const body = await request.json();
const { first_name, last_name, phone, email, message } = body;
@@ -43,9 +46,6 @@ export async function POST(request: NextRequest) {
message: 'Your message has been received.',
});
} catch (error) {
return NextResponse.json(
{ error, message: 'مشکلی پیش آمده.' },
{ status: 400 },
);
return serverError('مشکلی پیش آمده.');
}
}
@@ -443,19 +443,19 @@ export const sefidCategoryProducts = [
size: '',
categoryId: '50',
},
{
{
model: 'پلاک گرانیتی 26',
color_code: 'سفید',
size: '5.5 * 26 * 2.5',
categoryId: '50',
},
{
{
model: 'پلاک 7',
color_code: 'سفید',
size: '7 * 33 * 2.5',
categoryId: '50',
},
{
{
model: 'پلاک گرانتی 7',
color_code: 'سفید',
size: '7 * 33 * 2.5',
@@ -473,7 +473,7 @@ export const sefidCategoryProducts = [
size: '7 * 20 * 10 * 2.7',
categoryId: '50',
},
{
{
model: 'پلاک 26',
color_code: 'سفید',
size: '5.5 * 26 * 2.5',
@@ -485,13 +485,13 @@ export const sefidCategoryProducts = [
size: '',
categoryId: '50',
},
{
{
model: 'دور ستون ',
color_code: 'سفید',
size: '',
categoryId: '50',
},
{
{
model: 'دور ستون 50 ',
color_code: 'سفید',
size: '',
@@ -524,7 +524,7 @@ export const meshkiCategoryProducts = [
size: '7 * 33 * 2.5',
categoryId: '51',
},
{
{
model: ' پلاک 10 * 20',
color_code: 'مشکی',
size: '10 * 20 * 2.5',
@@ -542,31 +542,31 @@ export const meshkiCategoryProducts = [
size: '10 * 20 * 5.5',
categoryId: '51',
},
{
{
model: 'ال 5/5',
color_code: 'مشکی',
size: '5.5 * 20 * 10 * 2.7',
categoryId: '51',
},
{
{
model: 'پاستون و سرستون',
color_code: 'مشکی',
size: '',
categoryId: '51',
},
{
{
model: 'دورستون 50',
color_code: 'مشکی',
size: '',
categoryId: '51',
},
{
{
model: 'دورستون 40',
color_code: 'مشکی',
size: '',
categoryId: '51',
},
]
];
export const rusticiCategoryProducts = [
{
@@ -575,7 +575,7 @@ export const rusticiCategoryProducts = [
size: '5.5 * 20 * 4',
categoryId: '52',
},
{
{
model: 'پلاک 7',
color_code: 'سمیرم',
size: '7 * 25 * 2.5',
@@ -594,49 +594,49 @@ export const rusticiCategoryProducts = [
categoryId: '52',
},
{
{
model: 'پلاک 20 * 20',
color_code: 'سمیرم',
size: '20 * 20 * 3',
categoryId: '52',
},
{
{
model: 'پلاک 5/5 ترکدار',
color_code: 'سمیرم',
size: '5.5 * 20 * 5.5',
categoryId: '52',
},
{
{
model: 'پلاک 10 ترکدار',
color_code: 'سمیرم',
size: '10 * 10 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک 10 صاف',
color_code: 'سمیرم',
size: '10 * 10 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک 10 صاف',
color_code: 'سمیرم',
size: '10 * 10 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک 7 ',
color_code: 'سمیرم در 3 رنگ',
size: '7 * 25 * 4',
categoryId: '52',
},
{
{
model: 'پلاک 7 ',
color_code: 'سمیرم در 3 رنگ',
size: '7 * 25 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک 7 رستیک زبر ',
color_code: 'سمیرم',
size: '7 * 20 * 2.5',
@@ -648,32 +648,31 @@ export const rusticiCategoryProducts = [
size: '5.5 * 20 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک 5/5 ',
color_code: 'سمیرم',
size: '5.5 * 20 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک 3 خط ',
color_code: 'سمیرم',
size: '10 * 10 * 2.5',
categoryId: '52',
},
{
{
model: 'پلاک رستیک',
color_code: 'سمیرم',
size: '20 * 40 * 3',
categoryId: '52',
},
{
{
model: 'پلاک 7 سمیرم رستیک ترکدار',
color_code: 'سمیرم',
size: '7 * 25 * 2.5',
categoryId: '52',
},
]
];
export const ghazaghiCategoryProducts = [
{
@@ -684,7 +683,6 @@ export const ghazaghiCategoryProducts = [
},
];
export const ghazaghizardCategoryProducts = [
{
model: 'پلاک قزاقی کامل',
@@ -692,7 +690,7 @@ export const ghazaghizardCategoryProducts = [
size: '10 * 20 * 5.5',
categoryId: '53',
},
{
{
model: 'پلاک نیمه',
color_code: 'زرد',
size: '5.5 * 20 * 5.5',
@@ -704,7 +702,7 @@ export const ghazaghizardCategoryProducts = [
size: '5.5 * 20 * 2.5',
categoryId: '53',
},
{
{
model: 'پلاک 10 قزاقی',
color_code: 'زرد',
size: '10 * 10 * 2.5',
@@ -722,19 +720,19 @@ export const ghazaghizardCategoryProducts = [
size: '10 * 20 * 4',
categoryId: '53',
},
{
{
model: 'پلاک 5/5 ترکدار',
color_code: 'پوست پیازی',
size: '5.5 * 20 * 2.5',
categoryId: '53',
},
{
{
model: 'پلاک 5/5 ترکدار',
color_code: 'زرد',
size: '5.5 * 20 * 2.5',
categoryId: '53',
},
{
{
model: 'ال 5/5',
color_code: 'زرد',
size: '10 * 20 * 5.5',
@@ -755,50 +753,50 @@ export const ghazaghipiyaziCategoryProducts = [
size: '10 * 20 * 5.5',
categoryId: '54',
},
{
{
model: 'پلاک 5/5 قزاقی',
color_code: 'پوست پیازی',
size: '5.5 * 20 * 2.5',
categoryId: '54',
},
{
model: 'پلاک 5/5 ',
color_code: 'قرمز',
size: '5.5 * 20 * 2.5',
categoryId: '54',
},
{
{
model: 'پلاک',
color_code: 'پوست پیازی',
size: '20 * 20 * 3',
categoryId: '54',
},
{
{
model: 'قزاقی کامل',
color_code: 'پوست پیازی',
size: '10 * 20 * 4',
categoryId: '54',
},
{
{
model: 'قزاقی کامل',
color_code: ' قرمز',
size: '10 * 20 * 4',
categoryId: '54',
},
{
{
model: 'قزاقی کامل',
color_code: ' قرمز',
size: '10 * 20 * 5.5',
categoryId: '54',
},
{
{
model: 'نیمه قزاقی',
color_code: ' قرمز',
size: '5.5 * 20 * 5.5',
categoryId: '54',
},
]
];
export const ghazaghighermezCategoryProducts = [
{
@@ -819,22 +817,22 @@ export const ghazaghighermezCategoryProducts = [
size: '20 * 20 * 3',
categoryId: '55',
},
{
{
model: 'پلاک ترکدار',
color_code: 'قرمز',
size: '7 * 25 * 2.5',
categoryId: '55',
},
{
{
model: 'پلاک ترکدار',
color_code: 'قرمز',
size: '5.5 * 20 * 2.5',
categoryId: '55',
},
{
{
model: 'ال لب قاشقی',
color_code: 'قرمز',
size: '10 * 20 * 2.5',
categoryId: '55',
},
]
];
@@ -1,35 +1,29 @@
import { NextResponse, NextRequest } from 'next/server';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { generateProductsForCategory } from './data';
import { productCategoriesData } from '../../data';
import { buildPagination, getPaginationParams } from '@/lib/pagination';
import { notFound, okPaginated } from '@/lib/api-response';
export async function GET(request: NextRequest, context: any) {
const cookieStore = await cookies();
const lang = cookieStore.get('locale')?.value || 'en';
type RouteContext = {
params: {
id: string;
};
};
const params = await context.params;
export async function GET(request: NextRequest, context: RouteContext) {
const params = context.params;
const category = productCategoriesData.find((cat) => cat.id === params.id);
if (!category) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
return notFound();
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const perPage = 5;
const { page, perPage, start, end } = getPaginationParams(request, 5);
const allProducts = generateProductsForCategory(category.id);
const start = (page - 1) * perPage;
const end = start + perPage;
const paginatedProducts = allProducts.slice(start, end);
return NextResponse.json({
data: paginatedProducts,
pagination: {
page,
perPage,
total: allProducts.length,
totalPages: Math.ceil(allProducts.length / perPage),
},
});
return okPaginated(
paginatedProducts,
buildPagination(page, perPage, allProducts.length),
);
}
+20 -15
View File
@@ -1,24 +1,26 @@
'use server';
import { NextResponse, NextRequest } from 'next/server';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
import { productCategoriesData } from '../data';
import { getLocaleFromCookies } from '@/lib/locale';
import { localizeText } from '@/lib/domain';
import { notFound, ok } from '@/lib/api-response';
export async function GET(request: NextRequest, context: any) {
const cookieStore = await cookies();
const lang = cookieStore.get('locale')?.value || 'en';
type RouteContext = {
params: {
id: string;
};
};
const params = (await context).params;
const { id } = params;
export async function GET(request: NextRequest, context: RouteContext) {
const locale = await getLocaleFromCookies();
const { id } = context.params;
let category = productCategoriesData.find((cat) => cat.id === id);
if (!category) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
return notFound();
}
if (lang === 'fa') {
if (locale === 'fa') {
category = {
...category,
title: category.fa_title || category.title,
@@ -28,7 +30,10 @@ export async function GET(request: NextRequest, context: any) {
};
}
return NextResponse.json({
data: category,
});
category = {
...category,
title: localizeText(locale, category.title, category.fa_title),
};
return ok(category);
}
+20 -11
View File
@@ -1,15 +1,20 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { APIProductCategoryEntity } from '@/models/api';
import { productCategoriesData } from './data';
import { productsRelatedByCategory } from './[id]/products/data';
import { getLocaleFromCookies } from '@/lib/locale';
import { localizeText } from '@/lib/domain';
import { ok, serverError } from '@/lib/api-response';
const getTypesCount = (categoryId: string) =>
// @ts-ignore
productsRelatedByCategory[categoryId]?.length || 0;
const setRelatedLocaleData = (productCategory: APIProductCategoryEntity) => ({
title: productCategory.fa_title || productCategory.title,
title: localizeText(
'fa',
productCategory.title,
productCategory.fa_title,
),
priceTypeTitle:
productCategory.fa_priceTypeTitle || productCategory.priceTypeTitle,
bestForTitle: productCategory.fa_bestForTitle || productCategory.bestForTitle,
@@ -20,21 +25,25 @@ export async function GET(request: Request) {
try {
let data = productCategoriesData as APIProductCategoryEntity[];
const cookieStore = await cookies();
const lang = (await cookieStore).get('locale')?.value || 'en';
const locale = await getLocaleFromCookies();
data = productCategoriesData.map((productCategory) => ({
...productCategory,
...(lang === 'fa' ? setRelatedLocaleData(productCategory) : {}),
...(locale === 'fa'
? setRelatedLocaleData(productCategory)
: {
title: localizeText(
locale,
productCategory.title,
productCategory.fa_title,
),
}),
typesCount: getTypesCount(productCategory.id),
}));
return NextResponse.json({ data });
return ok(data);
} catch (error) {
console.error('Error fetching product categories:', error);
return NextResponse.json(
{ error: 'Failed to fetch product categories' },
{ status: 500 },
);
return serverError('Failed to fetch product categories');
}
}
+19 -18
View File
@@ -1,28 +1,29 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { projects } from '../data';
import { getLocaleFromCookies } from '@/lib/locale';
import { localizeContent, localizeText } from '@/lib/domain';
import { notFound, ok } from '@/lib/api-response';
export async function GET(request: Request, context: any) {
const params = await context.params;
const projectId = params.id;
type RouteContext = {
params: {
id: string;
};
};
const cookieStore = await cookies();
const lang = cookieStore.get('locale')?.value || 'en';
export async function GET(request: Request, context: RouteContext) {
const projectId = context.params.id;
const locale = await getLocaleFromCookies();
let project = projects.find((project) => project.id === projectId);
if (!project) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
return notFound();
}
if (lang === 'fa') {
project = {
...project,
title: project.fa_title || project.title,
content: project.fa_content || project.content,
};
}
project = {
...project,
title: localizeText(locale, project.title, project.fa_title),
content: localizeContent(locale, project.content, project.fa_content),
};
return NextResponse.json({
data: project,
});
return ok(project);
}
+12 -8
View File
@@ -1,4 +1,3 @@
import images from '@/assets/images/images';
import { tempContent } from '@/assets/temp/tempContent';
import { IDynamicContent } from '@/models/dynamicContent';
@@ -38,7 +37,7 @@ export const projects = [
{
id: '3',
title: 'Top Benefits of the Robotics in Manufacturing',
fa_title: 'مزایای اصلی استفاده از رباتیک در تولید',
fa_title: 'مزایای اصلی استفاده از در تولید',
imageSrc: `/images/project3.jpeg`,
...tempContent,
},
@@ -63,13 +62,18 @@ export const projects = [
imageSrc: `/images/project1.jpeg`,
...tempContent,
},
{
id: '7',
title: 'The Advantages of Customized Manufacturing Solutions',
fa_title: 'پروژه‌ی تست',
imageSrc: `/images/project1.jpeg`,
...tempContent,
},
] as IProjectData[];
export function generateProjects(): IProjectData[] {
return [...projects, ...projects, ...projects, ...projects, ...projects].map(
(e, i) => ({
...e,
id: String(i + 1),
}),
);
return [...projects].map((e, i) => ({
...e,
id: String(i + 1),
}));
}
+9 -26
View File
@@ -1,47 +1,30 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import {
generateProjects,
IProjectData,
IProjectThumbResponseData,
} from './data';
import { getLocaleFromCookies } from '@/lib/locale';
import { localizeText } from '@/lib/domain';
import { buildPagination, getPaginationParams } from '@/lib/pagination';
import { okPaginated } from '@/lib/api-response';
export async function GET(request: Request) {
let projects = generateProjects();
let data = projects as IProjectThumbResponseData[];
const cookieStore = await cookies();
const lang = (await cookieStore).get('lang')?.value || 'fa';
// Pagination logic
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const perPage = 6;
const start = (page - 1) * perPage;
const end = start + perPage;
const locale = await getLocaleFromCookies();
const { page, perPage, start, end } = getPaginationParams(request, 6);
const paginatedProjects = projects.slice(start, end);
const setRelatedLocaleData = (project: IProjectData) => ({
title: project.fa_title || project.title,
title: localizeText(locale, project.title, project.fa_title),
});
data = paginatedProjects.map((project) => ({
id: project.id,
imageSrc: project.imageSrc,
...(lang === 'fa'
? setRelatedLocaleData(project)
: { title: project.title }),
...setRelatedLocaleData(project),
}));
return NextResponse.json({
data,
pagination: {
page,
perPage,
total: projects.length,
totalPages: Math.ceil(projects.length / perPage),
},
});
return okPaginated(data, buildPagination(page, perPage, projects.length));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

+11
View File
@@ -13,6 +13,12 @@ import product1 from './product1.png';
import notFound from './404.png';
import langEn from './lang_en.png';
import langFa from './lang_fa.png';
import en from './en_square.png';
import fa from './ir_square.png';
import certificateIso9001 from './certificates/iso9001.jpg';
import certificateIso14001 from './certificates/iso14001.jpg';
import certificateStandard from './certificates/standard.jpg';
import certificateLia from './certificates/lia.jpg';
export default {
homeBanner,
logo,
@@ -27,6 +33,11 @@ export default {
homeProductBack,
whatWeDoImage,
product1,
fa,en,
langEn,
langFa,
certificateIso9001,
certificateIso14001,
certificateStandard,
certificateLia,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+42
View File
@@ -39,6 +39,27 @@ export const tempContent = {
'مزایای راه‌حل‌های سفارشی‌سازی در تولید',
],
},
{
type: 'quote',
data: {
text: 'پایداری با بهینه‌سازی مداوم فرآیندها ممکن می‌شود.',
author: 'تیم پاسارگاد',
},
},
{
type: 'divider',
},
{
type: 'subtitle',
data: 'پروژه‌های بیشتر',
},
{
type: 'cta',
data: {
label: 'رفتن به پروژه‌ها',
link: '/projects',
},
},
],
fa_content: [
{
@@ -93,5 +114,26 @@ export const tempContent = {
'مزایای راه‌حل‌های سفارشی‌سازی در تولید',
],
},
{
type: 'quote',
data: {
text: 'پایداری با بهینه‌سازی مداوم فرآیندها ممکن می‌شود.',
author: 'تیم پاسارگاد',
},
},
{
type: 'divider',
},
{
type: 'subtitle',
data: 'پروژه‌های بیشتر',
},
{
type: 'cta',
data: {
label: 'رفتن به پروژه‌ها',
link: '/projects',
},
},
],
};
@@ -0,0 +1,53 @@
'use client';
import images from '@/assets/images/images';
import Gallery from '@/components/uikit/gallery';
import { GalleryItem } from '@/components/uikit/gallery/gallery';
import { isRtl } from '@/config';
import { TLocales } from '@/models/layout';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import { useState } from 'react';
const certificates: GalleryItem[] = [
{ src: images.certificateIso9001, alt: 'ISO 9001' },
{ src: images.certificateIso14001, alt: 'ISO 14001' },
{ src: images.certificateStandard, alt: 'Standard' },
{ src: images.certificateLia, alt: 'LIA' },
];
export default function CertificateGallery() {
const params = useParams();
const locale = params.locale as TLocales;
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
return (
<>
<div className="flex flex-wrap items-center justify-center gap-3">
{certificates.map((cert, i) => (
<button
key={cert.alt}
onClick={() => setSelectedIndex(i)}
className="group relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-white/20 transition-transform hover:scale-105"
>
<Image
src={cert.src}
alt={cert.alt}
fill
className="object-contain p-1"
/>
</button>
))}
</div>
{selectedIndex !== null && (
<Gallery
items={certificates}
initialIndex={selectedIndex}
onClose={() => setSelectedIndex(null)}
rtl={isRtl(locale)}
/>
)}
</>
);
}
@@ -1,11 +1,13 @@
import {
IContentCTA,
IContentData,
IContentDataLink,
IContentImage,
IContentQuote,
IDynamicContent,
} from '@/models/dynamicContent';
function ImageContent(data: IContentImage) {
function imageContent(data: IContentImage) {
return (
<div className="flex flex-col items-center gap-3">
<img
@@ -26,12 +28,23 @@ function titleContent(data: string) {
return <h2 className="text-2xl font-bold text-gray-500">{data}</h2>;
}
function fullContent(data: IContentData) {
const splittedData = data.data?.split('\n') || [];
return splittedData.map((e) => {
function subtitleContent(data: string) {
return <h3 className="text-xl font-semibold text-gray-500">{data}</h3>;
}
function fullContent(data: IContentData | string) {
const normalizedData =
typeof data === 'string'
? ({
data,
} as IContentData)
: data;
const splittedData = normalizedData.data?.split('\n') || [];
return splittedData.map((e, index) => {
return (
<p className="indent-5 text-base text-gray-400">
{generateLinkedContent(e, data.links)}
<p key={index} className="indent-5 text-base text-gray-400">
{generateLinkedContent(e, normalizedData.links)}
</p>
);
});
@@ -80,6 +93,34 @@ function listContent(data: string[], type: 'bullet' | 'numberList') {
);
}
function quoteContent(data: IContentQuote) {
return (
<blockquote className="rounded-xl border-l-4 border-primary bg-gray-100 px-4 py-3 text-gray-500">
<p className="text-base italic">{data.text}</p>
{data.author ? (
<cite className="mt-2 block text-sm text-gray-400"> {data.author}</cite>
) : null}
</blockquote>
);
}
function ctaContent(data: IContentCTA) {
return (
<a
href={data.link}
target={data.target || '_self'}
rel={data.target === '_blank' ? 'noopener noreferrer' : undefined}
className="bg-primary inline-flex w-fit rounded-lg px-4 py-2 text-sm text-white"
>
{data.label}
</a>
);
}
function dividerContent() {
return <hr className="my-2 border-gray-200" />;
}
export default function ContentGenerator({
data,
className,
@@ -89,17 +130,27 @@ export default function ContentGenerator({
}) {
return (
<div className={`flex flex-col gap-3 md:gap-6 ${className}`}>
{data.map((e) => {
{data.map((e, index) => {
switch (e.type) {
case 'title':
return titleContent(e.data as string);
return <div key={index}>{titleContent(e.data)}</div>;
case 'subtitle':
return <div key={index}>{subtitleContent(e.data)}</div>;
case 'content':
return <div>{fullContent(e.data as IContentData)}</div>;
return <div key={index}>{fullContent(e.data)}</div>;
case 'image':
return ImageContent(e.data as IContentImage);
return <div key={index}>{imageContent(e.data)}</div>;
case 'bullet':
case 'numberList':
return listContent(e.data as string[], e.type);
return <div key={index}>{listContent(e.data, e.type)}</div>;
case 'quote':
return <div key={index}>{quoteContent(e.data)}</div>;
case 'cta':
return <div key={index}>{ctaContent(e.data)}</div>;
case 'divider':
return <div key={index}>{dividerContent()}</div>;
default:
return null;
}
})}
</div>
+26 -9
View File
@@ -1,16 +1,18 @@
'use client';
import images from '@/assets/images/images';
import Image from 'next/image';
import Link from 'next/link';
import FooterMenuBuilder from './FooterMenuBuilder';
import FooterMenuBuilderWrapper from './FooterMenuBuilderWrapper';
import FooterInfo from './FooterInfo';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { appConstants } from '@/assets/constants';
import { getQuickLinks } from '@/assets/constants/footerMenu/quickLinks.const';
import { getWhoAreWeLinks } from '@/assets/constants/footerMenu/whoAreWe.const';
import images from '@/assets/images/images';
import { Icon, IconName } from '@/components/uikit/icons';
import { TLocales } from '@/models/layout';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import FooterInfo from './FooterInfo';
import FooterMenuBuilder from './FooterMenuBuilder';
import FooterMenuBuilderWrapper from './FooterMenuBuilderWrapper';
export default function Footer() {
const t = useTranslations();
@@ -41,7 +43,7 @@ export default function Footer() {
</h5>
</div>
<div className="flex items-start justify-between gap-8 border-y border-gray-300/50 py-14 max-lg:flex-col-reverse">
<FooterInfo withLogo className="lg:max-w-[200px]" />
<FooterInfo withLogo className="max-lg:mt-3 lg:max-w-[200px]" />
<div className="grid w-full grid-cols-2 gap-5 md:grid-cols-3 md:gap-10 lg:max-w-3/5">
<FooterMenuBuilder
title={t('footer_links_quick_links')}
@@ -74,6 +76,21 @@ export default function Footer() {
{t('footer_links_address')}
</span>
</div>
<div className="flex items-center gap-4 max-lg:justify-center">
{appConstants.socials.map((social) => (
<Link
href={social.link}
key={social.title}
className="text-white lg:flex-1"
>
<Icon
name={social.icon as IconName}
className="text-primary"
/>
</Link>
))}
</div>
</FooterMenuBuilderWrapper>
</div>
</div>
+5 -15
View File
@@ -1,11 +1,10 @@
'use client';
import { appConstants } from '@/assets/constants';
import images from '@/assets/images/images';
import { Icon, IconName } from '@/components/uikit/icons';
import CertificateGallery from '@/components/layout/certificateGallery';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import LanguageSwitch from '../languageSwitch';
export default function FooterInfo({
@@ -32,18 +31,9 @@ export default function FooterInfo({
)}
<p className="text-sm tracking-normal">{t('footer_slogan')}</p>
<div className="flex items-center gap-4 max-lg:justify-center">
{appConstants.socials.map((social) => (
<Link
href={social.link}
key={social.title}
className="text-white lg:flex-1"
>
<Icon name={social.icon as IconName} className="text-primary" />
</Link>
))}
</div>
<LanguageSwitch />
{withLogo && <CertificateGallery />}
{!withLogo && <LanguageSwitch />}
</div>
);
}
@@ -1,14 +1,14 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import routeFactory from '@/assets/constants/routeFactory';
import images from '@/assets/images/images';
import { usePathname, useParams } from 'next/navigation';
import { Icon } from '@/components/uikit/icons';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import FooterInfo from '../footer/FooterInfo';
import routeFactory from '@/assets/constants/routeFactory';
interface Props {
isOpen: boolean;
@@ -66,7 +66,9 @@ export default function HamburgerMenu({ isOpen, onClose }: Props) {
if (!isOpen) return null;
return (
<div className="fixed inset-x-0 top-0 z-50 flex h-screen">
<div
className={`fixed inset-x-0 start-0 top-0 z-50 flex h-screen justify-end`}
>
<div className="absolute inset-0 bg-black/60" />
<div
@@ -0,0 +1,68 @@
'use client';
import { useEffect, useState } from 'react';
import { TLocales } from '@/models/layout';
import images from '@/assets/images/images';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
const languages = [
{
title: 'EN',
image: images.en,
locale: 'en',
},
{
title: 'FA',
image: images.fa,
locale: 'fa',
},
];
export default function ModalLanguageSelector() {
const [show, setShow] = useState(false);
const t = useTranslations();
useEffect(() => {
const selected = localStorage.getItem('selectedLocale');
const cookieLocale = document.cookie
.split('; ')
.find((item) => item.startsWith('locale='))
?.split('=')[1];
if (!selected && !cookieLocale) setShow(true);
}, []);
const changeLocale = (locale: TLocales) => {
localStorage.setItem('selectedLocale', locale);
document.cookie = `locale=${locale}; path=/; max-age=31536000; samesite=lax`;
const path = window.location.pathname.replace(/^\/(en|fa)/, '');
window.location.pathname = `/${locale}${path}`;
};
const closeModal = () => setShow(false);
if (!show) return null;
return (
<div
className="fixed inset-0 z-100 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={closeModal}
>
<div className="relative h-auto w-100 rounded-3xl bg-white p-6 shadow-2xl">
<div className="flex justify-around gap-4">
{languages.map((lang) => (
<div
key={lang.locale}
onClick={() => changeLocale(lang.locale as TLocales)}
className="flex w-32 flex-col items-center justify-between gap-2 rounded-xl border border-gray-300 bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700 shadow-sm transition-all hover:border-blue-400 hover:bg-blue-100"
>
<span className="text-2xl">
<Image src={lang.image} alt={lang.title} className="w-100" />
</span>
{lang.title}
</div>
))}
</div>
</div>
</div>
);
}
+16 -30
View File
@@ -2,28 +2,22 @@
import images from '@/assets/images/images';
import { TLocales } from '@/models/layout';
import Image from 'next/image';
import Select from '@/components/uikit/select';
import { usePathname } from 'next/navigation';
const languages = [
{ value: 'en', label: 'English', image: images.en },
{ value: 'fa', label: 'فارسی', image: images.fa },
];
export default function LanguageSwitch() {
const pathname = usePathname();
const currentLocale = pathname.split('/')[1] as TLocales;
const languages = [
{
title: 'EN',
image: images.langEn,
locale: 'en',
},
{
title: 'FA',
image: images.langFa,
locale: 'fa',
},
];
const changeLocale = (locale: TLocales) => {
const currentLocale = pathname.split('/')[1];
const changeLocale = (locale: string) => {
if (currentLocale === locale) return;
document.cookie = `locale=${locale}; path=/; max-age=31536000; samesite=lax`;
let newPath = pathname.replace(/^\/(en|fa)/, '');
if (newPath === '/') newPath = '';
@@ -31,19 +25,11 @@ export default function LanguageSwitch() {
};
return (
<div className="flex items-center justify-center gap-3">
{languages.map((language) => (
<div
className="flex cursor-pointer flex-col items-center justify-center gap-2 px-1"
onClick={() => changeLocale(language.locale as TLocales)}
key={language.title}
>
<Image src={language.image} alt={language.title} />
<span className="text-xs font-normal text-white">
{language.title}
</span>
</div>
))}
</div>
<Select
options={languages}
value={currentLocale}
onChange={changeLocale}
size="small"
/>
);
}
+10 -4
View File
@@ -1,14 +1,15 @@
'use client';
import routeFactory from '@/assets/constants/routeFactory';
import images from '@/assets/images/images';
import { Icon } from '@/components/uikit/icons';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname, useParams } from 'next/navigation';
import { useParams, usePathname } from 'next/navigation';
import { useState } from 'react';
import HamburgerMenu from '../hamburgerMenu';
import { useTranslations } from 'next-intl';
import routeFactory from '@/assets/constants/routeFactory';
import LanguageSwitch from '../languageSwitch';
export default function Navbar() {
const [open, setOpen] = useState<boolean>(false);
@@ -50,7 +51,7 @@ export default function Navbar() {
/>
</Link>
<ul className="hidden space-x-6 text-sm lg:flex lg:text-base">
<ul className="hidden items-center space-x-6 text-sm lg:flex lg:text-base">
{links.map(({ route, title }) => (
<li key={route()}>
<Link
@@ -61,12 +62,17 @@ export default function Navbar() {
</Link>
</li>
))}
<div className="h-4 w-px bg-white opacity-35"></div>
<li>
<a className="flex items-center gap-2" href="tel:+098123456789">
<Icon name="phone" className="text-primary" />
<p>+098 123456789</p>
</a>
</li>
<li>
<LanguageSwitch />
</li>
</ul>
{!open && (
@@ -0,0 +1,21 @@
'use client';
interface Props {
count?: number;
}
export default function SliderThumbSkeleton({ count = 3 }: Props) {
return (
<div className="flex gap-4 overflow-hidden">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex w-full animate-pulse flex-col gap-4 overflow-hidden rounded-xl bg-white"
>
<div className="aspect-[1.09] w-full bg-gray-200" />
<div className="mx-auto mb-4 h-4 w-3/4 rounded bg-gray-200" />
</div>
))}
</div>
);
}
@@ -0,0 +1,78 @@
'use client';
import images from '@/assets/images/images';
import SectionTitle from '@/components/layout/sectionTitle';
import Gallery from '@/components/uikit/gallery';
import { GalleryItem } from '@/components/uikit/gallery/gallery';
import { isRtl } from '@/config';
import { TLocales } from '@/models/layout';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { useState } from 'react';
const certifications: GalleryItem[] = [
{ src: images.certificateIso9001, alt: 'ISO 9001', title: 'ISO 9001' },
{ src: images.certificateIso14001, alt: 'ISO 14001', title: 'ISO 14001' },
{ src: images.certificateStandard, alt: 'Standard', title: 'Standard' },
{ src: images.certificateLia, alt: 'LIA', title: 'LIA' },
];
export default function CertificationsSection({
locale,
className = '',
}: {
locale: TLocales;
className?: string;
}) {
const t = useTranslations();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
return (
<div className={`w-full py-20 ${className}`}>
<div className="container mx-auto">
<SectionTitle
title={t('com_about_certifications_title')}
description={t('com_about_certifications_title_bold')}
locale={locale}
/>
<p className="mt-4 max-w-2xl text-base font-light text-gray-300">
{t('com_about_certifications_description')}
</p>
<div className="mt-10 grid grid-cols-2 gap-6 sm:grid-cols-4">
{certifications.map((cert, i) => (
<button
key={cert.alt}
onClick={() => setSelectedIndex(i)}
className="group cursor-pointer"
>
<div className="relative aspect-[3/4] overflow-hidden rounded-xl border border-gray-100 bg-white transition-all group-hover:shadow-lg">
<Image
src={cert.src}
alt={cert.alt}
fill
className="object-contain p-4"
/>
</div>
{cert.title && (
<p className="mt-3 text-center text-sm font-medium text-gray-500">
{cert.title}
</p>
)}
</button>
))}
</div>
</div>
{selectedIndex !== null && (
<Gallery
items={certifications}
initialIndex={selectedIndex}
onClose={() => setSelectedIndex(null)}
rtl={isRtl(locale)}
/>
)}
</div>
);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import Button from '@/components/uikit/button';
import { TLocales } from '@/models/layout';
import Image from 'next/image';
import Link from 'next/link';
import { useTranslations } from 'use-intl';
import { useTranslations } from 'next-intl';
interface Props {
post: IPostThumbResponseData;
@@ -17,7 +17,7 @@ const PostThumb = ({ post, locale }: Props) => {
return (
<div
key={post.id}
className="group flex flex-col gap-3 overflow-hidden bg-white md:gap-5"
className="group flex w-full flex-col gap-3 overflow-hidden bg-white md:gap-5"
>
<div className="relative aspect-[1.09] w-full overflow-hidden rounded-2xl">
<Image
@@ -1,14 +1,58 @@
// 'use client';
// import { Swiper, SwiperSlide } from 'swiper/react';
// import { FreeMode } from 'swiper/modules';
// import 'swiper/css';
// import 'swiper/css/free-mode';
// import { IPostThumbResponseData } from '@/app/api/blog/data';
// import { useTranslations } from 'use-intl';
// import routeFactory from '@/assets/constants/routeFactory';
// import { TLocales } from '@/models/layout';
// import PostThumb from './PostThumb';
// interface Props {
// posts: IPostThumbResponseData[];
// locale: TLocales;
// }
// export default function PostsGridViewSlider({ posts, locale }: Props) {
// const t = useTranslations();
// const routes = routeFactory(t, locale);
// return (
// <div className="w-full">
// <Swiper
// modules={[FreeMode]}
// freeMode
// spaceBetween={16}
// breakpoints={{
// 0: { slidesPerView: 1.1 },
// 768: { slidesPerView: Math.min(posts.length, 3), spaceBetween: 24 },
// }}
// className="w-full"
// >
// {posts.map((post) => (
// <SwiperSlide key={post.id} className="w-auto">
// <PostThumb post={post} locale={locale} />
// </SwiperSlide>
// ))}
// </Swiper>
// </div>
// );
// }
'use client';
import { useEffect, useState } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { FreeMode } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/free-mode';
import { IPostThumbResponseData } from '@/app/api/blog/data';
import { useTranslations } from 'use-intl';
import routeFactory from '@/assets/constants/routeFactory';
import { TLocales } from '@/models/layout';
import PostThumb from './PostThumb';
import { IPostThumbResponseData } from '@/app/api/blog/data';
import { TLocales } from '@/models/layout';
import { useTranslations } from 'next-intl';
import routeFactory from '@/assets/constants/routeFactory';
import SliderThumbSkeleton from '@/components/layout/sliderSkeleton';
interface Props {
posts: IPostThumbResponseData[];
@@ -16,8 +60,18 @@ interface Props {
}
export default function PostsGridViewSlider({ posts, locale }: Props) {
const [hasMounted, setHasMounted] = useState(false);
const t = useTranslations();
const routes = routeFactory(t, locale);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return <SliderThumbSkeleton count={3} />;
}
return (
<div className="w-full">
<Swiper
@@ -26,12 +80,12 @@ export default function PostsGridViewSlider({ posts, locale }: Props) {
spaceBetween={16}
breakpoints={{
0: { slidesPerView: 1.1 },
768: { slidesPerView: posts.length, spaceBetween: 24 },
768: { slidesPerView: Math.min(posts.length, 3), spaceBetween: 24 },
}}
className="w-full"
>
{posts.map((post) => (
<SwiperSlide key={post.id}>
<SwiperSlide key={post.id} className="w-auto">
<PostThumb post={post} locale={locale} />
</SwiperSlide>
))}
@@ -57,6 +57,8 @@ const ContactForm = ({ className = '' }) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setContact(formData).finally(() => {
setLoading(false);
+10 -7
View File
@@ -28,13 +28,16 @@ export default async function HomePageBlog({ locale, className }: Props) {
<h4 className="text-base font-extralight tracking-normal max-lg:hidden">
{t('com_home_blog_description')}
</h4>
<Button
link={routes.blog.route()}
endIcon="showMore"
className="mt-5"
>
{t('com_home_blog_show_more')}
</Button>
<div className="flex w-full justify-end">
<Button
link={routes.blog.route()}
endIcon="showMore"
className="mt-5"
>
{t('com_home_blog_show_more')}
</Button>
</div>
</div>
</div>
+9 -7
View File
@@ -29,13 +29,15 @@ export default async function HomePageProjects({ locale }: Props) {
<h4 className="text-base font-extralight tracking-normal max-lg:hidden">
{t('com_home_projects_description')}
</h4>
<Button
link={routes.projects.route()}
endIcon="showMore"
className="mt-5"
>
{t('com_home_projects_show_more')}
</Button>
<div className="flex w-full justify-end">
<Button
link={routes.projects.route()}
endIcon="showMore"
className="mt-5"
>
{t('com_home_projects_show_more')}
</Button>
</div>
</div>
</div>
<div className="mt-10">
@@ -1,3 +1,7 @@
'use client';
import { useEffect } from 'react';
import { useState } from 'react';
import type { IProductCardProps } from './productCard.d';
export default function ProductCard({
@@ -5,17 +9,60 @@ export default function ProductCard({
imageSrc,
size,
}: IProductCardProps) {
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
useEffect(() => {
if (!isPreviewOpen) return;
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsPreviewOpen(false);
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isPreviewOpen]);
return (
<div className="overflow-hidden rounded-xl bg-gray-200 p-4">
<div className="aspect-square w-full">
<img
src={imageSrc}
alt={model}
className="h-full w-full object-cover"
/>
<>
<div
className="cursor-pointer overflow-hidden rounded-xl bg-gray-200 p-4"
onClick={() => setIsPreviewOpen(true)}
>
<div className="aspect-square w-full">
<img
src={imageSrc}
alt={model}
className="h-full w-full object-cover"
/>
</div>
<h3 className="mt-4 mb-10 text-xl font-bold text-gray-500">{model}</h3>
<span className="text-base font-normal text-gray-300">{size}</span>
</div>
<h3 className="mt-4 mb-10 text-xl font-bold text-gray-500">{model}</h3>
<span className="text-base font-normal text-gray-300">{size}</span>
</div>
{isPreviewOpen ? (
<div
className="fixed inset-0 z-100 flex items-center justify-center bg-black/70 p-4 transition-opacity duration-200"
onClick={() => setIsPreviewOpen(false)}
>
<div
className="relative max-h-[90vh] w-full max-w-3xl scale-100 rounded-2xl bg-white p-4 opacity-100 transition-all duration-200 ease-out"
onClick={(event) => event.stopPropagation()}
>
<button
className="absolute top-3 right-3 cursor-pointer rounded-full bg-black/70 px-3 py-1 text-sm text-white"
onClick={() => setIsPreviewOpen(false)}
>
</button>
<img
src={imageSrc}
alt={model}
className="max-h-[80vh] w-full rounded-xl object-contain"
/>
</div>
</div>
) : null}
</>
);
}
@@ -1,3 +1,5 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import Button from '@/components/uikit/button';
@@ -11,12 +13,13 @@ interface Props {
locale: TLocales;
}
const ProjectThumb = ({ project, locale }: Props) => {
export default function ProjectThumb({ project, locale }: Props) {
const t = useTranslations();
const routes = routeFactory(t, locale);
return (
<div className="group flex flex-col gap-3 overflow-hidden bg-white md:gap-5">
<div className="relative aspect-[1.4] w-full overflow-hidden rounded-2xl">
<div className="relative aspect-[1.09] w-full overflow-hidden rounded-2xl">
<Image
src={project.imageSrc}
alt={project.title}
@@ -24,7 +27,7 @@ const ProjectThumb = ({ project, locale }: Props) => {
className="h-auto w-full object-cover"
/>
<div className="invisible absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 backdrop-blur-xs transition-all group-hover:visible group-hover:opacity-100">
<Button endIcon="showMore" link={routes.project.route(project.id)}>
<Button link={routes.project.route(project.id)} endIcon="showMore">
{t('com_home_projects_details_CTA')}
</Button>
</div>
@@ -38,6 +41,4 @@ const ProjectThumb = ({ project, locale }: Props) => {
</Link>
</div>
);
};
export default ProjectThumb;
}
@@ -1,22 +1,36 @@
'use client';
import { useEffect, useState } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { FreeMode } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/free-mode';
import ProjectThumb from './ProjectThumb';
import { TLocales } from '@/models/layout';
import { IProjectThumbResponseData } from '@/app/api/projects/data';
import { TLocales } from '@/models/layout';
import SliderThumbSkeleton from '@/components/layout/sliderSkeleton';
interface Props {
projects: IProjectThumbResponseData[];
locale: TLocales;
className?: string;
}
export default function ProjectsGridSlider({
projects,
locale,
className = '',
}: {
projects: IProjectThumbResponseData[];
locale: TLocales;
className?: string;
}) {
}: Props) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) return <SliderThumbSkeleton count={3} />;
return (
<div className={className}>
<Swiper
@@ -25,12 +39,15 @@ export default function ProjectsGridSlider({
spaceBetween={16}
breakpoints={{
0: { slidesPerView: 1.1 },
768: { slidesPerView: projects.length, spaceBetween: 24 },
768: {
slidesPerView: Math.min(projects.length, 3),
spaceBetween: 24,
},
}}
className="w-full"
>
{projects.map((project) => (
<SwiperSlide key={project.id}>
<SwiperSlide key={project.id} className="w-auto">
<ProjectThumb project={project} locale={locale} />
</SwiperSlide>
))}
+14
View File
@@ -0,0 +1,14 @@
import { StaticImageData } from 'next/image';
export interface GalleryItem {
src: string | StaticImageData;
alt: string;
title?: string;
}
export interface GalleryProps {
items: GalleryItem[];
initialIndex?: number;
onClose: () => void;
rtl?: boolean;
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import { GalleryProps } from './gallery';
export default function Gallery({
items,
initialIndex = 0,
onClose,
rtl = false,
}: GalleryProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [direction, setDirection] = useState<'next' | 'prev'>('next');
const [isAnimating, setIsAnimating] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>(null);
const animate = useCallback(
(dir: 'next' | 'prev', newIndex: number) => {
if (isAnimating) return;
setDirection(dir);
setIsAnimating(true);
setCurrentIndex(newIndex);
timeoutRef.current = setTimeout(() => setIsAnimating(false), 300);
},
[isAnimating],
);
const goNext = useCallback(() => {
animate('next', (currentIndex + 1) % items.length);
}, [currentIndex, items.length, animate]);
const goPrev = useCallback(() => {
animate('prev', (currentIndex - 1 + items.length) % items.length);
}, [currentIndex, items.length, animate]);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (rtl) {
if (e.key === 'ArrowLeft') goNext();
else if (e.key === 'ArrowRight') goPrev();
} else {
if (e.key === 'ArrowRight') goNext();
else if (e.key === 'ArrowLeft') goPrev();
}
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [goNext, goPrev, onClose, rtl]);
const current = items[currentIndex];
const slideInClass =
direction === 'next'
? 'animate-[slideInRight_300ms_ease-out]'
: 'animate-[slideInLeft_300ms_ease-out]';
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={onClose}
>
<div
className="relative flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={goPrev}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/20 text-white transition-colors hover:bg-white/40"
>
<svg
className={`h-6 w-6 ${rtl ? 'scale-x-[-1]' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
<div className="relative max-h-[90vh] max-w-[90vw] overflow-hidden">
<button
onClick={onClose}
className="absolute top-0 right-0 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white text-gray-800 shadow-lg transition-colors hover:bg-gray-200"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{current.title && (
<div className="absolute bottom-4 left-4 z-10 rounded-md bg-black/60 px-3 py-1.5 text-sm text-white">
{current.title}
</div>
)}
<div key={currentIndex} className={slideInClass}>
<Image
src={current.src}
alt={current.alt}
className="max-h-[85vh] rounded-lg object-contain"
/>
</div>
</div>
<button
onClick={goNext}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/20 text-white transition-colors hover:bg-white/40"
>
<svg
className={`h-6 w-6 ${rtl ? 'scale-x-[-1]' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
<style jsx global>{`
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`}</style>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import useClickOutside from '@/hooks/useClickOutside';
import Image from 'next/image';
import React, { useCallback, useRef, useState } from 'react';
import { SelectProps, SelectSize } from './select';
const sizeClasses: Record<
SelectSize,
{ trigger: string; option: string; icon: number }
> = {
small: {
trigger: 'h-8 px-2 text-xs gap-1.5',
option: 'px-2 py-1.5 text-xs gap-1.5',
icon: 16,
},
medium: {
trigger: 'h-10 px-3 text-sm gap-2',
option: 'px-3 py-2 text-sm gap-2',
icon: 20,
},
large: {
trigger: 'h-12 px-4 text-base gap-2.5',
option: 'px-4 py-2.5 text-base gap-2.5',
icon: 24,
},
};
const DROPDOWN_MAX_HEIGHT = 240;
const Select: React.FC<SelectProps> = ({
options,
value,
onChange,
size = 'medium',
placeholder = 'Select...',
className = '',
}) => {
const [open, setOpen] = useState(false);
const [dropUp, setDropUp] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useClickOutside(ref, () => setOpen(false));
const selected = options.find((opt) => opt.value === value);
const classes = sizeClasses[size];
const toggle = useCallback(() => {
setOpen((prev) => {
if (!prev && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
setDropUp(
spaceBelow < DROPDOWN_MAX_HEIGHT && spaceAbove > spaceBelow,
);
}
return !prev;
});
}, []);
return (
<div ref={ref} className={`relative ${className}`}>
<button
ref={triggerRef}
type="button"
onClick={toggle}
className={`flex w-full items-center justify-between rounded-lg border border-white/20 bg-transparent text-white transition-colors hover:border-white/40 ${classes.trigger}`}
>
<span className="flex items-center gap-1 truncate">
{selected ? (
<>
{selected.image && (
<Image
src={selected.image}
alt={selected.label}
width={classes.icon}
height={classes.icon}
className="rounded-sm object-cover"
/>
)}
<span>{selected.label}</span>
</>
) : (
<span className="text-white/50">{placeholder}</span>
)}
</span>
<svg
className={`h-4 w-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
{open && (
<ul
className={`absolute z-50 w-full overflow-y-auto rounded-lg border border-white/20 bg-gray-800 shadow-lg ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}
style={{ maxHeight: DROPDOWN_MAX_HEIGHT }}
>
{options.map((opt) => (
<li
key={opt.value}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`flex cursor-pointer items-center transition-colors hover:bg-white/10 ${classes.option} ${opt.value === value ? 'bg-white/5' : ''}`}
>
{opt.image && (
<Image
src={opt.image}
alt={opt.label}
width={classes.icon}
height={classes.icon}
className="rounded-sm object-cover"
/>
)}
<span>{opt.label}</span>
</li>
))}
</ul>
)}
</div>
);
};
export default Select;
+18
View File
@@ -0,0 +1,18 @@
import { StaticImageData } from 'next/image';
export interface SelectOption {
value: string;
label: string;
image?: string | StaticImageData;
}
export interface SelectProps {
options: SelectOption[];
value: string;
onChange: (value: string) => void;
size?: 'small' | 'medium' | 'large';
placeholder?: string;
className?: string;
}
export type SelectSize = 'small' | 'medium' | 'large';
+4 -1
View File
@@ -1,4 +1,7 @@
export const port = process.env.PORT || 3000;
export const port = process.env.PORT || 4000;
export const host = process.env.VERCEL_PROJECT_PRODUCTION_URL
? `${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: `http://localhost:${port}`;
export const rtlLocales = ['fa'];
export const isRtl = (locale: string) => rtlLocales.includes(locale);
+21
View File
@@ -0,0 +1,21 @@
import { RefObject, useEffect } from 'react';
export default function useClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: () => void,
) {
useEffect(() => {
const listener = (e: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(e.target as Node)) return;
handler();
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import type { IPagination } from '@/models/response';
export function ok<T>(data: T) {
return NextResponse.json({ data });
}
export function okPaginated<T>(data: T, pagination: IPagination) {
return NextResponse.json({ data, pagination });
}
export function notFound(message = 'Not found') {
return NextResponse.json({ error: message }, { status: 404 });
}
export function serverError(message = 'Internal server error') {
return NextResponse.json({ error: message }, { status: 500 });
}
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000/api',
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4000/api',
headers: {
'Content-Type': 'application/json',
},
+13
View File
@@ -0,0 +1,13 @@
export function localizeText(
locale: string,
defaultValue: string,
faValue?: string,
) {
if (locale === 'fa') return faValue || defaultValue;
return defaultValue;
}
export function localizeContent<T>(locale: string, defaultValue: T, faValue?: T) {
if (locale === 'fa') return faValue || defaultValue;
return defaultValue;
}
+9
View File
@@ -0,0 +1,9 @@
import { cookies } from 'next/headers';
import { routing } from '@/i18n/routing';
export async function getLocaleFromCookies() {
const locale = (await cookies()).get('locale')?.value;
return locale && routing.locales.includes(locale as 'en' | 'fa')
? locale
: routing.defaultLocale;
}
+24
View File
@@ -0,0 +1,24 @@
import type { IPagination } from '@/models/response';
export function getPaginationParams(request: Request, defaultPerPage: number) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1);
const perPage = defaultPerPage;
const start = (page - 1) * perPage;
const end = start + perPage;
return { page, perPage, start, end };
}
export function buildPagination(
page: number,
perPage: number,
total: number,
): IPagination {
return {
page,
perPage,
total,
totalPages: Math.ceil(total / perPage),
};
}
+24
View File
@@ -0,0 +1,24 @@
'use server';
import { cookies } from 'next/headers';
import api from '@/lib/axios';
type RequestConfig = {
params?: Record<string, string | number | boolean | undefined>;
};
async function withCookieHeader(config?: RequestConfig) {
const cookieStore = await cookies();
return {
...config,
headers: {
Cookie: cookieStore.toString(),
},
};
}
export async function serverGet<T>(url: string, config?: RequestConfig) {
const { data } = await api.get<T>(url, await withCookieHeader(config));
return data;
}
+6 -2
View File
@@ -18,7 +18,11 @@ export function middleware(request: NextRequest) {
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
const locale = matchedLocale || defaultLocale;
const cookieLocale = request.cookies.get('locale')?.value;
const preferredLocale = locales.includes(cookieLocale || '')
? cookieLocale
: defaultLocale;
const locale = matchedLocale || preferredLocale;
let response: NextResponse;
@@ -26,7 +30,7 @@ export function middleware(request: NextRequest) {
const pathnameIsMissingLocale = !matchedLocale;
if (pathnameIsMissingLocale) {
response = NextResponse.redirect(
new URL(`/${defaultLocale}${pathname}`, request.url),
new URL(`/${preferredLocale}${pathname}`, request.url),
);
} else {
response = NextResponse.next();
+68 -9
View File
@@ -1,14 +1,62 @@
export interface IDynamicContent {
type: keyof IDynamicContentItem;
data: string[] | string | IContentImage | IContentData;
export type DynamicContentType =
| 'title'
| 'subtitle'
| 'content'
| 'image'
| 'bullet'
| 'numberList'
| 'quote'
| 'cta'
| 'divider';
export type IDynamicContent =
| ITitleContentBlock
| ISubtitleContentBlock
| IParagraphContentBlock
| IImageContentBlock
| IListContentBlock
| IQuoteContentBlock
| ICTAContentBlock
| IDividerContentBlock;
export interface ITitleContentBlock {
type: 'title';
data: string;
}
export interface IDynamicContentItem {
bullet?: string[];
numberList?: string[];
image?: IContentImage;
title?: string;
content?: IContentData;
export interface ISubtitleContentBlock {
type: 'subtitle';
data: string;
}
export interface IParagraphContentBlock {
type: 'content';
data: IContentData | string;
}
export interface IImageContentBlock {
type: 'image';
data: IContentImage;
}
export interface IListContentBlock {
type: 'bullet' | 'numberList';
data: string[];
}
export interface IQuoteContentBlock {
type: 'quote';
data: IContentQuote;
}
export interface ICTAContentBlock {
type: 'cta';
data: IContentCTA;
}
export interface IDividerContentBlock {
type: 'divider';
data?: null;
}
export interface IContentImage {
@@ -28,3 +76,14 @@ export interface IContentDataLink {
link: string;
label: string;
}
export interface IContentQuote {
text: string;
author?: string;
}
export interface IContentCTA {
label: string;
link: string;
target?: '_self' | '_blank';
}
+6 -25
View File
@@ -1,37 +1,18 @@
'use server';
import { IPostResponseData, IPostThumbResponseData } from '@/app/api/blog/data';
import api from '@/lib/axios';
import { serverGet } from '@/lib/server-api';
import { IResponse } from '@/models/response';
import { cookies } from 'next/headers';
export async function getPosts(
page = 1,
): Promise<IResponse<IPostThumbResponseData[]>> {
const cookiesStore = await cookies();
return api
.get('/blog', {
headers: {
Cookie: cookiesStore.toString(),
},
params: { page },
})
.then(({ data }) => {
return Promise.resolve(data);
})
.catch((error) => Promise.reject(error));
return serverGet<IResponse<IPostThumbResponseData[]>>('/blog', {
params: { page },
});
}
export async function getPost(postId: string): Promise<IPostResponseData> {
const cookiesStore = await cookies();
return api
.get(`/blog/${postId}`, {
headers: {
Cookie: cookiesStore.toString(),
},
})
.then(({ data }) => {
return Promise.resolve(data.data);
})
.catch((error) => Promise.reject(error));
const data = await serverGet<{ data: IPostResponseData }>(`/blog/${postId}`);
return data.data;
}
+9 -14
View File
@@ -1,18 +1,13 @@
import api from "@/lib/axios";
import api from '@/lib/axios';
interface payload {
first_name: string,
last_name: string,
email: string,
phone: string,
message: string,
interface Payload {
first_name: string;
last_name: string;
email: string;
phone: string;
message: string;
}
export async function setContact(payload:payload) {
return api
.post('/contact', payload)
.then(({ data }) => {
return Promise.resolve();
})
.catch((error) => Promise.reject(error));
export async function setContact(payload: Payload) {
await api.post('/contact', payload);
}
+14 -38
View File
@@ -2,57 +2,33 @@
import type { IProductCategoryCardProps } from '@/components/pages/productCategories/productCategoryCard.d';
import type { IProductCardProps } from '@/components/pages/products/productCard/productCard.d';
import api from '@/lib/axios';
import { serverGet } from '@/lib/server-api';
import { IResponse } from '@/models/response';
import { cookies } from 'next/headers';
export async function getProductCategories() {
const cookiesStore = await cookies();
return api
.get('/product_categories', {
headers: {
Cookie: cookiesStore.toString(),
},
})
.then(({ data }) => {
return Promise.resolve(data.data);
})
.catch((error) => Promise.reject(error));
const data = await serverGet<{ data: IProductCategoryCardProps[] }>(
'/product_categories',
);
return data.data;
}
export async function getProductCategoryById(
categoryId: string,
): Promise<IProductCategoryCardProps> {
const cookiesStore = await cookies();
return api
.get(`/product_categories/${categoryId}`, {
headers: {
Cookie: cookiesStore.toString(),
},
})
.then(({ data }) => {
return Promise.resolve(data.data);
})
.catch((error) => Promise.reject(error));
const data = await serverGet<{ data: IProductCategoryCardProps }>(
`/product_categories/${categoryId}`,
);
return data.data;
}
export async function getProducts(
categoryId: string,
page = 1,
): Promise<IResponse<IProductCardProps[]>> {
const cookiesStore = await cookies();
console.log('request');
console.log(page);
return api
.get(`/product_categories/${categoryId}/products`, {
headers: {
Cookie: cookiesStore.toString(),
},
return serverGet<IResponse<IProductCardProps[]>>(
`/product_categories/${categoryId}/products`,
{
params: { page },
})
.then(({ data }) => {
return Promise.resolve(data);
})
.catch((error) => Promise.reject(error));
},
);
}
+8 -24
View File
@@ -1,35 +1,19 @@
'use server';
import { IProjectResponseData } from '@/app/api/projects/data';
import api from '@/lib/axios';
import { cookies } from 'next/headers';
import { serverGet } from '@/lib/server-api';
export async function getProjects(page = 1 as number) {
const cookiesStore = await cookies();
return api
.get('/projects', {
headers: {
Cookie: cookiesStore.toString(),
},
params: { page },
})
.then(({ data }) => Promise.resolve(data))
.catch((error) => Promise.reject(error));
return serverGet('/projects', {
params: { page },
});
}
export async function getProject(
projectId: string,
): Promise<IProjectResponseData> {
const cookiesStore = await cookies();
return api
.get(`/projects/${projectId}`, {
headers: {
Cookie: cookiesStore.toString(),
},
})
.then(({ data }) => {
return Promise.resolve(data.data);
})
.catch((error) => Promise.reject(error));
const data = await serverGet<{ data: IProjectResponseData }>(
`/projects/${projectId}`,
);
return data.data;
}
+3023
View File
File diff suppressed because it is too large Load Diff