Compare commits
10 Commits
864d5c51dc
...
730e2d8ca8
| Author | SHA1 | Date | |
|---|---|---|---|
| 730e2d8ca8 | |||
| 478b550c51 | |||
| 3cab7e3ee5 | |||
| aae6a4b49b | |||
| 19e53d4b97 | |||
| 584bc5522e | |||
| 123df850a8 | |||
| 21c52a84ed | |||
| 6c9dd0b731 | |||
| 1d5b25354d |
@@ -2,3 +2,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.next/cache
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.log
|
||||
|
||||
@@ -42,3 +42,7 @@ next-env.d.ts
|
||||
|
||||
contactMessages.json
|
||||
public/sitemap.xml
|
||||
|
||||
data/
|
||||
|
||||
node_modules/
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -7,6 +7,8 @@ const withNextIntl = createNextIntlPlugin({
|
||||
},
|
||||
});
|
||||
|
||||
const config: NextConfig = {};
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default withNextIntl(config);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 360 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 578 KiB After Width: | Height: | Size: 17 KiB |
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
title: localizeText(locale, post.title, post.fa_title),
|
||||
content: localizeContent(locale, post.content, post.fa_content),
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: post,
|
||||
});
|
||||
return ok(post);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -566,7 +566,7 @@ export const meshkiCategoryProducts = [
|
||||
size: '',
|
||||
categoryId: '51',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export const rusticiCategoryProducts = [
|
||||
{
|
||||
@@ -672,8 +672,7 @@ export const rusticiCategoryProducts = [
|
||||
size: '7 * 25 * 2.5',
|
||||
categoryId: '52',
|
||||
},
|
||||
|
||||
]
|
||||
];
|
||||
|
||||
export const ghazaghiCategoryProducts = [
|
||||
{
|
||||
@@ -684,7 +683,6 @@ export const ghazaghiCategoryProducts = [
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
export const ghazaghizardCategoryProducts = [
|
||||
{
|
||||
model: 'پلاک قزاقی کامل',
|
||||
@@ -798,7 +796,7 @@ export const ghazaghipiyaziCategoryProducts = [
|
||||
size: '5.5 * 20 * 5.5',
|
||||
categoryId: '54',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export const ghazaghighermezCategoryProducts = [
|
||||
{
|
||||
@@ -837,4 +835,4 @@ export const ghazaghighermezCategoryProducts = [
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
title: localizeText(locale, project.title, project.fa_title),
|
||||
content: localizeContent(locale, project.content, project.fa_content),
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: project,
|
||||
});
|
||||
return ok(project);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
return [...projects].map((e, i) => ({
|
||||
...e,
|
||||
id: String(i + 1),
|
||||
}),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 980 B |
@@ -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,
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -28,6 +28,8 @@ 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>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
link={routes.blog.route()}
|
||||
endIcon="showMore"
|
||||
@@ -37,6 +39,7 @@ export default async function HomePageBlog({ locale, className }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<PostsGridViewSlider
|
||||
|
||||
@@ -29,6 +29,7 @@ export default async function HomePageProjects({ locale }: Props) {
|
||||
<h4 className="text-base font-extralight tracking-normal max-lg:hidden">
|
||||
{t('com_home_projects_description')}
|
||||
</h4>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
link={routes.projects.route()}
|
||||
endIcon="showMore"
|
||||
@@ -38,6 +39,7 @@ export default async function HomePageProjects({ locale }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<ProjectsGridSlider
|
||||
projects={projects.data?.slice(0, 3)}
|
||||
|
||||
@@ -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,8 +9,27 @@ 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="cursor-pointer overflow-hidden rounded-xl bg-gray-200 p-4"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
>
|
||||
<div className="aspect-square w-full">
|
||||
<img
|
||||
src={imageSrc}
|
||||
@@ -17,5 +40,29 @@ export default function ProductCard({
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
return serverGet<IResponse<IPostThumbResponseData[]>>('/blog', {
|
||||
params: { page },
|
||||
})
|
||||
.then(({ data }) => {
|
||||
return Promise.resolve(data);
|
||||
})
|
||||
.catch((error) => Promise.reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
return serverGet('/projects', {
|
||||
params: { page },
|
||||
})
|
||||
.then(({ data }) => Promise.resolve(data))
|
||||
.catch((error) => Promise.reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||