diff --git a/package-lock.json b/package-lock.json index b912f69..4e2f4d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -631,9 +631,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -650,9 +647,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -669,9 +663,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -688,9 +679,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -707,9 +695,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -726,9 +711,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -745,9 +727,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -764,9 +743,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -783,9 +759,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -808,9 +781,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -833,9 +803,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -858,9 +825,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -883,9 +847,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -908,9 +869,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -933,9 +891,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -958,9 +913,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1228,9 +1180,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1247,9 +1196,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1266,9 +1212,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1285,9 +1228,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1526,9 +1466,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1546,9 +1483,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1566,9 +1500,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1586,9 +1517,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2139,9 +2067,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2156,9 +2081,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2173,9 +2095,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2190,9 +2109,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2207,9 +2123,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2224,9 +2137,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2241,9 +2151,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2258,9 +2165,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2275,9 +2179,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2292,9 +2193,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4979,9 +4877,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5003,9 +4898,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5027,9 +4919,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5051,9 +4940,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/public/images/MeshkeeLogo-White.png b/public/images/MeshkeeLogo-White.png new file mode 100644 index 0000000..a10ecd3 Binary files /dev/null and b/public/images/MeshkeeLogo-White.png differ diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 6b43c1e..d3eac31 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -6,13 +6,14 @@ import PageBanner from "@/components/PageBanner"; import MissionVision from "@/components/MissionVision"; import ImportCountries from "@/components/ImportCountries"; 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: "درباره ما | آراد آریسمان", description: "آشنایی با شرکت آراد آریسمان؛ بیش از ۲۳ سال تجربه در تولید و عرضه مکمل‌های غذایی دام، طیور و زنبور عسل.", -}; + path: "/about", +}); export default function AboutPage() { return ( @@ -84,8 +85,6 @@ export default function AboutPage() { - -
diff --git a/src/app/api/auth/handoff/route.ts b/src/app/api/auth/handoff/route.ts new file mode 100644 index 0000000..93365f5 --- /dev/null +++ b/src/app/api/auth/handoff/route.ts @@ -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); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..53435fb --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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); +} diff --git a/src/app/blog/[id]/[slug]/page.tsx b/src/app/blog/[id]/[slug]/page.tsx new file mode 100644 index 0000000..16af46e --- /dev/null +++ b/src/app/blog/[id]/[slug]/page.tsx @@ -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 { + 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 ( + <> + + +
+ + + + + + بازگشت به مقالات + + +
+ {post.categoryNameFa && ( + + {post.categoryNameFa} + + )} + {postDate(post)} + + {postReadingTime(post)} +
+ + {image && ( +
+ {title} +
+ )} + + {content && ( +
+ )} + +
+
+

نویسنده: تیم آراد آریسمان

+

+ برای مشاوره تخصصی تغذیه دام و طیور با ما در تماس باشید. +

+
+ + تماس با ما + +
+ +
+ + {related.length > 0 && ( +
+ +

+ مقالات مرتبط +

+
+ {related.map((p) => ( + + ))} +
+
+
+ )} + + ); +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index c955e83..dc7de45 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,43 +1,9 @@ -import type { Metadata } from "next"; -import Image from "next/image"; -import Link from "next/link"; -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"; +import { notFound, permanentRedirect } from "next/navigation"; +import { getPost } from "@/lib/meshkee"; +import { blogPath } from "@/lib/urls"; -export const revalidate = 300; - -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 { - 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({ +/** Legacy /blog/{slug} → canonical /blog/{id}/{titleSlug} */ +export default async function LegacyBlogRedirect({ params, }: { params: Promise<{ slug: string }>; @@ -45,98 +11,5 @@ export default async function BlogDetailPage({ const { slug } = await params; const post = await getPost(slug); if (!post) notFound(); - - 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 ( - <> - - -
- - - - - - بازگشت به مقالات - - -
- {post.categoryNameFa && ( - - {post.categoryNameFa} - - )} - {postDate(post)} - - {postReadingTime(post)} -
- - {image && ( -
- {title} -
- )} - - {content && ( -
- )} - -
-
-

نویسنده: تیم آراد آریسمان

-

- برای مشاوره تخصصی تغذیه دام و طیور با ما در تماس باشید. -

-
- - تماس با ما - -
- -
- - {related.length > 0 && ( -
- -

- مقالات مرتبط -

-
- {related.map((p) => ( - - ))} -
-
-
- )} - - ); + permanentRedirect(blogPath(post)); } diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index 7af30a8..d333d4f 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,15 +1,16 @@ -import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/seo"; import Container from "@/components/Container"; import PageBanner from "@/components/PageBanner"; import PostCard from "@/components/PostCard"; import Pagination from "@/components/Pagination"; import { getPosts } from "@/lib/meshkee"; -export const metadata: Metadata = { +export const metadata = pageMetadata({ title: "مقالات | آراد آریسمان", description: "مقالات آموزشی درباره تغذیه دام، طیور و زنبور عسل از تیم آراد آریسمان.", -}; + path: "/blog", +}); const PAGE_SIZE = 6; diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 7bc0e12..4c0ce5e 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,14 +1,15 @@ -import type { Metadata } from "next"; import Container from "@/components/Container"; import PageBanner from "@/components/PageBanner"; import ContactInfo from "@/components/ContactInfo"; import ContactForm from "@/components/ContactForm"; +import { pageMetadata } from "@/lib/seo"; -export const metadata: Metadata = { +export const metadata = pageMetadata({ title: "تماس با ما | آراد آریسمان", description: "راه‌های ارتباطی با شرکت آراد آریسمان؛ آدرس، تلفن تماس و فرم ارسال پیام.", -}; + path: "/contact", +}); const mapQuery = encodeURIComponent( "قم، شهرک صنعتی شکوهیه، خیابان شهید بروجردی" diff --git a/src/app/globals.css b/src/app/globals.css index 5882e12..7550ced 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -68,3 +68,224 @@ body { .rich-content strong { 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; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8fee45..82ee252 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,9 @@ import type { Metadata } from "next"; import { Vazirmatn } from "next/font/google"; import Header from "@/components/Header"; 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"; const vazirmatn = Vazirmatn({ @@ -10,13 +13,30 @@ const vazirmatn = Vazirmatn({ weight: ["300", "400", "500", "600", "700", "800"], }); -export const metadata: Metadata = { - title: "آراد آریسمان | صفحه اصلی", - description: - "شرکت آراد آریسمان، تولید و عرضه مکمل‌ها، ویتامین‌ها و افزودنی‌های خوراک دام و طیور با بیش از ۲۳ سال تجربه.", -}; +const defaultFavicon = "/favicon.ico"; + +export async function generateMetadata(): Promise { + const branding = await getWebsiteFavicon(); + const iconUrl = branding.faviconUrl || defaultFavicon; + + return { + metadataBase: new URL(SITE_URL), + title: { + default: "آراد آریسمان", + template: "%s", + }, + description: + "شرکت آراد آریسمان، تولید و عرضه مکمل‌ها، ویتامین‌ها و افزودنی‌های خوراک دام و طیور با بیش از ۲۳ سال تجربه.", + icons: { + icon: iconUrl, + shortcut: iconUrl, + }, + }; +} + +export default async function RootLayout({ children }: LayoutProps<"/">) { + const businessId = await getBusinessId(); -export default function RootLayout({ children }: LayoutProps<"/">) { return ( ) { className={`${vazirmatn.variable} h-full antialiased`} > -
-
{children}
-
+ +
+
{children}
+
+ ); diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..be90a4a --- /dev/null +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -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", + }, + } + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f06d519..94eb3ee 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,20 @@ +import type { Metadata } from "next"; import HeroSlider from "@/components/HeroSlider"; import CategoryShowcase from "@/components/CategoryShowcase"; +import HomeContactStrip from "@/components/HomeContactStrip"; import Features from "@/components/Features"; import Stats from "@/components/Stats"; import Products from "@/components/Products"; import About from "@/components/About"; -import Partners from "@/components/Partners"; import { getStaticImageSlot } from "@/lib/meshkee"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata: Metadata = pageMetadata({ + title: "آراد آریسمان | صفحه اصلی", + description: + "شرکت آراد آریسمان، تولید و عرضه مکمل‌ها، ویتامین‌ها و افزودنی‌های خوراک دام و طیور با بیش از ۲۳ سال تجربه.", + path: "/", +}); export default async function Home() { const sliderSlot = await getStaticImageSlot("slider"); @@ -14,11 +23,11 @@ export default async function Home() { <> + - ); } diff --git a/src/app/products/[id]/[slug]/page.tsx b/src/app/products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..50354cb --- /dev/null +++ b/src/app/products/[id]/[slug]/page.tsx @@ -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 { + 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 ( + <> + + +
+ + + + + + بازگشت به محصولات + + +
+
+ {image ? ( + {product.nameFa} + ) : ( +
+ آ +
+ )} + {product.categoryNameFa && ( + + {product.categoryNameFa} + + )} +
+ +
+ {shortDesc && ( +

{shortDesc}

+ )} + +
+ + {productPriceLabel(product)} + + {!product.store.inStock && ( + + (استعلام موجودی) + + )} +
+ +
+ + 09104067935 + + + استعلام قیمت + +
+
+
+ + {product.descriptionHtml && ( +
+

معرفی محصول

+
+
+ )} + +
+ + {related.length > 0 && ( +
+ +

+ محصولات مرتبط +

+
+ {related.map((p) => ( + + ))} +
+
+
+ )} + + ); +} diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index f17f247..51bbaf7 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -1,40 +1,9 @@ -import type { Metadata } from "next"; -import Image from "next/image"; -import Link from "next/link"; -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"; +import { notFound, permanentRedirect } from "next/navigation"; +import { getProduct } from "@/lib/meshkee"; +import { productPath } from "@/lib/urls"; -export const revalidate = 300; - -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 { - 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({ +/** Legacy /products/{slug} → canonical /products/{id}/{nameFaSlug} */ +export default async function LegacyProductRedirect({ params, }: { params: Promise<{ slug: string }>; @@ -42,121 +11,5 @@ export default async function ProductDetailPage({ const { slug } = await params; const product = await getProduct(slug); if (!product) notFound(); - - 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 ( - <> - - -
- - - - - - بازگشت به محصولات - - -
-
- {image ? ( - {product.nameFa} - ) : ( -
- آ -
- )} - {product.categoryNameFa && ( - - {product.categoryNameFa} - - )} -
- -
-

- {product.nameFa} -

- {shortDesc && ( -

{shortDesc}

- )} - -
- - {productPriceLabel(product)} - - {!product.store.inStock && ( - - (استعلام موجودی) - - )} -
- -
- - 09104067935 - - - استعلام قیمت - -
-
-
- - {product.descriptionHtml && ( -
-

معرفی محصول

-
-
- )} - -
- - {related.length > 0 && ( -
- -

- محصولات مرتبط -

-
- {related.map((p) => ( - - ))} -
-
-
- )} - - ); + permanentRedirect(productPath(product)); } diff --git a/src/app/products/category/[id]/[slug]/page.tsx b/src/app/products/category/[id]/[slug]/page.tsx new file mode 100644 index 0000000..dd1544e --- /dev/null +++ b/src/app/products/category/[id]/[slug]/page.tsx @@ -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 { + 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 ( + <> + + +
+ +
+ + نمایش محصولات دسته{" "} + {category.nameFa} + + + همه محصولات + +
+ + {items.length > 0 ? ( + <> +
+ {items.map((product) => ( + + ))} +
+ + + + ) : ( +

+ در حال حاضر محصولی در این دسته‌بندی وجود ندارد. +

+ )} +
+
+ + ); +} diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 2d2b8c5..f221596 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -1,16 +1,18 @@ -import type { Metadata } from "next"; -import Link from "next/link"; +import { 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 { 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: "محصولات | آراد آریسمان", description: "مکمل‌های غذایی، ویتامینه و معدنی دام، طیور و زنبور عسل؛ محصولات آراد آریسمان را مشاهده کنید.", -}; + path: "/products", +}); const PAGE_SIZE = 6; @@ -20,17 +22,22 @@ export default async function ProductsPage({ searchParams: Promise<{ page?: string; categoryId?: string }>; }) { 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({ page: requestedPage, pageSize: PAGE_SIZE, - categoryId, }); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const currentPage = Math.min(requestedPage, totalPages); - const activeCategoryName = categoryId ? items[0]?.categoryNameFa : null; return ( <> @@ -42,23 +49,6 @@ export default async function ProductsPage({
- {categoryId && ( -
- - فیلتر شده بر اساس دسته‌بندی:{" "} - - {activeCategoryName || "دسته‌بندی انتخابی"} - - - - حذف فیلتر - -
- )} - {items.length > 0 ? ( <>
@@ -71,7 +61,6 @@ export default async function ProductsPage({ currentPage={currentPage} totalPages={totalPages} basePath="/products" - extraParams={categoryId ? { categoryId } : {}} /> ) : ( diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx new file mode 100644 index 0000000..47eafc4 --- /dev/null +++ b/src/components/AccountMenu.tsx @@ -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 ( + + + + ); +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + + + ); +} + +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(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) { + 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 ( + + + ورود + + ); + } + + const displayName = userDisplayName(user); + + return ( +
+ + + {open ? ( +
+ setOpen(false)} + > + پروفایل من + + {isWebsiteAdmin ? ( + + {handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"} + + ) : null} + +
+ ) : null} +
+ ); +} diff --git a/src/components/CategoryShowcase.tsx b/src/components/CategoryShowcase.tsx index cf70d0c..c13e004 100644 --- a/src/components/CategoryShowcase.tsx +++ b/src/components/CategoryShowcase.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import Container from "./Container"; import ScrollReveal from "./ScrollReveal"; import { aspectRatioToCss, getCategorySummary, getStaticImageSlot } from "@/lib/meshkee"; +import { categoryPath } from "@/lib/urls"; import { categoryFallbackImage } from "@/lib/categoryIcons"; import { toFa } from "@/lib/format"; @@ -11,6 +12,7 @@ type Tile = { href: string; image: string; title: string; + titleEn?: string; subtitle?: string; }; @@ -30,12 +32,14 @@ export default async function CategoryShowcase() { href: item.linkUrl || "/products", image: item.url, title: item.titleFa || "", + titleEn: item.titleEn || item.title || undefined, })) : categories.map((cat) => ({ key: cat.categoryId, - href: `/products?categoryId=${cat.categoryId}`, + href: categoryPath({ id: cat.categoryId, nameFa: cat.name }), image: categoryFallbackImage(cat.name), title: cat.name, + titleEn: cat.nameEn || undefined, subtitle: `${toFa(cat.count)} محصول`, })); @@ -81,6 +85,11 @@ export default async function CategoryShowcase() { {tile.title} )} + {tile.titleEn && ( + + {tile.titleEn} + + )} {tile.subtitle && ( {tile.subtitle} diff --git a/src/components/Features.tsx b/src/components/Features.tsx index c6ec49b..e1a778a 100644 --- a/src/components/Features.tsx +++ b/src/components/Features.tsx @@ -51,10 +51,10 @@ export default async function Features() {
{f.linkUrl ? ( - + {f.title} ) : ( - + {f.title} )}

{f.title}

diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index cd50351..5b37ee6 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,7 @@ import Image from "next/image"; import Link from "next/link"; import Container from "./Container"; +import MeshkeeCredit from "./MeshkeeCredit"; const phones = ["09104067935", "۰۲۵۳۳۳۴۲۶۳۳", "۰۲۵۳۳۳۴۲۹۶۷", "۰۲۵۳۳۳۴۲۹۸۳"]; @@ -21,6 +22,7 @@ export default function Footer() { fill className="object-cover object-left opacity-10 pointer-events-none select-none" /> +
-
- - طراحی و توسعه توسط تیم تجارت الکترونیک مشکی +
+ +
+
آراد آریسمان
+ +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0e0a6e5..1135763 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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"; -import Link from "next/link"; -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]); +export default async function Header() { + const categories = await getTopLevelCategories(); return ( -
- - -
- - - آراد آریسمان - - - - - - - - - ورود - - -
-
+ ({ + id: cat.id, + nameFa: cat.nameFa, + href: categoryPath(cat), + }))} + /> ); } diff --git a/src/components/HeaderNav.tsx b/src/components/HeaderNav.tsx new file mode 100644 index 0000000..aa2da89 --- /dev/null +++ b/src/components/HeaderNav.tsx @@ -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(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 ( +
+ + +
+ + + آراد آریسمان + + + + + + +
+
+ ); +} diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx index ca6a4de..485da20 100644 --- a/src/components/HeroSlider.tsx +++ b/src/components/HeroSlider.tsx @@ -133,9 +133,9 @@ export default function HeroSlider({ <>
-
+
-
+
{slide.eyebrow && ( )} - {slide.title && ( -

- {slide.title} -

- )} + {slide.title && + (i === index ? ( +

+ {slide.title} +

+ ) : ( +

+ {slide.title} +

+ ))} {slide.subtitle && (

+ + +

+
+ تماس با ما +

+ برای مشاوره، سفارش یا هر سوالی — در کنار شما هستیم +

+
+ +
+ + + + + 09104067935 + + + تماس با ما + +
+
+ + +
+ ); +} diff --git a/src/components/MeshkeeCredit.tsx b/src/components/MeshkeeCredit.tsx new file mode 100644 index 0000000..690df14 --- /dev/null +++ b/src/components/MeshkeeCredit.tsx @@ -0,0 +1,31 @@ +import Image from "next/image"; + +export default function MeshkeeCredit() { + return ( + + + ); +} diff --git a/src/components/Partners.tsx b/src/components/Partners.tsx deleted file mode 100644 index 65618c8..0000000 --- a/src/components/Partners.tsx +++ /dev/null @@ -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 ( -
- -
- {slotImages.length > 0 - ? slotImages.map((item) => { - const logo = ( - {item.titleFa - ); - return item.linkUrl ? ( - - {logo} - - ) : ( - {logo} - ); - }) - : localPartners.map((src, i) => ( - نماد اعتماد - ))} -
-
-
- ); -} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 63ad716..05112fd 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -8,6 +8,7 @@ import { postReadingTime, postTitle, } from "@/lib/meshkee"; +import { blogPath } from "@/lib/urls"; export default function PostCard({ post }: { post: Post }) { const title = postTitle(post); @@ -16,7 +17,7 @@ export default function PostCard({ post }: { post: Post }) { return (
diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index e1f6197..ed3b1e4 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import Link from "next/link"; import type { Product } from "@/lib/meshkee"; import { productImage, productPriceLabel, stripHtml } from "@/lib/meshkee"; +import { productPath } from "@/lib/urls"; export default function ProductCard({ product }: { product: Product }) { const image = productImage(product); @@ -9,7 +10,7 @@ export default function ProductCard({ product }: { product: Product }) { return (
diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..abe0ebe --- /dev/null +++ b/src/components/Providers.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SessionProvider } from "@/lib/session"; + +export default function Providers({ + businessId, + children, +}: { + businessId: string; + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..b572797 --- /dev/null +++ b/src/lib/config.ts @@ -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(/\/$/, ""); diff --git a/src/lib/meshkee-server.ts b/src/lib/meshkee-server.ts new file mode 100644 index 0000000..97b9bce --- /dev/null +++ b/src/lib/meshkee-server.ts @@ -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); +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index e9299f3..681fe19 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -3,6 +3,52 @@ import { toFa } from "@/lib/format"; const API_BASE = "https://api.meshkee.com/api/v1"; export const TENANT_DOMAIN = "aradarisman.com"; +export type Tenant = { + id: number; + name: string; + nameFa: string; + slug: string; + domain: string; +}; + +export async function getTenant(): Promise { + try { + return await meshkeeFetch(`/tenants/${TENANT_DOMAIN}`, 3600); + } catch { + return null; + } +} + +export async function getBusinessId(): Promise { + 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 { + try { + return await meshkeeFetch( + `/tenants/${TENANT_DOMAIN}/website/favicon`, + 3600 + ); + } catch { + return defaultFavicon; + } +} + /** * Business-managed image overrides (hero slider, category tiles, feature * icons, about photo, brand logos). Each slot has a stable `key`; when a @@ -139,6 +185,17 @@ export async function getProduct(slug: string): Promise { return data.product as Product; } +export async function getProductById(id: string): Promise { + 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 { return product.thumbnail || product.image || product.thumbnailUrl || null; } @@ -151,9 +208,72 @@ export function productPriceLabel(product: Product): string { return "تماس بگیرید"; } +export type Category = { + id: string; + parentId: string | null; + name: string; + nameFa: string; + slug: string; + sortOrder: number; + isActive: boolean; +}; + +export async function getCategories(): Promise { + 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 { + 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 { + 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([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 = { categoryId: string; name: string; + nameEn: string | null; count: number; image: string | null; }; @@ -172,7 +292,7 @@ export async function getCategorySummary(limit = 8): Promise const perId = new Map< 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) { if (!p.categoryId || !p.categoryNameFa) continue; @@ -181,8 +301,15 @@ export async function getCategorySummary(limit = 8): Promise if (entry) { entry.count += 1; if (!entry.image && img) entry.image = img; + if (!entry.nameEn && p.categoryName) entry.nameEn = p.categoryName; } 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 perName.set(entry.name, { categoryId: entry.id, name: entry.name, + nameEn: entry.nameEn, count: entry.count, image: entry.image, }); @@ -200,8 +328,12 @@ export async function getCategorySummary(limit = 8): Promise } existing.count += entry.count; 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.nameEn && entry.nameEn) existing.nameEn = entry.nameEn; } return Array.from(perName.values()) @@ -290,6 +422,16 @@ export async function getPost(slug: string): Promise { return (data.blog ?? data.post ?? data) as Post; } +export async function getPostById(id: string): Promise { + 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 { return post.nameFa || post.titleFa || post.title || "بدون عنوان"; } diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..905c8f1 --- /dev/null +++ b/src/lib/seo.ts @@ -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), + }, + }; +} diff --git a/src/lib/session.tsx b/src/lib/session.tsx new file mode 100644 index 0000000..bc953a3 --- /dev/null +++ b/src/lib/session.tsx @@ -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 & { 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(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(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 {children}; +} + +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 || "کاربر"; +} diff --git a/src/lib/shared-auth-cookie.ts b/src/lib/shared-auth-cookie.ts new file mode 100644 index 0000000..87843c4 --- /dev/null +++ b/src/lib/shared-auth-cookie.ts @@ -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; +} diff --git a/src/lib/slugify.ts b/src/lib/slugify.ts new file mode 100644 index 0000000..10d8b24 --- /dev/null +++ b/src/lib/slugify.ts @@ -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, ""); +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts new file mode 100644 index 0000000..7e7de84 --- /dev/null +++ b/src/lib/urls.ts @@ -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): string { + return slugifyFa(product.nameFa); +} + +export function productPath(product: Pick): string { + const slug = productSlug(product); + return `/products/${product.id}/${encodeURIComponent(slug)}`; +} + +export function productUrl(product: Pick): string { + return productPath(product); +} + +export function categorySlug(category: Pick): string { + return slugifyFa(category.nameFa); +} + +export function categoryPath(category: Pick): string { + const slug = categorySlug(category); + return `/products/category/${category.id}/${encodeURIComponent(slug)}`; +} + +export function categoryUrl(category: Pick): string { + return categoryPath(category); +} + +export function blogSlug(post: Post): string { + return slugifyFa(postTitle(post)); +} + +export function blogPath(post: Pick & Post): string { + const slug = blogSlug(post); + return `/blog/${post.id}/${encodeURIComponent(slug)}`; +} + +export function blogUrl(post: Pick & Post): string { + return blogPath(post); +}