Add Meshkee SEO, customer auth menu, and footer polish.
Implement canonical product/category/blog URLs, sitemap-config endpoint, page metadata, customer login dropdown with admin handoff, safeteb-style footer bar, backend favicon, and remove the standalone partners strip. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7a0de3c1f4
commit
b2a0fe73bc
Generated
-114
@@ -631,9 +631,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -650,9 +647,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -669,9 +663,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -688,9 +679,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -707,9 +695,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -726,9 +711,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -745,9 +727,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -764,9 +743,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -783,9 +759,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -808,9 +781,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -833,9 +803,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -858,9 +825,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -883,9 +847,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -908,9 +869,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -933,9 +891,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -958,9 +913,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1228,9 +1180,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1247,9 +1196,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1266,9 +1212,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1285,9 +1228,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1526,9 +1466,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1546,9 +1483,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1566,9 +1500,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1586,9 +1517,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2139,9 +2067,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2156,9 +2081,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2173,9 +2095,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2190,9 +2109,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2207,9 +2123,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2224,9 +2137,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2241,9 +2151,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2258,9 +2165,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2275,9 +2179,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2292,9 +2193,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -4979,9 +4877,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5003,9 +4898,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5027,9 +4919,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5051,9 +4940,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -6,13 +6,14 @@ import PageBanner from "@/components/PageBanner";
|
|||||||
import MissionVision from "@/components/MissionVision";
|
import MissionVision from "@/components/MissionVision";
|
||||||
import ImportCountries from "@/components/ImportCountries";
|
import ImportCountries from "@/components/ImportCountries";
|
||||||
import Features from "@/components/Features";
|
import Features from "@/components/Features";
|
||||||
import Partners from "@/components/Partners";
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = pageMetadata({
|
||||||
title: "درباره ما | آراد آریسمان",
|
title: "درباره ما | آراد آریسمان",
|
||||||
description:
|
description:
|
||||||
"آشنایی با شرکت آراد آریسمان؛ بیش از ۲۳ سال تجربه در تولید و عرضه مکملهای غذایی دام، طیور و زنبور عسل.",
|
"آشنایی با شرکت آراد آریسمان؛ بیش از ۲۳ سال تجربه در تولید و عرضه مکملهای غذایی دام، طیور و زنبور عسل.",
|
||||||
};
|
path: "/about",
|
||||||
|
});
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
return (
|
return (
|
||||||
@@ -84,8 +85,6 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
<ImportCountries />
|
<ImportCountries />
|
||||||
|
|
||||||
<Partners />
|
|
||||||
|
|
||||||
<section className="py-16 sm:py-20">
|
<section className="py-16 sm:py-20">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="rounded-3xl bg-primary px-8 py-14 sm:px-16 text-center">
|
<div className="rounded-3xl bg-primary px-8 py-14 sm:px-16 text-center">
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const token = getBearerToken(request);
|
||||||
|
if (!token) return unauthorizedResponse();
|
||||||
|
|
||||||
|
const res = await meshkeeFetch("/auth/handoff", {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(res.data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const token = getBearerToken(request);
|
||||||
|
if (!token) return unauthorizedResponse();
|
||||||
|
|
||||||
|
const res = await meshkeeFetch("/auth/me", { token });
|
||||||
|
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
|
||||||
|
return jsonResponse(res.data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
|
import Container from "@/components/Container";
|
||||||
|
import PageBanner from "@/components/PageBanner";
|
||||||
|
import PostCard from "@/components/PostCard";
|
||||||
|
import {
|
||||||
|
getPostById,
|
||||||
|
getPosts,
|
||||||
|
postContent,
|
||||||
|
postDate,
|
||||||
|
postExcerpt,
|
||||||
|
postImage,
|
||||||
|
postReadingTime,
|
||||||
|
postTitle,
|
||||||
|
} from "@/lib/meshkee";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
import { blogPath, blogSlug } from "@/lib/urls";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const post = await getPostById(id);
|
||||||
|
if (!post) return {};
|
||||||
|
const title = postTitle(post);
|
||||||
|
return pageMetadata({
|
||||||
|
title: `${title} | آراد آریسمان`,
|
||||||
|
description: postExcerpt(post, 160),
|
||||||
|
path: blogPath(post),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, slug: slugParam } = await params;
|
||||||
|
const slug = decodeURIComponent(slugParam);
|
||||||
|
const post = await getPostById(id);
|
||||||
|
if (!post) notFound();
|
||||||
|
|
||||||
|
const canonicalSlug = blogSlug(post);
|
||||||
|
if (slug !== canonicalSlug) {
|
||||||
|
permanentRedirect(blogPath(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = postTitle(post);
|
||||||
|
const image = postImage(post);
|
||||||
|
const content = postContent(post);
|
||||||
|
|
||||||
|
const related = post.categoryId
|
||||||
|
? (await getPosts({ categoryId: post.categoryId, pageSize: 4 })).items
|
||||||
|
.filter((p) => p.id !== post.id)
|
||||||
|
.slice(0, 3)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageBanner title={title} breadcrumb="مقالات" />
|
||||||
|
|
||||||
|
<section className="py-16 sm:py-20">
|
||||||
|
<Container className="max-w-3xl">
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
بازگشت به مقالات
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center gap-3 text-sm text-foreground/70">
|
||||||
|
{post.categoryNameFa && (
|
||||||
|
<span className="rounded-full bg-primary/5 px-3 py-1 text-xs font-semibold text-primary">
|
||||||
|
{post.categoryNameFa}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{postDate(post)}</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-foreground/30" />
|
||||||
|
<span>{postReadingTime(post)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{image && (
|
||||||
|
<div className="relative mt-6 aspect-[16/9] rounded-3xl overflow-hidden shadow-xl bg-surface">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(min-width: 1024px) 768px, 100vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content && (
|
||||||
|
<div
|
||||||
|
className="rich-content mt-8"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-2xl bg-surface p-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-secondary">نویسنده: تیم آراد آریسمان</p>
|
||||||
|
<p className="mt-1 text-sm text-foreground">
|
||||||
|
برای مشاوره تخصصی تغذیه دام و طیور با ما در تماس باشید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="rounded-full bg-primary px-6 py-3 text-white font-semibold hover:bg-primary-dark transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
تماس با ما
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{related.length > 0 && (
|
||||||
|
<section className="pb-16 sm:pb-20 bg-surface pt-16">
|
||||||
|
<Container>
|
||||||
|
<h2 className="text-2xl font-extrabold text-primary">
|
||||||
|
مقالات مرتبط
|
||||||
|
</h2>
|
||||||
|
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{related.map((p) => (
|
||||||
|
<PostCard key={p.id} post={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
import Image from "next/image";
|
import { getPost } from "@/lib/meshkee";
|
||||||
import Link from "next/link";
|
import { blogPath } from "@/lib/urls";
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import Container from "@/components/Container";
|
|
||||||
import PageBanner from "@/components/PageBanner";
|
|
||||||
import PostCard from "@/components/PostCard";
|
|
||||||
import {
|
|
||||||
getPost,
|
|
||||||
getPosts,
|
|
||||||
postContent,
|
|
||||||
postDate,
|
|
||||||
postExcerpt,
|
|
||||||
postImage,
|
|
||||||
postReadingTime,
|
|
||||||
postTitle,
|
|
||||||
} from "@/lib/meshkee";
|
|
||||||
|
|
||||||
export const revalidate = 300;
|
/** Legacy /blog/{slug} → canonical /blog/{id}/{titleSlug} */
|
||||||
|
export default async function LegacyBlogRedirect({
|
||||||
export async function generateStaticParams() {
|
|
||||||
const { items } = await getPosts({ page: 1, pageSize: 100 });
|
|
||||||
return items.map((p) => ({ slug: p.slug }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
|
||||||
const post = await getPost(slug);
|
|
||||||
if (!post) return {};
|
|
||||||
return {
|
|
||||||
title: `${postTitle(post)} | آراد آریسمان`,
|
|
||||||
description: postExcerpt(post, 160),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogDetailPage({
|
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -45,98 +11,5 @@ export default async function BlogDetailPage({
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const post = await getPost(slug);
|
const post = await getPost(slug);
|
||||||
if (!post) notFound();
|
if (!post) notFound();
|
||||||
|
permanentRedirect(blogPath(post));
|
||||||
const title = postTitle(post);
|
|
||||||
const image = postImage(post);
|
|
||||||
const content = postContent(post);
|
|
||||||
|
|
||||||
const related = post.categoryId
|
|
||||||
? (await getPosts({ categoryId: post.categoryId, pageSize: 4 })).items
|
|
||||||
.filter((p) => p.id !== post.id)
|
|
||||||
.slice(0, 3)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageBanner title={title} breadcrumb="مقالات" />
|
|
||||||
|
|
||||||
<section className="py-16 sm:py-20">
|
|
||||||
<Container className="max-w-3xl">
|
|
||||||
<Link
|
|
||||||
href="/blog"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
بازگشت به مقالات
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3 text-sm text-foreground/70">
|
|
||||||
{post.categoryNameFa && (
|
|
||||||
<span className="rounded-full bg-primary/5 px-3 py-1 text-xs font-semibold text-primary">
|
|
||||||
{post.categoryNameFa}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{postDate(post)}</span>
|
|
||||||
<span className="h-1 w-1 rounded-full bg-foreground/30" />
|
|
||||||
<span>{postReadingTime(post)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{image && (
|
|
||||||
<div className="relative mt-6 aspect-[16/9] rounded-3xl overflow-hidden shadow-xl bg-surface">
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt={title}
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(min-width: 1024px) 768px, 100vw"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{content && (
|
|
||||||
<div
|
|
||||||
className="rich-content mt-8"
|
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-10 rounded-2xl bg-surface p-6 flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-secondary">نویسنده: تیم آراد آریسمان</p>
|
|
||||||
<p className="mt-1 text-sm text-foreground">
|
|
||||||
برای مشاوره تخصصی تغذیه دام و طیور با ما در تماس باشید.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="rounded-full bg-primary px-6 py-3 text-white font-semibold hover:bg-primary-dark transition-colors shrink-0"
|
|
||||||
>
|
|
||||||
تماس با ما
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{related.length > 0 && (
|
|
||||||
<section className="pb-16 sm:pb-20 bg-surface pt-16">
|
|
||||||
<Container>
|
|
||||||
<h2 className="text-2xl font-extrabold text-primary">
|
|
||||||
مقالات مرتبط
|
|
||||||
</h2>
|
|
||||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{related.map((p) => (
|
|
||||||
<PostCard key={p.id} post={p} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
import { pageMetadata } from "@/lib/seo";
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PageBanner from "@/components/PageBanner";
|
import PageBanner from "@/components/PageBanner";
|
||||||
import PostCard from "@/components/PostCard";
|
import PostCard from "@/components/PostCard";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import { getPosts } from "@/lib/meshkee";
|
import { getPosts } from "@/lib/meshkee";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = pageMetadata({
|
||||||
title: "مقالات | آراد آریسمان",
|
title: "مقالات | آراد آریسمان",
|
||||||
description:
|
description:
|
||||||
"مقالات آموزشی درباره تغذیه دام، طیور و زنبور عسل از تیم آراد آریسمان.",
|
"مقالات آموزشی درباره تغذیه دام، طیور و زنبور عسل از تیم آراد آریسمان.",
|
||||||
};
|
path: "/blog",
|
||||||
|
});
|
||||||
|
|
||||||
const PAGE_SIZE = 6;
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PageBanner from "@/components/PageBanner";
|
import PageBanner from "@/components/PageBanner";
|
||||||
import ContactInfo from "@/components/ContactInfo";
|
import ContactInfo from "@/components/ContactInfo";
|
||||||
import ContactForm from "@/components/ContactForm";
|
import ContactForm from "@/components/ContactForm";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = pageMetadata({
|
||||||
title: "تماس با ما | آراد آریسمان",
|
title: "تماس با ما | آراد آریسمان",
|
||||||
description:
|
description:
|
||||||
"راههای ارتباطی با شرکت آراد آریسمان؛ آدرس، تلفن تماس و فرم ارسال پیام.",
|
"راههای ارتباطی با شرکت آراد آریسمان؛ آدرس، تلفن تماس و فرم ارسال پیام.",
|
||||||
};
|
path: "/contact",
|
||||||
|
});
|
||||||
|
|
||||||
const mapQuery = encodeURIComponent(
|
const mapQuery = encodeURIComponent(
|
||||||
"قم، شهرک صنعتی شکوهیه، خیابان شهید بروجردی"
|
"قم، شهرک صنعتی شکوهیه، خیابان شهید بروجردی"
|
||||||
|
|||||||
@@ -68,3 +68,224 @@ body {
|
|||||||
.rich-content strong {
|
.rich-content strong {
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 20px 0 19px;
|
||||||
|
background-color: #1d1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom-site {
|
||||||
|
color: #9c9c9c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #9c9c9c;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__frame {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line {
|
||||||
|
position: absolute;
|
||||||
|
background: #777;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--l {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: top;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--b {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition-delay: 0.14s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--r {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: bottom;
|
||||||
|
transition-delay: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--t {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: right;
|
||||||
|
transition-delay: 0.42s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--l,
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--r {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--b,
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--t {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
|
||||||
|
transition-delay: 0.42s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
|
||||||
|
transition-delay: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
|
||||||
|
transition-delay: 0.14s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__logo {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: auto;
|
||||||
|
height: 42px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__logo {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__text {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 42px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cfcfcf;
|
||||||
|
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 575px) {
|
||||||
|
.footer-bottom-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu {
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu--login {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu__trigger {
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu__dropdown {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.12);
|
||||||
|
inset-inline-start: 0;
|
||||||
|
min-width: 12rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu__dropdown a,
|
||||||
|
.account-menu__dropdown button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: right;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu__dropdown a:hover,
|
||||||
|
.account-menu__dropdown button:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, white);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu__logout {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-menu__logout:hover {
|
||||||
|
background: #fef3f2;
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|||||||
+26
-4
@@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
|||||||
import { Vazirmatn } from "next/font/google";
|
import { Vazirmatn } from "next/font/google";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
|
import Providers from "@/components/Providers";
|
||||||
|
import { getBusinessId, getWebsiteFavicon } from "@/lib/meshkee";
|
||||||
|
import { SITE_URL } from "@/lib/seo";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const vazirmatn = Vazirmatn({
|
const vazirmatn = Vazirmatn({
|
||||||
@@ -10,13 +13,30 @@ const vazirmatn = Vazirmatn({
|
|||||||
weight: ["300", "400", "500", "600", "700", "800"],
|
weight: ["300", "400", "500", "600", "700", "800"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const defaultFavicon = "/favicon.ico";
|
||||||
title: "آراد آریسمان | صفحه اصلی",
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const branding = await getWebsiteFavicon();
|
||||||
|
const iconUrl = branding.faviconUrl || defaultFavicon;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadataBase: new URL(SITE_URL),
|
||||||
|
title: {
|
||||||
|
default: "آراد آریسمان",
|
||||||
|
template: "%s",
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
"شرکت آراد آریسمان، تولید و عرضه مکملها، ویتامینها و افزودنیهای خوراک دام و طیور با بیش از ۲۳ سال تجربه.",
|
"شرکت آراد آریسمان، تولید و عرضه مکملها، ویتامینها و افزودنیهای خوراک دام و طیور با بیش از ۲۳ سال تجربه.",
|
||||||
};
|
icons: {
|
||||||
|
icon: iconUrl,
|
||||||
|
shortcut: iconUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
const businessId = await getBusinessId();
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="fa"
|
lang="fa"
|
||||||
@@ -24,9 +44,11 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||||||
className={`${vazirmatn.variable} h-full antialiased`}
|
className={`${vazirmatn.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
|
<Providers businessId={businessId}>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 pt-[125px]">{children}</main>
|
<main className="flex-1 pt-[125px]">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { SITE_URL } from "@/lib/seo";
|
||||||
|
|
||||||
|
const staticPages = [
|
||||||
|
{ path: "/", changefreq: "daily", priority: 1 },
|
||||||
|
{ path: "/products", changefreq: "weekly", priority: 0.9 },
|
||||||
|
{ path: "/blog", changefreq: "weekly", priority: 0.8 },
|
||||||
|
{ path: "/about", changefreq: "monthly", priority: 0.6 },
|
||||||
|
{ path: "/contact", changefreq: "monthly", priority: 0.5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
baseUrl: SITE_URL,
|
||||||
|
staticPages,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-2
@@ -1,11 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import HeroSlider from "@/components/HeroSlider";
|
import HeroSlider from "@/components/HeroSlider";
|
||||||
import CategoryShowcase from "@/components/CategoryShowcase";
|
import CategoryShowcase from "@/components/CategoryShowcase";
|
||||||
|
import HomeContactStrip from "@/components/HomeContactStrip";
|
||||||
import Features from "@/components/Features";
|
import Features from "@/components/Features";
|
||||||
import Stats from "@/components/Stats";
|
import Stats from "@/components/Stats";
|
||||||
import Products from "@/components/Products";
|
import Products from "@/components/Products";
|
||||||
import About from "@/components/About";
|
import About from "@/components/About";
|
||||||
import Partners from "@/components/Partners";
|
|
||||||
import { getStaticImageSlot } from "@/lib/meshkee";
|
import { getStaticImageSlot } from "@/lib/meshkee";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
|
||||||
|
export const metadata: Metadata = pageMetadata({
|
||||||
|
title: "آراد آریسمان | صفحه اصلی",
|
||||||
|
description:
|
||||||
|
"شرکت آراد آریسمان، تولید و عرضه مکملها، ویتامینها و افزودنیهای خوراک دام و طیور با بیش از ۲۳ سال تجربه.",
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const sliderSlot = await getStaticImageSlot("slider");
|
const sliderSlot = await getStaticImageSlot("slider");
|
||||||
@@ -14,11 +23,11 @@ export default async function Home() {
|
|||||||
<>
|
<>
|
||||||
<HeroSlider slotImages={sliderSlot?.images ?? []} />
|
<HeroSlider slotImages={sliderSlot?.images ?? []} />
|
||||||
<CategoryShowcase />
|
<CategoryShowcase />
|
||||||
|
<HomeContactStrip />
|
||||||
<Features />
|
<Features />
|
||||||
<Stats />
|
<Stats />
|
||||||
<Products />
|
<Products />
|
||||||
<About />
|
<About />
|
||||||
<Partners />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
|
import Container from "@/components/Container";
|
||||||
|
import PageBanner from "@/components/PageBanner";
|
||||||
|
import ProductCard from "@/components/ProductCard";
|
||||||
|
import {
|
||||||
|
getProductById,
|
||||||
|
getProducts,
|
||||||
|
productImage,
|
||||||
|
productPriceLabel,
|
||||||
|
stripHtml,
|
||||||
|
} from "@/lib/meshkee";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
import { productPath, productSlug } from "@/lib/urls";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const product = await getProductById(id);
|
||||||
|
if (!product) return {};
|
||||||
|
const description = product.summary || stripHtml(product.descriptionHtml, 160);
|
||||||
|
return pageMetadata({
|
||||||
|
title: `${product.nameFa} | آراد آریسمان`,
|
||||||
|
description,
|
||||||
|
path: productPath(product),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, slug: slugParam } = await params;
|
||||||
|
const slug = decodeURIComponent(slugParam);
|
||||||
|
const product = await getProductById(id);
|
||||||
|
if (!product) notFound();
|
||||||
|
|
||||||
|
const canonicalSlug = productSlug(product);
|
||||||
|
if (slug !== canonicalSlug) {
|
||||||
|
permanentRedirect(productPath(product));
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = productImage(product);
|
||||||
|
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
|
||||||
|
|
||||||
|
const related = product.categoryId
|
||||||
|
? (await getProducts({ categoryId: product.categoryId, pageSize: 4 })).items
|
||||||
|
.filter((p) => p.id !== product.id)
|
||||||
|
.slice(0, 3)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageBanner title={product.nameFa} subtitle={shortDesc} breadcrumb={product.nameFa} />
|
||||||
|
|
||||||
|
<section className="py-16 sm:py-20">
|
||||||
|
<Container>
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
|
<div className="relative aspect-square rounded-3xl overflow-hidden shadow-xl bg-surface">
|
||||||
|
{image ? (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={product.nameFa}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(min-width: 1024px) 45vw, 90vw"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-primary/20 text-6xl font-bold">
|
||||||
|
آ
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{product.categoryNameFa && (
|
||||||
|
<span className="absolute top-4 right-4 rounded-full bg-white/90 backdrop-blur px-4 py-1.5 text-sm font-semibold text-primary">
|
||||||
|
{product.categoryNameFa}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{shortDesc && (
|
||||||
|
<p className="text-foreground leading-8">{shortDesc}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
<span className="text-xl font-extrabold text-primary">
|
||||||
|
{productPriceLabel(product)}
|
||||||
|
</span>
|
||||||
|
{!product.store.inStock && (
|
||||||
|
<span className="text-sm text-foreground/60">
|
||||||
|
(استعلام موجودی)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href="tel:09104067935"
|
||||||
|
dir="ltr"
|
||||||
|
className="rounded-full bg-primary px-7 py-3.5 text-white font-semibold hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
09104067935
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="rounded-full border border-primary/30 px-7 py-3.5 text-primary font-semibold hover:bg-primary/5 transition-colors"
|
||||||
|
>
|
||||||
|
استعلام قیمت
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{product.descriptionHtml && (
|
||||||
|
<div className="mt-14 rounded-2xl border border-black/5 bg-white p-6 sm:p-8">
|
||||||
|
<h2 className="text-lg font-bold text-secondary">معرفی محصول</h2>
|
||||||
|
<div
|
||||||
|
className="rich-content mt-4"
|
||||||
|
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{related.length > 0 && (
|
||||||
|
<section className="pb-16 sm:pb-20">
|
||||||
|
<Container>
|
||||||
|
<h2 className="text-2xl font-extrabold text-primary">
|
||||||
|
محصولات مرتبط
|
||||||
|
</h2>
|
||||||
|
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{related.map((p) => (
|
||||||
|
<ProductCard key={p.id} product={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
import Image from "next/image";
|
import { getProduct } from "@/lib/meshkee";
|
||||||
import Link from "next/link";
|
import { productPath } from "@/lib/urls";
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import Container from "@/components/Container";
|
|
||||||
import PageBanner from "@/components/PageBanner";
|
|
||||||
import ProductCard from "@/components/ProductCard";
|
|
||||||
import {
|
|
||||||
getProduct,
|
|
||||||
getProducts,
|
|
||||||
productImage,
|
|
||||||
productPriceLabel,
|
|
||||||
stripHtml,
|
|
||||||
} from "@/lib/meshkee";
|
|
||||||
|
|
||||||
export const revalidate = 300;
|
/** Legacy /products/{slug} → canonical /products/{id}/{nameFaSlug} */
|
||||||
|
export default async function LegacyProductRedirect({
|
||||||
export async function generateStaticParams() {
|
|
||||||
const { items } = await getProducts({ page: 1, pageSize: 100 });
|
|
||||||
return items.map((p) => ({ slug: p.slug }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { slug } = await params;
|
|
||||||
const product = await getProduct(slug);
|
|
||||||
if (!product) return {};
|
|
||||||
return {
|
|
||||||
title: `${product.nameFa} | آراد آریسمان`,
|
|
||||||
description: product.summary || stripHtml(product.descriptionHtml, 160),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProductDetailPage({
|
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -42,121 +11,5 @@ export default async function ProductDetailPage({
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const product = await getProduct(slug);
|
const product = await getProduct(slug);
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
permanentRedirect(productPath(product));
|
||||||
const image = productImage(product);
|
|
||||||
const shortDesc = product.summary || stripHtml(product.descriptionHtml, 160);
|
|
||||||
|
|
||||||
const related = product.categoryId
|
|
||||||
? (await getProducts({ categoryId: product.categoryId, pageSize: 4 })).items
|
|
||||||
.filter((p) => p.id !== product.id)
|
|
||||||
.slice(0, 3)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageBanner title={product.nameFa} subtitle={shortDesc} breadcrumb={product.nameFa} />
|
|
||||||
|
|
||||||
<section className="py-16 sm:py-20">
|
|
||||||
<Container>
|
|
||||||
<Link
|
|
||||||
href="/products"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M10.7 18.7a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.4l6-6a1 1 0 1 1 1.4 1.4L6 11.3h14a1 1 0 1 1 0 2H6l4.7 4.3a1 1 0 0 1 0 1.4Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
بازگشت به محصولات
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
||||||
<div className="relative aspect-square rounded-3xl overflow-hidden shadow-xl bg-surface">
|
|
||||||
{image ? (
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt={product.nameFa}
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
className="object-cover"
|
|
||||||
sizes="(min-width: 1024px) 45vw, 90vw"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-primary/20 text-6xl font-bold">
|
|
||||||
آ
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{product.categoryNameFa && (
|
|
||||||
<span className="absolute top-4 right-4 rounded-full bg-white/90 backdrop-blur px-4 py-1.5 text-sm font-semibold text-primary">
|
|
||||||
{product.categoryNameFa}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-extrabold text-primary">
|
|
||||||
{product.nameFa}
|
|
||||||
</h1>
|
|
||||||
{shortDesc && (
|
|
||||||
<p className="mt-3 text-foreground leading-8">{shortDesc}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
|
||||||
<span className="text-xl font-extrabold text-primary">
|
|
||||||
{productPriceLabel(product)}
|
|
||||||
</span>
|
|
||||||
{!product.store.inStock && (
|
|
||||||
<span className="text-sm text-foreground/60">
|
|
||||||
(استعلام موجودی)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
|
||||||
<a
|
|
||||||
href="tel:09104067935"
|
|
||||||
dir="ltr"
|
|
||||||
className="rounded-full bg-primary px-7 py-3.5 text-white font-semibold hover:bg-primary-dark transition-colors"
|
|
||||||
>
|
|
||||||
09104067935
|
|
||||||
</a>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="rounded-full border border-primary/30 px-7 py-3.5 text-primary font-semibold hover:bg-primary/5 transition-colors"
|
|
||||||
>
|
|
||||||
استعلام قیمت
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{product.descriptionHtml && (
|
|
||||||
<div className="mt-14 rounded-2xl border border-black/5 bg-white p-6 sm:p-8">
|
|
||||||
<h2 className="text-lg font-bold text-secondary">معرفی محصول</h2>
|
|
||||||
<div
|
|
||||||
className="rich-content mt-4"
|
|
||||||
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{related.length > 0 && (
|
|
||||||
<section className="pb-16 sm:pb-20">
|
|
||||||
<Container>
|
|
||||||
<h2 className="text-2xl font-extrabold text-primary">
|
|
||||||
محصولات مرتبط
|
|
||||||
</h2>
|
|
||||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{related.map((p) => (
|
|
||||||
<ProductCard key={p.id} product={p} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
|
import Container from "@/components/Container";
|
||||||
|
import PageBanner from "@/components/PageBanner";
|
||||||
|
import ProductCard from "@/components/ProductCard";
|
||||||
|
import Pagination from "@/components/Pagination";
|
||||||
|
import {
|
||||||
|
getCategories,
|
||||||
|
getCategoryById,
|
||||||
|
getCategoryDescendantIds,
|
||||||
|
getProducts,
|
||||||
|
} from "@/lib/meshkee";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
import { categoryPath, categorySlug } from "@/lib/urls";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const category = await getCategoryById(id);
|
||||||
|
if (!category) return {};
|
||||||
|
return pageMetadata({
|
||||||
|
title: `${category.nameFa} | محصولات آراد آریسمان`,
|
||||||
|
description: `مشاهده محصولات دسته ${category.nameFa}؛ مکملهای غذایی آراد آریسمان.`,
|
||||||
|
path: categoryPath(category),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryProductsPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id, slug: slugParam } = await params;
|
||||||
|
const { page } = await searchParams;
|
||||||
|
const slug = decodeURIComponent(slugParam);
|
||||||
|
const category = await getCategoryById(id);
|
||||||
|
if (!category) notFound();
|
||||||
|
|
||||||
|
const canonicalSlug = categorySlug(category);
|
||||||
|
if (slug !== canonicalSlug) {
|
||||||
|
permanentRedirect(categoryPath(category));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPage = Math.max(1, Number(page) || 1);
|
||||||
|
const allCategories = await getCategories();
|
||||||
|
const allowedIds = new Set(getCategoryDescendantIds(id, allCategories));
|
||||||
|
const { items: allItems } = await getProducts({ pageSize: 100 });
|
||||||
|
const filtered = allItems.filter(
|
||||||
|
(p) => p.categoryId && allowedIds.has(p.categoryId)
|
||||||
|
);
|
||||||
|
const total = filtered.length;
|
||||||
|
const start = (requestedPage - 1) * PAGE_SIZE;
|
||||||
|
const items = filtered.slice(start, start + PAGE_SIZE);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.min(requestedPage, totalPages);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageBanner
|
||||||
|
title={category.nameFa}
|
||||||
|
subtitle={`محصولات دسته ${category.nameFa}`}
|
||||||
|
breadcrumb={category.nameFa}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="py-16 sm:py-20">
|
||||||
|
<Container>
|
||||||
|
<div className="mb-8 flex items-center justify-between flex-wrap gap-3 rounded-2xl bg-surface px-5 py-4">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
نمایش محصولات دسته{" "}
|
||||||
|
<strong className="text-primary">{category.nameFa}</strong>
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
همه محصولات
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{items.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
basePath={categoryPath(category)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-foreground py-10">
|
||||||
|
در حال حاضر محصولی در این دستهبندی وجود ندارد.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+15
-26
@@ -1,16 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import { permanentRedirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PageBanner from "@/components/PageBanner";
|
import PageBanner from "@/components/PageBanner";
|
||||||
import ProductCard from "@/components/ProductCard";
|
import ProductCard from "@/components/ProductCard";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import { getProducts } from "@/lib/meshkee";
|
import { getCategoryById, getProducts } from "@/lib/meshkee";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
|
import { categoryPath } from "@/lib/urls";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = pageMetadata({
|
||||||
title: "محصولات | آراد آریسمان",
|
title: "محصولات | آراد آریسمان",
|
||||||
description:
|
description:
|
||||||
"مکملهای غذایی، ویتامینه و معدنی دام، طیور و زنبور عسل؛ محصولات آراد آریسمان را مشاهده کنید.",
|
"مکملهای غذایی، ویتامینه و معدنی دام، طیور و زنبور عسل؛ محصولات آراد آریسمان را مشاهده کنید.",
|
||||||
};
|
path: "/products",
|
||||||
|
});
|
||||||
|
|
||||||
const PAGE_SIZE = 6;
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
@@ -20,17 +22,22 @@ export default async function ProductsPage({
|
|||||||
searchParams: Promise<{ page?: string; categoryId?: string }>;
|
searchParams: Promise<{ page?: string; categoryId?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { page, categoryId } = await searchParams;
|
const { page, categoryId } = await searchParams;
|
||||||
const requestedPage = Math.max(1, Number(page) || 1);
|
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
const category = await getCategoryById(categoryId);
|
||||||
|
if (category) {
|
||||||
|
permanentRedirect(categoryPath(category));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPage = Math.max(1, Number(page) || 1);
|
||||||
const { items, total } = await getProducts({
|
const { items, total } = await getProducts({
|
||||||
page: requestedPage,
|
page: requestedPage,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
categoryId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
const currentPage = Math.min(requestedPage, totalPages);
|
const currentPage = Math.min(requestedPage, totalPages);
|
||||||
const activeCategoryName = categoryId ? items[0]?.categoryNameFa : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -42,23 +49,6 @@ export default async function ProductsPage({
|
|||||||
|
|
||||||
<section className="py-16 sm:py-20">
|
<section className="py-16 sm:py-20">
|
||||||
<Container>
|
<Container>
|
||||||
{categoryId && (
|
|
||||||
<div className="mb-8 flex items-center justify-between flex-wrap gap-3 rounded-2xl bg-surface px-5 py-4">
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
فیلتر شده بر اساس دستهبندی:{" "}
|
|
||||||
<strong className="text-primary">
|
|
||||||
{activeCategoryName || "دستهبندی انتخابی"}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href="/products"
|
|
||||||
className="text-sm font-semibold text-primary hover:text-primary-dark transition-colors"
|
|
||||||
>
|
|
||||||
حذف فیلتر
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -71,7 +61,6 @@ export default async function ProductsPage({
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
basePath="/products"
|
basePath="/products"
|
||||||
extraParams={categoryId ? { categoryId } : {}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
|
import { businessDashboardUrl, customerPortalUrl } from "@/lib/config";
|
||||||
|
import { useSession, userDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden
|
||||||
|
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 9l6 6 6-6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AccountMenu({ scrolled }: { scrolled: boolean }) {
|
||||||
|
const { user, isAuthenticated, ready, isWebsiteAdmin, logout, getAccessToken } = useSession();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [handoffBusy, setHandoffBusy] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const triggerClass = scrolled
|
||||||
|
? "border-primary/20 text-primary hover:bg-primary hover:text-white"
|
||||||
|
: "border-white/40 text-white hover:bg-white/10";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClickOutside = (event: Event) => {
|
||||||
|
if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (handoffBusy) return;
|
||||||
|
|
||||||
|
const baseUrl = businessDashboardUrl;
|
||||||
|
const popup = window.open("about:blank", "_blank");
|
||||||
|
setHandoffBusy(true);
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const res = await fetch("/api/auth/handoff", {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("handoff failed");
|
||||||
|
const data = (await res.json()) as { ticket?: string };
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (data.ticket) url.searchParams.set("handoff", data.ticket);
|
||||||
|
if (popup) {
|
||||||
|
popup.opener = null;
|
||||||
|
popup.location.replace(url.toString());
|
||||||
|
} else {
|
||||||
|
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (popup) popup.location.replace(baseUrl);
|
||||||
|
else window.open(baseUrl, "_blank", "noopener,noreferrer");
|
||||||
|
} finally {
|
||||||
|
setHandoffBusy(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready || !isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={customerPortalUrl}
|
||||||
|
className={`account-menu account-menu--login flex items-center gap-2 rounded-full border px-5 py-2.5 text-[15px] font-medium transition-colors ${triggerClass}`}
|
||||||
|
aria-label="ورود"
|
||||||
|
title="ورود"
|
||||||
|
>
|
||||||
|
<UserIcon />
|
||||||
|
<span>ورود</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = userDisplayName(user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="account-menu account-menu--user relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={`account-menu__trigger flex items-center gap-2 rounded-full border px-5 py-2.5 text-[15px] font-medium transition-colors ${triggerClass}`}
|
||||||
|
>
|
||||||
|
<UserIcon />
|
||||||
|
<span className="account-menu__name max-w-[9.5rem] truncate">{displayName} عزیز</span>
|
||||||
|
<ChevronIcon open={open} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div role="menu" className="account-menu__dropdown">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href={`${customerPortalUrl}/profile`}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
پروفایل من
|
||||||
|
</a>
|
||||||
|
{isWebsiteAdmin ? (
|
||||||
|
<a role="menuitem" href={businessDashboardUrl} onClick={openBusinessDashboard}>
|
||||||
|
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="account-menu__logout"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import Container from "./Container";
|
import Container from "./Container";
|
||||||
import ScrollReveal from "./ScrollReveal";
|
import ScrollReveal from "./ScrollReveal";
|
||||||
import { aspectRatioToCss, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
|
import { aspectRatioToCss, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee";
|
||||||
|
import { categoryPath } from "@/lib/urls";
|
||||||
import { categoryFallbackImage } from "@/lib/categoryIcons";
|
import { categoryFallbackImage } from "@/lib/categoryIcons";
|
||||||
import { toFa } from "@/lib/format";
|
import { toFa } from "@/lib/format";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ type Tile = {
|
|||||||
href: string;
|
href: string;
|
||||||
image: string;
|
image: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleEn?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,12 +32,14 @@ export default async function CategoryShowcase() {
|
|||||||
href: item.linkUrl || "/products",
|
href: item.linkUrl || "/products",
|
||||||
image: item.url,
|
image: item.url,
|
||||||
title: item.titleFa || "",
|
title: item.titleFa || "",
|
||||||
|
titleEn: item.titleEn || item.title || undefined,
|
||||||
}))
|
}))
|
||||||
: categories.map((cat) => ({
|
: categories.map((cat) => ({
|
||||||
key: cat.categoryId,
|
key: cat.categoryId,
|
||||||
href: `/products?categoryId=${cat.categoryId}`,
|
href: categoryPath({ id: cat.categoryId, nameFa: cat.name }),
|
||||||
image: categoryFallbackImage(cat.name),
|
image: categoryFallbackImage(cat.name),
|
||||||
title: cat.name,
|
title: cat.name,
|
||||||
|
titleEn: cat.nameEn || undefined,
|
||||||
subtitle: `${toFa(cat.count)} محصول`,
|
subtitle: `${toFa(cat.count)} محصول`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -81,6 +85,11 @@ export default async function CategoryShowcase() {
|
|||||||
{tile.title}
|
{tile.title}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{tile.titleEn && (
|
||||||
|
<span className="mt-1 block text-[11px] font-light tracking-[0.18em] text-white/60">
|
||||||
|
{tile.titleEn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{tile.subtitle && (
|
{tile.subtitle && (
|
||||||
<span className="mt-1 block text-xs text-white/75">
|
<span className="mt-1 block text-xs text-white/75">
|
||||||
{tile.subtitle}
|
{tile.subtitle}
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export default async function Features() {
|
|||||||
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-primary/5">
|
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-primary/5">
|
||||||
{f.linkUrl ? (
|
{f.linkUrl ? (
|
||||||
<a href={f.linkUrl} aria-label={f.title}>
|
<a href={f.linkUrl} aria-label={f.title}>
|
||||||
<Image src={f.icon} alt="" width={40} height={40} />
|
<Image src={f.icon} alt={f.title} width={40} height={40} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Image src={f.icon} alt="" width={40} height={40} />
|
<Image src={f.icon} alt={f.title} width={40} height={40} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-5 text-lg font-bold text-secondary">{f.title}</h3>
|
<h3 className="mt-5 text-lg font-bold text-secondary">{f.title}</h3>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Container from "./Container";
|
import Container from "./Container";
|
||||||
|
import MeshkeeCredit from "./MeshkeeCredit";
|
||||||
|
|
||||||
const phones = ["09104067935", "۰۲۵۳۳۳۴۲۶۳۳", "۰۲۵۳۳۳۴۲۹۶۷", "۰۲۵۳۳۳۴۲۹۸۳"];
|
const phones = ["09104067935", "۰۲۵۳۳۳۴۲۶۳۳", "۰۲۵۳۳۳۴۲۹۶۷", "۰۲۵۳۳۳۴۲۹۸۳"];
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function Footer() {
|
|||||||
fill
|
fill
|
||||||
className="object-cover object-left opacity-10 pointer-events-none select-none"
|
className="object-cover object-left opacity-10 pointer-events-none select-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container className="relative py-16 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10">
|
<Container className="relative py-16 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10">
|
||||||
<div>
|
<div>
|
||||||
<Image
|
<Image
|
||||||
@@ -71,9 +73,12 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<div className="border-t border-white/10">
|
<div className="footer-bottom">
|
||||||
<Container className="py-5 text-center text-sm text-white/50">
|
<Container>
|
||||||
طراحی و توسعه توسط تیم تجارت الکترونیک مشکی
|
<div className="footer-bottom-bar">
|
||||||
|
<div className="footer-bottom-site">آراد آریسمان</div>
|
||||||
|
<MeshkeeCredit />
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+11
-127
@@ -1,133 +1,17 @@
|
|||||||
"use client";
|
import { getTopLevelCategories } from "@/lib/meshkee";
|
||||||
|
import { categoryPath } from "@/lib/urls";
|
||||||
|
import HeaderNav from "./HeaderNav";
|
||||||
|
|
||||||
import Image from "next/image";
|
export default async function Header() {
|
||||||
import Link from "next/link";
|
const categories = await getTopLevelCategories();
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Container from "./Container";
|
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{ label: "صفحه اصلی", href: "/" },
|
|
||||||
{ label: "محصولات", href: "/products" },
|
|
||||||
{ label: "مقالات", href: "/blog" },
|
|
||||||
{ label: "درباره ما", href: "/about" },
|
|
||||||
{ label: "تماس با ما", href: "/contact" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [scrolledPast, setScrolledPast] = useState(false);
|
|
||||||
const canBeTransparent = pathname === "/";
|
|
||||||
const scrolled = !canBeTransparent || scrolledPast;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canBeTransparent) return;
|
|
||||||
function onScroll() {
|
|
||||||
setScrolledPast(window.scrollY > 20);
|
|
||||||
}
|
|
||||||
onScroll();
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
|
||||||
}, [canBeTransparent]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed inset-x-0 top-0 z-50">
|
<HeaderNav
|
||||||
<div className="bg-secondary text-white">
|
productCategories={categories.map((cat) => ({
|
||||||
<Container className="flex h-11 items-center justify-between text-sm">
|
id: cat.id,
|
||||||
<a
|
nameFa: cat.nameFa,
|
||||||
href="tel:09104067935"
|
href: categoryPath(cat),
|
||||||
className="flex items-center gap-2 text-white/90 hover:text-accent transition-colors"
|
}))}
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="shrink-0">
|
|
||||||
<path
|
|
||||||
d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.4 0 .8-.2 1L6.6 10.8z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
|
||||||
<span dir="ltr">09104067935</span>
|
|
||||||
</a>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<a href="#" aria-label="تلگرام" className="text-white/90 hover:text-accent transition-colors">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M21.5 3.5 2.8 10.9c-1 .4-1 1.7.1 2l4.7 1.5 1.8 5.6c.2.6 1 .8 1.4.3l2.5-2.7 4.9 3.6c.6.5 1.6.1 1.8-.6l3-15.6c.2-1-.7-1.8-1.5-1.5z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#" aria-label="واتساپ" className="text-white/90 hover:text-accent transition-colors">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M12 2a10 10 0 0 0-8.6 15.1L2 22l5-1.4A10 10 0 1 0 12 2Zm0 18.2a8.1 8.1 0 0 1-4.2-1.1l-.3-.2-3 .8.8-2.9-.2-.3A8.2 8.2 0 1 1 12 20.2Zm4.5-6.1c-.2-.1-1.5-.7-1.7-.8-.2-.1-.4-.1-.6.1-.2.2-.7.8-.8 1-.2.2-.3.2-.5.1a6.7 6.7 0 0 1-2-1.2 7.4 7.4 0 0 1-1.4-1.7c-.1-.2 0-.4.1-.5l.4-.4c.1-.2.2-.3.2-.5.1-.2 0-.4 0-.5-.1-.1-.6-1.5-.8-2-.2-.5-.4-.4-.6-.4h-.5c-.2 0-.5.1-.7.3-.2.2-.9.9-.9 2.2s.9 2.6 1.1 2.8c.1.2 1.9 3 4.7 4.1.7.3 1.2.4 1.6.6.7.2 1.3.2 1.8.1.5-.1 1.5-.6 1.7-1.2.2-.6.2-1.1.2-1.2-.1-.2-.3-.2-.5-.3Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#" aria-label="ایمیل" className="text-white/90 hover:text-accent transition-colors">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm0 2v.2l8 5.4 8-5.4V6H4Zm16 12V8.6l-7.4 5a1 1 0 0 1-1.2 0L4 8.6V18h16Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`border-b transition-all duration-300 ${
|
|
||||||
scrolled
|
|
||||||
? "bg-white/95 backdrop-blur border-black/5 shadow-sm"
|
|
||||||
: "bg-white/10 backdrop-blur-md border-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Container className="flex h-20 items-center justify-between">
|
|
||||||
<Link href="/" className="shrink-0">
|
|
||||||
<Image
|
|
||||||
src={scrolled ? "/images/logo-wordmark.png" : "/images/footer-logo.png"}
|
|
||||||
alt="آراد آریسمان"
|
|
||||||
width={140}
|
|
||||||
height={40}
|
|
||||||
className="h-10 w-auto"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<nav className="hidden lg:flex items-center gap-8">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<Link
|
|
||||||
key={link.label}
|
|
||||||
href={link.href}
|
|
||||||
className={`text-[15px] font-medium transition-colors ${
|
|
||||||
scrolled
|
|
||||||
? "text-secondary/80 hover:text-primary"
|
|
||||||
: "text-white/90 hover:text-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className={`flex items-center gap-2 rounded-full border px-5 py-2.5 text-[15px] font-medium transition-colors ${
|
|
||||||
scrolled
|
|
||||||
? "border-primary/20 text-primary hover:bg-primary hover:text-white"
|
|
||||||
: "border-white/40 text-white hover:bg-white/10"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path
|
|
||||||
d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
ورود
|
|
||||||
</a>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Container from "./Container";
|
||||||
|
import AccountMenu from "./AccountMenu";
|
||||||
|
|
||||||
|
type NavCategory = {
|
||||||
|
id: string;
|
||||||
|
nameFa: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
|
{ label: "مقالات", href: "/blog" },
|
||||||
|
{ label: "درباره ما", href: "/about" },
|
||||||
|
{ label: "تماس با ما", href: "/contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HeaderNav({
|
||||||
|
productCategories,
|
||||||
|
}: {
|
||||||
|
productCategories: NavCategory[];
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [scrolledPast, setScrolledPast] = useState(false);
|
||||||
|
const [productsOpen, setProductsOpen] = useState(false);
|
||||||
|
const productsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canBeTransparent = pathname === "/";
|
||||||
|
const scrolled = !canBeTransparent || scrolledPast;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canBeTransparent) return;
|
||||||
|
function onScroll() {
|
||||||
|
setScrolledPast(window.scrollY > 20);
|
||||||
|
}
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, [canBeTransparent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onPointerDown(e: MouseEvent) {
|
||||||
|
if (!productsRef.current?.contains(e.target as Node)) {
|
||||||
|
setProductsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const linkClass = scrolled
|
||||||
|
? "text-secondary/80 hover:text-primary"
|
||||||
|
: "text-white/90 hover:text-accent";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed inset-x-0 top-0 z-50">
|
||||||
|
<div className="bg-secondary text-white">
|
||||||
|
<Container className="flex h-11 items-center justify-between text-sm">
|
||||||
|
<a
|
||||||
|
href="tel:09104067935"
|
||||||
|
className="flex items-center gap-2 text-white/90 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="shrink-0">
|
||||||
|
<path
|
||||||
|
d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.4 0 .8-.2 1L6.6 10.8z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span dir="ltr">09104067935</span>
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href="#" aria-label="تلگرام" className="text-white/90 hover:text-accent transition-colors">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M21.5 3.5 2.8 10.9c-1 .4-1 1.7.1 2l4.7 1.5 1.8 5.6c.2.6 1 .8 1.4.3l2.5-2.7 4.9 3.6c.6.5 1.6.1 1.8-.6l3-15.6c.2-1-.7-1.8-1.5-1.5z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="#" aria-label="واتساپ" className="text-white/90 hover:text-accent transition-colors">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12 2a10 10 0 0 0-8.6 15.1L2 22l5-1.4A10 10 0 1 0 12 2Zm0 18.2a8.1 8.1 0 0 1-4.2-1.1l-.3-.2-3 .8.8-2.9-.2-.3A8.2 8.2 0 1 1 12 20.2Zm4.5-6.1c-.2-.1-1.5-.7-1.7-.8-.2-.1-.4-.1-.6.1-.2.2-.7.8-.8 1-.2.2-.3.2-.5.1a6.7 6.7 0 0 1-2-1.2 7.4 7.4 0 0 1-1.4-1.7c-.1-.2 0-.4.1-.5l.4-.4c.1-.2.2-.3.2-.5.1-.2 0-.4 0-.5-.1-.1-.6-1.5-.8-2-.2-.5-.4-.4-.6-.4h-.5c-.2 0-.5.1-.7.3-.2.2-.9.9-.9 2.2s.9 2.6 1.1 2.8c.1.2 1.9 3 4.7 4.1.7.3 1.2.4 1.6.6.7.2 1.3.2 1.8.1.5-.1 1.5-.6 1.7-1.2.2-.6.2-1.1.2-1.2-.1-.2-.3-.2-.5-.3Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="#" aria-label="ایمیل" className="text-white/90 hover:text-accent transition-colors">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm0 2v.2l8 5.4 8-5.4V6H4Zm16 12V8.6l-7.4 5a1 1 0 0 1-1.2 0L4 8.6V18h16Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`border-b transition-all duration-300 ${
|
||||||
|
scrolled
|
||||||
|
? "bg-white/95 backdrop-blur border-black/5 shadow-sm"
|
||||||
|
: "bg-white/10 backdrop-blur-md border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Container className="flex h-20 items-center justify-between">
|
||||||
|
<Link href="/" className="shrink-0">
|
||||||
|
<Image
|
||||||
|
src={scrolled ? "/images/logo-wordmark.png" : "/images/footer-logo.png"}
|
||||||
|
alt="آراد آریسمان"
|
||||||
|
width={140}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-auto"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden lg:flex items-center gap-8">
|
||||||
|
{navLinks.slice(0, 1).map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.label}
|
||||||
|
href={link.href}
|
||||||
|
className={`text-[15px] font-medium transition-colors ${linkClass}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={productsRef}
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() => setProductsOpen(true)}
|
||||||
|
onMouseLeave={() => setProductsOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className={`text-[15px] font-medium transition-colors ${linkClass}`}
|
||||||
|
>
|
||||||
|
محصولات
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="نمایش دستهبندی محصولات"
|
||||||
|
aria-expanded={productsOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
className={`inline-flex transition-colors ${linkClass}`}
|
||||||
|
onClick={() => setProductsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className={`transition-transform ${productsOpen ? "rotate-180" : ""}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 9l6 6 6-6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{productsOpen && productCategories.length > 0 && (
|
||||||
|
<div className="absolute top-full start-0 pt-2 min-w-[220px]">
|
||||||
|
<div className="rounded-xl border border-black/5 bg-white py-2 shadow-lg">
|
||||||
|
{productCategories.map((cat) => (
|
||||||
|
<Link
|
||||||
|
key={cat.id}
|
||||||
|
href={cat.href}
|
||||||
|
className="block px-4 py-2.5 text-[15px] text-secondary/85 hover:bg-primary/5 hover:text-primary transition-colors"
|
||||||
|
onClick={() => setProductsOpen(false)}
|
||||||
|
>
|
||||||
|
{cat.nameFa}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="my-1 border-t border-black/5" />
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="block px-4 py-2.5 text-[14px] font-medium text-primary hover:bg-primary/5 transition-colors"
|
||||||
|
onClick={() => setProductsOpen(false)}
|
||||||
|
>
|
||||||
|
همه محصولات
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{navLinks.slice(1).map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.label}
|
||||||
|
href={link.href}
|
||||||
|
className={`text-[15px] font-medium transition-colors ${linkClass}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<AccountMenu scrolled={scrolled} />
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -133,9 +133,9 @@ export default function HeroSlider({
|
|||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 pointer-events-none" />
|
||||||
|
|
||||||
<div className="relative z-10 flex h-full items-center pt-[125px] pointer-events-none">
|
<div className="relative z-10 flex h-full items-end pb-20 sm:pb-28 pt-[125px] pointer-events-none">
|
||||||
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
|
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
|
||||||
<div className="max-w-xl text-center sm:text-start mx-auto sm:mx-0 sm:ps-12">
|
<div className="max-w-3xl text-center sm:text-start mx-auto sm:mx-0 sm:ps-12">
|
||||||
{slide.eyebrow && (
|
{slide.eyebrow && (
|
||||||
<span
|
<span
|
||||||
className={`inline-block rounded-full bg-white/10 backdrop-blur px-4 py-1.5 text-sm font-semibold text-accent transition-all duration-700 delay-200 ${
|
className={`inline-block rounded-full bg-white/10 backdrop-blur px-4 py-1.5 text-sm font-semibold text-accent transition-all duration-700 delay-200 ${
|
||||||
@@ -147,9 +147,10 @@ export default function HeroSlider({
|
|||||||
{slide.eyebrow}
|
{slide.eyebrow}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{slide.title && (
|
{slide.title &&
|
||||||
|
(i === index ? (
|
||||||
<h1
|
<h1
|
||||||
className={`mt-5 text-3xl sm:text-5xl font-extrabold leading-[1.25] text-white transition-all duration-700 delay-300 ${
|
className={`mt-5 text-[clamp(1.1rem,3.6vw,3rem)] font-extrabold leading-tight text-white whitespace-nowrap transition-all duration-700 delay-300 ${
|
||||||
i === index
|
i === index
|
||||||
? "translate-y-0 opacity-100"
|
? "translate-y-0 opacity-100"
|
||||||
: "translate-y-4 opacity-0"
|
: "translate-y-4 opacity-0"
|
||||||
@@ -157,7 +158,18 @@ export default function HeroSlider({
|
|||||||
>
|
>
|
||||||
{slide.title}
|
{slide.title}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
) : (
|
||||||
|
<p
|
||||||
|
aria-hidden
|
||||||
|
className={`mt-5 text-[clamp(1.1rem,3.6vw,3rem)] font-extrabold leading-tight text-white whitespace-nowrap transition-all duration-700 delay-300 ${
|
||||||
|
i === index
|
||||||
|
? "translate-y-0 opacity-100"
|
||||||
|
: "translate-y-4 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{slide.title}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
{slide.subtitle && (
|
{slide.subtitle && (
|
||||||
<p
|
<p
|
||||||
className={`mt-5 text-base sm:text-lg text-white/85 transition-all duration-700 delay-500 ${
|
className={`mt-5 text-base sm:text-lg text-white/85 transition-all duration-700 delay-500 ${
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Container from "./Container";
|
||||||
|
import ScrollReveal from "./ScrollReveal";
|
||||||
|
|
||||||
|
export default function HomeContactStrip() {
|
||||||
|
return (
|
||||||
|
<section className="bg-[#f1e7d9] text-secondary">
|
||||||
|
<Container>
|
||||||
|
<ScrollReveal>
|
||||||
|
<div className="flex flex-col gap-6 py-10 sm:flex-row sm:items-center sm:justify-between sm:py-12">
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<span className="text-sm font-bold text-accent">تماس با ما</span>
|
||||||
|
<h2 className="mt-2 text-xl sm:text-2xl font-extrabold leading-relaxed text-primary">
|
||||||
|
برای مشاوره، سفارش یا هر سوالی — در کنار شما هستیم
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-5 shrink-0">
|
||||||
|
<a
|
||||||
|
href="tel:09104067935"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-primary/20 bg-white/40 px-5 py-3 text-[15px] font-medium text-primary hover:bg-white/70 transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.4 0 .8-.2 1L6.6 10.8z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span dir="ltr">09104067935</span>
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-primary px-7 py-3 text-[15px] font-bold text-white hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
تماس با ما
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollReveal>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function MeshkeeCredit() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://meshkee.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
dir="ltr"
|
||||||
|
className="meshkee-credit"
|
||||||
|
>
|
||||||
|
<span className="meshkee-credit__frame" aria-hidden="true">
|
||||||
|
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||||
|
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||||
|
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||||
|
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||||
|
</span>
|
||||||
|
<Image
|
||||||
|
src="/images/MeshkeeLogo-White.png"
|
||||||
|
alt="Meshkee"
|
||||||
|
width={42}
|
||||||
|
height={42}
|
||||||
|
className="meshkee-credit__logo"
|
||||||
|
/>
|
||||||
|
<span className="meshkee-credit__text">
|
||||||
|
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||||
|
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import Container from "./Container";
|
|
||||||
import { getStaticImageSlot } from "@/lib/meshkee";
|
|
||||||
|
|
||||||
const localPartners = [
|
|
||||||
"/images/partner-1.png",
|
|
||||||
"/images/partner-2.png",
|
|
||||||
"/images/partner-3.png",
|
|
||||||
"/images/partner-4.png",
|
|
||||||
"/images/partner-5.png",
|
|
||||||
"/images/partner-6.png",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function Partners() {
|
|
||||||
const slot = await getStaticImageSlot("brands");
|
|
||||||
const slotImages = slot?.images ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-14 border-y border-black/5">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-wrap items-center justify-around gap-x-14 gap-y-8 grayscale opacity-70">
|
|
||||||
{slotImages.length > 0
|
|
||||||
? slotImages.map((item) => {
|
|
||||||
const logo = (
|
|
||||||
<Image
|
|
||||||
src={item.url}
|
|
||||||
alt={item.titleFa || "نماد اعتماد"}
|
|
||||||
width={item.width || 95}
|
|
||||||
height={item.height || 82}
|
|
||||||
className="h-20 sm:h-24 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return item.linkUrl ? (
|
|
||||||
<a
|
|
||||||
key={item.id}
|
|
||||||
href={item.linkUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{logo}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span key={item.id}>{logo}</span>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: localPartners.map((src, i) => (
|
|
||||||
<Image
|
|
||||||
key={i}
|
|
||||||
src={src}
|
|
||||||
alt="نماد اعتماد"
|
|
||||||
width={95}
|
|
||||||
height={82}
|
|
||||||
className="h-20 sm:h-24 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
postReadingTime,
|
postReadingTime,
|
||||||
postTitle,
|
postTitle,
|
||||||
} from "@/lib/meshkee";
|
} from "@/lib/meshkee";
|
||||||
|
import { blogPath } from "@/lib/urls";
|
||||||
|
|
||||||
export default function PostCard({ post }: { post: Post }) {
|
export default function PostCard({ post }: { post: Post }) {
|
||||||
const title = postTitle(post);
|
const title = postTitle(post);
|
||||||
@@ -16,7 +17,7 @@ export default function PostCard({ post }: { post: Post }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/blog/${post.slug}`}
|
href={blogPath(post)}
|
||||||
className="group flex flex-col rounded-2xl border border-black/5 bg-white overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
|
className="group flex flex-col rounded-2xl border border-black/5 bg-white overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[16/10] bg-surface">
|
<div className="relative aspect-[16/10] bg-surface">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Product } from "@/lib/meshkee";
|
import type { Product } from "@/lib/meshkee";
|
||||||
import { productImage, productPriceLabel, stripHtml } from "@/lib/meshkee";
|
import { productImage, productPriceLabel, stripHtml } from "@/lib/meshkee";
|
||||||
|
import { productPath } from "@/lib/urls";
|
||||||
|
|
||||||
export default function ProductCard({ product }: { product: Product }) {
|
export default function ProductCard({ product }: { product: Product }) {
|
||||||
const image = productImage(product);
|
const image = productImage(product);
|
||||||
@@ -9,7 +10,7 @@ export default function ProductCard({ product }: { product: Product }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/products/${product.slug}`}
|
href={productPath(product)}
|
||||||
className="group rounded-2xl border border-black/5 bg-white overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
|
className="group rounded-2xl border border-black/5 bg-white overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-square bg-surface">
|
<div className="relative aspect-square bg-surface">
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "@/lib/session";
|
||||||
|
|
||||||
|
export default function Providers({
|
||||||
|
businessId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
businessId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <SessionProvider businessId={businessId}>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const websiteDomain =
|
||||||
|
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
|
||||||
|
process.env.WEBSITE_DOMAIN ??
|
||||||
|
process.env.NEXT_PUBLIC_DOMAIN ??
|
||||||
|
"aradarisman.com";
|
||||||
|
|
||||||
|
export const meshkeeApiBase = (
|
||||||
|
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
|
||||||
|
process.env.MESHKEE_API_BASE ??
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ??
|
||||||
|
"https://api.meshkee.com/api/v1"
|
||||||
|
).replace(/\/$/, "");
|
||||||
|
|
||||||
|
export const customerPortalUrl = (
|
||||||
|
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL ??
|
||||||
|
`https://customer.${websiteDomain}`
|
||||||
|
).replace(/\/$/, "");
|
||||||
|
|
||||||
|
export const businessDashboardUrl = (
|
||||||
|
process.env.NEXT_PUBLIC_BUSINESS_ORIGIN ?? `https://business.${websiteDomain}`
|
||||||
|
).replace(/\/$/, "");
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { meshkeeApiBase } from "@/lib/config";
|
||||||
|
|
||||||
|
export function getBearerToken(request: Request): string | null {
|
||||||
|
const auth = request.headers.get("authorization");
|
||||||
|
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function meshkeeFetch(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit & { token?: string | null },
|
||||||
|
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("Content-Type") && init?.body) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (init?.token) {
|
||||||
|
headers.set("Authorization", `Bearer ${init.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${meshkeeApiBase}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
let data: unknown = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: res.ok, status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonResponse(data: unknown, status = 200) {
|
||||||
|
return Response.json(data, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorizedResponse() {
|
||||||
|
return jsonResponse({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
+145
-3
@@ -3,6 +3,52 @@ import { toFa } from "@/lib/format";
|
|||||||
const API_BASE = "https://api.meshkee.com/api/v1";
|
const API_BASE = "https://api.meshkee.com/api/v1";
|
||||||
export const TENANT_DOMAIN = "aradarisman.com";
|
export const TENANT_DOMAIN = "aradarisman.com";
|
||||||
|
|
||||||
|
export type Tenant = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nameFa: string;
|
||||||
|
slug: string;
|
||||||
|
domain: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getTenant(): Promise<Tenant | null> {
|
||||||
|
try {
|
||||||
|
return await meshkeeFetch<Tenant>(`/tenants/${TENANT_DOMAIN}`, 3600);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBusinessId(): Promise<string> {
|
||||||
|
const tenant = await getTenant();
|
||||||
|
return tenant?.id != null ? String(tenant.id) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebsiteFavicon = {
|
||||||
|
faviconUrl: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
logoDarkUrl: string | null;
|
||||||
|
hasDedicatedFavicon: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFavicon: WebsiteFavicon = {
|
||||||
|
faviconUrl: null,
|
||||||
|
logoUrl: null,
|
||||||
|
logoDarkUrl: null,
|
||||||
|
hasDedicatedFavicon: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getWebsiteFavicon(): Promise<WebsiteFavicon> {
|
||||||
|
try {
|
||||||
|
return await meshkeeFetch<WebsiteFavicon>(
|
||||||
|
`/tenants/${TENANT_DOMAIN}/website/favicon`,
|
||||||
|
3600
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return defaultFavicon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Business-managed image overrides (hero slider, category tiles, feature
|
* Business-managed image overrides (hero slider, category tiles, feature
|
||||||
* icons, about photo, brand logos). Each slot has a stable `key`; when a
|
* icons, about photo, brand logos). Each slot has a stable `key`; when a
|
||||||
@@ -139,6 +185,17 @@ export async function getProduct(slug: string): Promise<Product | null> {
|
|||||||
return data.product as Product;
|
return data.product as Product;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProductById(id: string): Promise<Product | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/tenants/${TENANT_DOMAIN}/products/by-id/${id}`,
|
||||||
|
{ next: { revalidate: 300 } }
|
||||||
|
);
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) throw new Error(`Meshkee API product id "${id}" failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.product as Product;
|
||||||
|
}
|
||||||
|
|
||||||
export function productImage(product: Product): string | null {
|
export function productImage(product: Product): string | null {
|
||||||
return product.thumbnail || product.image || product.thumbnailUrl || null;
|
return product.thumbnail || product.image || product.thumbnailUrl || null;
|
||||||
}
|
}
|
||||||
@@ -151,9 +208,72 @@ export function productPriceLabel(product: Product): string {
|
|||||||
return "تماس بگیرید";
|
return "تماس بگیرید";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
nameFa: string;
|
||||||
|
slug: string;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCategories(): Promise<Category[]> {
|
||||||
|
try {
|
||||||
|
const data = await meshkeeFetch<{ items: Category[] }>(
|
||||||
|
`/tenants/${TENANT_DOMAIN}/categories`
|
||||||
|
);
|
||||||
|
return data.items.filter((c) => c.isActive);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoryById(id: string): Promise<Category | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/tenants/${TENANT_DOMAIN}/categories/by-id/${id}`,
|
||||||
|
{ next: { revalidate: 300 } }
|
||||||
|
);
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) throw new Error(`Meshkee API category id "${id}" failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.category as Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopLevelCategories(): Promise<Category[]> {
|
||||||
|
const items = await getCategories();
|
||||||
|
return items
|
||||||
|
.filter((c) => c.parentId === null)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.sortOrder - b.sortOrder ||
|
||||||
|
a.nameFa.localeCompare(b.nameFa, "fa")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Includes the category itself and all nested descendants. */
|
||||||
|
export function getCategoryDescendantIds(
|
||||||
|
categoryId: string,
|
||||||
|
categories: Category[]
|
||||||
|
): string[] {
|
||||||
|
const ids = new Set<string>([categoryId]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parentId && ids.has(c.parentId) && !ids.has(c.id)) {
|
||||||
|
ids.add(c.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
export type CategorySummary = {
|
export type CategorySummary = {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
nameEn: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
};
|
};
|
||||||
@@ -172,7 +292,7 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
|
|||||||
|
|
||||||
const perId = new Map<
|
const perId = new Map<
|
||||||
string,
|
string,
|
||||||
{ id: string; name: string; count: number; image: string | null }
|
{ id: string; name: string; nameEn: string | null; count: number; image: string | null }
|
||||||
>();
|
>();
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (!p.categoryId || !p.categoryNameFa) continue;
|
if (!p.categoryId || !p.categoryNameFa) continue;
|
||||||
@@ -181,8 +301,15 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
|
|||||||
if (entry) {
|
if (entry) {
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
if (!entry.image && img) entry.image = img;
|
if (!entry.image && img) entry.image = img;
|
||||||
|
if (!entry.nameEn && p.categoryName) entry.nameEn = p.categoryName;
|
||||||
} else {
|
} else {
|
||||||
perId.set(p.categoryId, { id: p.categoryId, name: p.categoryNameFa, count: 1, image: img });
|
perId.set(p.categoryId, {
|
||||||
|
id: p.categoryId,
|
||||||
|
name: p.categoryNameFa,
|
||||||
|
nameEn: p.categoryName ?? null,
|
||||||
|
count: 1,
|
||||||
|
image: img,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +320,7 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
|
|||||||
perName.set(entry.name, {
|
perName.set(entry.name, {
|
||||||
categoryId: entry.id,
|
categoryId: entry.id,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
|
nameEn: entry.nameEn,
|
||||||
count: entry.count,
|
count: entry.count,
|
||||||
image: entry.image,
|
image: entry.image,
|
||||||
});
|
});
|
||||||
@@ -200,8 +328,12 @@ export async function getCategorySummary(limit = 8): Promise<CategorySummary[]>
|
|||||||
}
|
}
|
||||||
existing.count += entry.count;
|
existing.count += entry.count;
|
||||||
const currentBest = perId.get(existing.categoryId)?.count ?? 0;
|
const currentBest = perId.get(existing.categoryId)?.count ?? 0;
|
||||||
if (entry.count > currentBest) existing.categoryId = entry.id;
|
if (entry.count > currentBest) {
|
||||||
|
existing.categoryId = entry.id;
|
||||||
|
if (entry.nameEn) existing.nameEn = entry.nameEn;
|
||||||
|
}
|
||||||
if (!existing.image && entry.image) existing.image = entry.image;
|
if (!existing.image && entry.image) existing.image = entry.image;
|
||||||
|
if (!existing.nameEn && entry.nameEn) existing.nameEn = entry.nameEn;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(perName.values())
|
return Array.from(perName.values())
|
||||||
@@ -290,6 +422,16 @@ export async function getPost(slug: string): Promise<Post | null> {
|
|||||||
return (data.blog ?? data.post ?? data) as Post;
|
return (data.blog ?? data.post ?? data) as Post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPostById(id: string): Promise<Post | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/blogs/by-id/${id}`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) throw new Error(`Meshkee API blog id "${id}" failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.blog ?? data.post ?? data) as Post;
|
||||||
|
}
|
||||||
|
|
||||||
export function postTitle(post: Post): string {
|
export function postTitle(post: Post): string {
|
||||||
return post.nameFa || post.titleFa || post.title || "بدون عنوان";
|
return post.nameFa || post.titleFa || post.title || "بدون عنوان";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ?? "https://aradarisman.com";
|
||||||
|
|
||||||
|
export function canonicalPath(path: string): string {
|
||||||
|
if (!path || path === "/") return SITE_URL;
|
||||||
|
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageMetadata({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
path,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
}): Metadata {
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: canonicalPath(path),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearSharedAuthCookies,
|
||||||
|
hydrateLocalTokensFromSharedCookies,
|
||||||
|
SHARED_ACCESS_TOKEN_KEY,
|
||||||
|
SHARED_REFRESH_TOKEN_KEY,
|
||||||
|
writeSharedAuthCookies,
|
||||||
|
} from "@/lib/shared-auth-cookie";
|
||||||
|
|
||||||
|
export type AuthBusinessMembership = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
teamRole: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
cellNumber?: string;
|
||||||
|
email?: string;
|
||||||
|
roles?: string[];
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
|
businesses?: AuthBusinessMembership[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionContextValue = {
|
||||||
|
user: AuthUser | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
businessId: string;
|
||||||
|
isWebsiteAdmin: boolean;
|
||||||
|
logout: () => void;
|
||||||
|
getAccessToken: () => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUser(raw: Partial<AuthUser> & { id?: string | number }): AuthUser | null {
|
||||||
|
if (raw.id == null) return null;
|
||||||
|
return {
|
||||||
|
id: String(raw.id),
|
||||||
|
firstName: raw.firstName ?? undefined,
|
||||||
|
lastName: raw.lastName ?? undefined,
|
||||||
|
cellNumber: raw.cellNumber ?? undefined,
|
||||||
|
email: raw.email ?? undefined,
|
||||||
|
roles: Array.isArray(raw.roles) ? raw.roles : undefined,
|
||||||
|
isSuperAdmin: Boolean(raw.isSuperAdmin),
|
||||||
|
businesses: Array.isArray(raw.businesses)
|
||||||
|
? raw.businesses.map((business) => ({
|
||||||
|
id: String(business.id),
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
isOwner: Boolean(business.isOwner),
|
||||||
|
teamRole: business.teamRole ?? null,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean {
|
||||||
|
if (!user || !businessId) return false;
|
||||||
|
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
|
||||||
|
return Boolean(
|
||||||
|
user.businesses?.some(
|
||||||
|
(business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
|
|
||||||
|
function readToken(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToken(key: string, value: string | null) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, value);
|
||||||
|
else localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionProvider({
|
||||||
|
children,
|
||||||
|
businessId,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
businessId: string;
|
||||||
|
}) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const getAccessToken = useCallback(() => {
|
||||||
|
hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY);
|
||||||
|
return (
|
||||||
|
readToken(SHARED_ACCESS_TOKEN_KEY) ||
|
||||||
|
readToken("accessToken") ||
|
||||||
|
readToken("meshkeeToken") ||
|
||||||
|
readToken("token")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearTokens = useCallback(() => {
|
||||||
|
writeToken(SHARED_ACCESS_TOKEN_KEY, null);
|
||||||
|
writeToken(SHARED_REFRESH_TOKEN_KEY, null);
|
||||||
|
writeToken("accessToken", null);
|
||||||
|
writeToken("meshkeeToken", null);
|
||||||
|
writeToken("token", null);
|
||||||
|
clearSharedAuthCookies();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = readToken(SHARED_REFRESH_TOKEN_KEY);
|
||||||
|
writeSharedAuthCookies(token, refresh ?? undefined);
|
||||||
|
|
||||||
|
fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { user?: AuthUser } & AuthUser;
|
||||||
|
const next = normalizeUser(data.user ?? data);
|
||||||
|
if (next) setUser(next);
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setReady(true));
|
||||||
|
}, [clearTokens, getAccessToken]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearTokens();
|
||||||
|
}, [clearTokens]);
|
||||||
|
|
||||||
|
const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
isAuthenticated: Boolean(user),
|
||||||
|
ready,
|
||||||
|
businessId,
|
||||||
|
isWebsiteAdmin,
|
||||||
|
logout,
|
||||||
|
getAccessToken,
|
||||||
|
}),
|
||||||
|
[user, ready, businessId, isWebsiteAdmin, logout, getAccessToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) throw new Error("useSession must be used within SessionProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userDisplayName(user: AuthUser | null | undefined) {
|
||||||
|
if (!user) return "کاربر";
|
||||||
|
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return full || user.cellNumber || user.email || "کاربر";
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/** Shared auth cookies so storefront + customer.{domain} share one login. */
|
||||||
|
|
||||||
|
export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
|
||||||
|
export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 2800;
|
||||||
|
const MAX_CHUNKS = 24;
|
||||||
|
|
||||||
|
function cookieDomain(): string | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
const host = window.location.hostname.toLowerCase();
|
||||||
|
if (host === "localhost" || host === "127.0.0.1") return undefined;
|
||||||
|
const parts = host.split(".").filter(Boolean);
|
||||||
|
if (parts.length < 2) return undefined;
|
||||||
|
return `.${parts.slice(-2).join(".")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRawCookie(name: string): string | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
|
||||||
|
if (!match) return null;
|
||||||
|
return match.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRawCookie(name: string, value: string | null) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const domain = cookieDomain();
|
||||||
|
if (value == null) {
|
||||||
|
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
|
||||||
|
if (domain) cookie += `; domain=${domain}`;
|
||||||
|
document.cookie = cookie;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxAge = 60 * 60 * 24 * 30;
|
||||||
|
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||||
|
if (domain) cookie += `; domain=${domain}`;
|
||||||
|
document.cookie = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChunkedCookie(name: string) {
|
||||||
|
writeRawCookie(name, null);
|
||||||
|
const n = Number(readRawCookie(`${name}_n`) || "0");
|
||||||
|
writeRawCookie(`${name}_n`, null);
|
||||||
|
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
|
||||||
|
writeRawCookie(`${name}_${i}`, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCookie(name: string, value: string | null) {
|
||||||
|
clearChunkedCookie(name);
|
||||||
|
if (value == null || value === "") return;
|
||||||
|
|
||||||
|
const encoded = encodeURIComponent(value);
|
||||||
|
if (encoded.length <= 3500) {
|
||||||
|
writeRawCookie(name, encoded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
|
||||||
|
chunks.push(encoded.slice(i, i + CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
writeRawCookie(`${name}_n`, String(chunks.length));
|
||||||
|
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name: string): string | null {
|
||||||
|
const n = Number(readRawCookie(`${name}_n`) || "0");
|
||||||
|
if (n > 0) {
|
||||||
|
let encoded = "";
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const part = readRawCookie(`${name}_${i}`);
|
||||||
|
if (part == null) return null;
|
||||||
|
encoded += part;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(encoded);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readRawCookie(name);
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
|
||||||
|
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
|
||||||
|
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSharedAuthCookies() {
|
||||||
|
writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
|
||||||
|
writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSharedAccessToken(): string | null {
|
||||||
|
return readCookie(SHARED_ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSharedRefreshToken(): string | null {
|
||||||
|
return readCookie(SHARED_REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrateLocalTokensFromSharedCookies(
|
||||||
|
accessKey = SHARED_ACCESS_TOKEN_KEY,
|
||||||
|
refreshKey = SHARED_REFRESH_TOKEN_KEY,
|
||||||
|
): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
if (localStorage.getItem(accessKey)) return false;
|
||||||
|
const access = readSharedAccessToken();
|
||||||
|
if (!access) return false;
|
||||||
|
localStorage.setItem(accessKey, access);
|
||||||
|
const refresh = readSharedRefreshToken();
|
||||||
|
if (refresh) localStorage.setItem(refreshKey, refresh);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Farsi letters kept, spaces → -, other punctuation stripped. */
|
||||||
|
export function slugifyFa(text: string): string {
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^\p{Script=Arabic}\p{N}-]+/gu, "")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Category, Post, Product } from "@/lib/meshkee";
|
||||||
|
import { postTitle } from "@/lib/meshkee";
|
||||||
|
import { slugifyFa } from "@/lib/slugify";
|
||||||
|
|
||||||
|
export function productSlug(product: Pick<Product, "nameFa">): string {
|
||||||
|
return slugifyFa(product.nameFa);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productPath(product: Pick<Product, "id" | "nameFa">): string {
|
||||||
|
const slug = productSlug(product);
|
||||||
|
return `/products/${product.id}/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productUrl(product: Pick<Product, "id" | "nameFa">): string {
|
||||||
|
return productPath(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categorySlug(category: Pick<Category, "nameFa">): string {
|
||||||
|
return slugifyFa(category.nameFa);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryPath(category: Pick<Category, "id" | "nameFa">): string {
|
||||||
|
const slug = categorySlug(category);
|
||||||
|
return `/products/category/${category.id}/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryUrl(category: Pick<Category, "id" | "nameFa">): string {
|
||||||
|
return categoryPath(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogSlug(post: Post): string {
|
||||||
|
return slugifyFa(postTitle(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogPath(post: Pick<Post, "id"> & Post): string {
|
||||||
|
const slug = blogSlug(post);
|
||||||
|
return `/blog/${post.id}/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogUrl(post: Pick<Post, "id"> & Post): string {
|
||||||
|
return blogPath(post);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user