Compare commits

...
6 Commits
Author SHA1 Message Date
Alireza HassaniandCursor c84147e765 Add Torob tags, Meshkee SEO routes, and updated static-image slots.
Product pages emit crawler-readable meta, public URLs use id plus Farsi slugs, and the homepage catalog matches the dashboard slot keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 16:12:26 +03:30
Websites Agent f42c30a4b7 Enable Next.js standalone output for leaner production deploy 2026-08-29 04:20:28 +00:00
Alireza HassaniandCursor 49b83223f1 Wire homepage banners to Meshkee static-image slots.
Publish a slot catalog so the dashboard can import keys, and keep local placeholders when a slot is empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:54:10 +03:30
Alireza HassaniandCursor 46e4835360 Stop importing gitignored sample images so production build can succeed.
Deploy checkouts do a hard reset, so Unsplash files ignored locally are missing on the server and next build fails with Module not found.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:38:50 +03:30
Alireza HassaniandCursor 16340c3b48 Add guest cart drawer, Continue handoff, and shared customer login.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:33:39 +03:30
Alireza HassaniandCursor ad05241a77 Stop tracking sample Unsplash images so they stay local.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:33:19 +03:30
71 changed files with 2623 additions and 469 deletions
+17
View File
@@ -0,0 +1,17 @@
---
description: Keep Meshkee static-image slot catalog in sync with on-page image sizes.
globs: lib/static-images.ts,app/meshkee/**,components/HeroSlider.tsx,components/Banners.tsx,components/Categories.tsx,components/Brands.tsx,components/AboutUs.tsx,app/page.tsx,app/aboutus/page.tsx
alwaysApply: false
---
# Static image slot catalog
Named homepage slots are defined in `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`) and published at `GET /meshkee/static-image-slots`.
When you change a slot’s layout, CSS aspect ratio, or displayed width:
1. Update that slot’s `aspectRatio` (`W:H`) and `recommendedWidth` in `STATIC_IMAGE_SLOT_CATALOG` in the **same change**.
2. Keep using `slot.aspectRatio` from the Meshkee API on the rendered frame.
3. Do not invent upload or CMS APIs. Empty/failed slots keep the local placeholder.
Keys: `slider` (duplicatable), `two-banner-below-slider` (2), `categories` (duplicatable), `four-banner` (4), `two-banner-row` (2), `brands` (duplicatable), `about-us-image` (2).
+5
View File
@@ -20,6 +20,11 @@
# production
/build
# Local Unsplash samples — never import these in app code.
# Production deploy does a hard git reset, so gitignored files disappear
# and `next build` fails with Module not found. Use public/images/placeholders/.
/src/assets/images/samples/
# misc
.DS_Store
*.pem
+47
View File
@@ -0,0 +1,47 @@
# Salar Store — AI context
Persian RTL storefront for **salarstore.com**. Next.js App Router. Do not invent Meshkee admin/upload APIs.
## Meshkee
- Apex domain: `salarstore.com`
- API: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_API_BASE_URL`)
- Tenant: `GET /tenants/salarstore.com` → `businessId`
- Public content: `/tenants/salarstore.com/...` (no auth)
- Customer app: `https://customer.salarstore.com`
- Business app: `https://business.salarstore.com`
Docs: https://api.meshkee.com/docs/website
## Static image slots
Dashboard-replaceable homepage images. Fetch `GET /tenants/salarstore.com/website/static-images?pageKey=home`. Empty/fail → keep local placeholders.
The business dashboard **Refresh** button imports keys from:
`GET https://salarstore.com/meshkee/static-image-slots`
Source of truth: `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`).
| key | kind | itemCount | aspectRatio | where |
| --- | --- | --- | --- | --- |
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero. Overlay `titleFa` / `titleEn` / `subtext` when set. |
| `two-banner-below-slider` | list | 2 | `13:5` | Two tiles under the slider. Overlay `titleFa`, `titleEn`, `subtext` like the original UI. |
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row. `titleFa` + `titleEn` beside the icon. |
| `four-banner` | list | 4 | `21:9` | 2×2 banner grid. Overlay `titleFa` / `titleEn` / `subtext` when set. |
| `two-banner-row` | list | 2 | `12:5` | Two-up row after top-selling. |
| `brands` | list (duplicatable) | unbounded | `2:1` | Brand logo carousel. |
| `about-us-image` | list | 2 | `8:5` | Home about uses image 1. `/aboutus` uses image 1 as hero and image 2 as the side portrait. |
Previous keys (`banners-below-slider`, `four-banner-section`, `three-banner-row`) are still read as aliases until the dashboard Refresh imports the catalog again.
Rules:
- Use `slot.images` (all if `kind=list`, first if `kind=single`). `url` → `<img src>`. `linkUrl` → wrap in `<a>`.
- Apply `slot.aspectRatio` from the API on the rendered frame.
- **If a slot’s on-page size or aspect ratio changes, update `STATIC_IMAGE_SLOT_CATALOG` (`aspectRatio` + `recommendedWidth`) in the same change** so the dashboard cropper stays in sync.
- Do not add upload/CMS endpoints on this website.
## Cart + customer session
Guest cart key `meshkee-guest-cart` (localStorage + cookie on `.salarstore.com`). Line `id` is the **store item variant id**. Continue hands off to `https://customer.salarstore.com/checkout/cart#guestCart=...` (guests go via login with that hash inside `redirect`). Shared cookies: `meshkee_customer_access_token` / `meshkee_customer_refresh_token`.
+36
View File
@@ -0,0 +1,36 @@
Build storefront cart UI + Continue handoff + shared customer login for this Meshkee website.
## Remote only
- Production Meshkee API and live site only — no localhost / local ports.
- Customer app: https://customer.<WEBSITE_DOMAIN>
- Business app: https://business.<WEBSITE_DOMAIN>
Production env:
NEXT_PUBLIC_API_BASE_URL=https://api.meshkee.com/api/v1
NEXT_PUBLIC_DOMAIN=<WEBSITE_DOMAIN>
NEXT_PUBLIC_CUSTOMER_ORIGIN=https://customer.<WEBSITE_DOMAIN>
NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.<WEBSITE_DOMAIN>
## Scope
Website only. Do not rebuild customer checkout, payment, addresses, or account pages — those are global on customer.<WEBSITE_DOMAIN>.
## Cart UI
- Header cart icon + count badge
- Popup/drawer: lines, quantity, remove, total, Continue
- Cart lines use store item variant id (not product id)
- Guest cart: localStorage key meshkee-guest-cart + cookie on .<WEBSITE_DOMAIN>
- Guest line: { id, name, slug?, price, quantity, image? } (id = variant id)
## Continue
- Logged in → customer.<WEBSITE_DOMAIN>/checkout/cart#guestCart=<base64url JSON>
- Guest → customer.<WEBSITE_DOMAIN>/login?redirect=/checkout/cart#guestCart=<base64url JSON>
- If no guest lines left, open /checkout/cart without the hash
## Shared session
- Cookies: meshkee_customer_access_token / meshkee_customer_refresh_token on .<WEBSITE_DOMAIN> (SameSite=Lax; chunk large tokens)
- Hydrate site session from those cookies
- ورود → customer login with redirect=/
- Logged in: "{firstName} عزیز" menu → profile, orders, logout; “مدیریت وبسایت” only for site admins → business app (handoff when available)
Match existing site design. Keep the change limited to cart popup, Continue handoff, and header auth.
+20
View File
@@ -0,0 +1,20 @@
Wire this page to salarstore.com static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- banners-below-slider > two image
- categories > duplicatable
- four-banner-section → four slot
- three-banner-row → three slot
- about us image - one slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
+41 -16
View File
@@ -1,27 +1,44 @@
import type { Metadata } from "next";
import Image from "next/image";
import { PageHero } from "@/components/PageHero";
import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import {
STATIC_IMAGE_KEYS,
getStaticImageSlot,
imagesForSlot,
slotAlt,
} from "@/lib/static-images";
export const metadata: Metadata = {
title: `درباره ما | ${SITE.nameFa}`,
description: SITE.about,
alternates: { canonical: "/aboutus" },
};
export default function AboutPage() {
export default async function AboutPage() {
const slot = await getStaticImageSlot(STATIC_IMAGE_KEYS.aboutUsImage);
const images = slot ? imagesForSlot([slot], STATIC_IMAGE_KEYS.aboutUsImage) : [];
const hero = images[0];
const side = images[1];
return (
<>
<PageHero title="درباره ما" english="About Us" />
<section className="container-site py-10 md:py-14">
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
<Image
src="/images/pages/about-bg.webp"
alt=""
fill
priority
sizes="1200px"
className="object-cover object-center"
/>
{hero ? (
<SlotImage image={hero} fill alt={slotAlt(hero, SITE.nameFa)} />
) : (
<Image
src="/images/pages/about-bg.webp"
alt={SITE.nameFa}
fill
priority
sizes="1200px"
className="object-cover object-center"
/>
)}
<div
className="pointer-events-none absolute inset-0"
style={{
@@ -42,13 +59,21 @@ export default function AboutPage() {
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
</div>
<div className="relative mx-auto w-full max-w-xs">
<Image
src="/images/pages/about-side.png"
alt={SITE.nameFa}
width={300}
height={603}
className="h-auto w-full object-contain"
/>
{side ? (
<SlotImage
image={side}
alt={slotAlt(side, SITE.nameFa)}
imgClassName="h-auto w-full object-contain"
/>
) : (
<Image
src="/images/pages/about-side.png"
alt={SITE.nameFa}
width={300}
height={603}
className="h-auto w-full object-contain"
/>
)}
</div>
</div>
</section>
+17
View File
@@ -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);
}
+10
View File
@@ -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);
}
@@ -1,25 +1,27 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import { Media } from "@/components/Media";
import { PageHero } from "@/components/PageHero";
import { RichContent } from "@/components/RichContent";
import { getBlog } from "@/lib/api";
import { getBlogById } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PLACEHOLDER } from "@/lib/data";
import { blogHref, blogTitleSlug } from "@/lib/paths";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ slug: string }>;
params: Promise<{ id: string; slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const { id } = await params;
try {
const blog = await getBlog(slug);
const blog = await getBlogById(id);
if (!blog) return { title: SITE.nameFa };
return {
title: `${blog.title} | ${SITE.nameFa}`,
description: blog.abstract || SITE.aboutShort,
description: blog.abstract?.trim() || SITE.aboutShort,
alternates: { canonical: blogHref(blog) },
};
} catch {
return { title: SITE.nameFa };
@@ -27,10 +29,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const blog = await getBlog(slug);
const { id, slug } = await params;
const blog = await getBlogById(id);
if (!blog) notFound();
if (decodeURIComponent(slug) !== blogTitleSlug(blog.title)) {
permanentRedirect(blogHref(blog));
}
const author = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
return (
+20
View File
@@ -0,0 +1,20 @@
import { notFound, permanentRedirect } from "next/navigation";
import { getBlog, getBlogById } from "@/lib/api";
import { blogHref } from "@/lib/paths";
/** Legacy `/blog/{slug-or-id}` → canonical `/blog/{id}/{titleSlug}`. */
export default async function LegacyBlogRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const byId = await getBlogById(id);
if (byId) permanentRedirect(blogHref(byId));
const bySlug = await getBlog(id);
if (bySlug) permanentRedirect(blogHref(bySlug));
notFound();
}
+5 -4
View File
@@ -9,7 +9,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `وبلاگ | ${SITE.nameFa}`,
description: "آخرین مقالات سالار استور",
description: "مقالات کوهنوردی، کمپینگ و راهنمای خرید لوازم ورزشی از سالار استور",
alternates: { canonical: "/blog" },
};
type Props = {
@@ -35,11 +36,11 @@ export default async function BlogPage({ searchParams }: Props) {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
key={post.id || post.slug}
href={post.href || `/blog/${post.slug}`}
className="overflow-hidden rounded-2xl border border-black/5 bg-white hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="400px" />
<Media src={post.image} alt={post.title} className="aspect-[16/10]" sizes="400px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h2 className="mt-2 text-base font-bold leading-8 line-clamp-2">{post.title}</h2>
+2 -1
View File
@@ -6,7 +6,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `تماس با ما | ${SITE.nameFa}`,
description: SITE.address,
description: `تماس با فروشگاه لوازم ورزشی سالار استور — ${SITE.address}`,
alternates: { canonical: "/contactus" },
};
const contacts = [
+13 -5
View File
@@ -1,8 +1,11 @@
import type { Metadata } from "next";
import { Carter_One, Oswald, Vazirmatn } from "next/font/google";
import { AppProviders } from "@/components/AppProviders";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { TopHeader } from "@/components/TopHeader";
import { getTenant } from "@/lib/api";
import { getMenuCategories } from "@/lib/nav";
import { SITE } from "@/lib/site";
import "./globals.css";
@@ -26,18 +29,23 @@ const carter = Carter_One({
});
export const metadata: Metadata = {
metadataBase: new URL(`https://${SITE.domain}`),
title: `${SITE.nameFa} | ${SITE.tagline}`,
description: SITE.aboutShort,
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [tenant, menuCategories] = await Promise.all([getTenant(), getMenuCategories()]);
return (
<html lang="fa" dir="rtl" className={`${vazir.variable} ${oswald.variable} ${carter.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-white text-ink">
<TopHeader />
<Header />
<main className="flex-1">{children}</main>
<Footer />
<AppProviders businessId={tenant ? String(tenant.id) : ""}>
<TopHeader />
<Header menuCategories={menuCategories} />
<main className="flex-1">{children}</main>
<Footer />
</AppProviders>
</body>
</html>
);
+17
View File
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/static-images";
export function GET() {
return NextResponse.json(
{ slots: STATIC_IMAGE_SLOT_CATALOG },
{
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=60",
},
},
);
}
+7
View File
@@ -1,4 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `صفحه پیدا نشد | ${SITE.nameFa}`,
description: "صفحه مورد نظر در سالار استور پیدا نشد.",
};
export default function NotFound() {
return (
+48 -8
View File
@@ -1,23 +1,63 @@
import { AboutUs } from "@/components/AboutUs";
import { BannerGrid4, BannerRow3 } from "@/components/Banners";
import { BannerGrid4, BannerRow } from "@/components/Banners";
import { BlogCarousel } from "@/components/BlogCarousel";
import { Brands } from "@/components/Brands";
import { Categories } from "@/components/Categories";
import { HeroSlider } from "@/components/HeroSlider";
import { SpecialSale } from "@/components/SpecialSale";
import { TopSelling } from "@/components/TopSelling";
import {
STATIC_IMAGE_KEYS,
getStaticImageSlots,
imagesForSlot,
slotByKey,
} from "@/lib/static-images";
import { SITE } from "@/lib/site";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: `${SITE.nameFa} | ${SITE.tagline}`,
description: SITE.aboutShort,
alternates: { canonical: "/" },
};
export default async function Home() {
const slots = await getStaticImageSlots("home");
const sliderSlot = slotByKey(slots, STATIC_IMAGE_KEYS.slider);
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider);
const categorySlot = slotByKey(slots, STATIC_IMAGE_KEYS.categories);
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBanner);
const twoRowSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerRow);
const brandsSlot = slotByKey(slots, STATIC_IMAGE_KEYS.brands);
const aboutSlot = slotByKey(slots, STATIC_IMAGE_KEYS.aboutUsImage);
export default function Home() {
return (
<>
<HeroSlider />
<h1 className="sr-only">
{SITE.nameFa}؛ {SITE.tagline}
</h1>
<HeroSlider
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
aspectRatio={sliderSlot?.aspectRatio}
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider)}
sideAspectRatio={sideSlot?.aspectRatio}
/>
<SpecialSale />
<Categories />
<BannerGrid4 />
<Categories
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
aspectRatio={categorySlot?.aspectRatio}
/>
<BannerGrid4
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBanner)}
aspectRatio={fourSlot?.aspectRatio}
/>
<TopSelling />
<BannerRow3 />
<Brands />
<AboutUs />
<BannerRow
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)}
aspectRatio={twoRowSlot?.aspectRatio}
/>
<Brands images={imagesForSlot(slots, STATIC_IMAGE_KEYS.brands)} aspectRatio={brandsSlot?.aspectRatio} />
<AboutUs images={imagesForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)} aspectRatio={aboutSlot?.aspectRatio} />
<BlogCarousel />
</>
);
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { ProductTabs } from "@/components/ProductTabs";
import { ProductView } from "@/components/ProductView";
import { getComments, getProductById, getStoreItemByProduct } from "@/lib/api";
import { productHref, productNameSlug, productTitle } from "@/lib/paths";
import { SITE } from "@/lib/site";
import { torobMetadata } from "@/lib/torob";
type Props = {
params: Promise<{ id: string; slug: string }>;
};
async function loadProduct(id: string) {
return getProductById(id);
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
try {
const product = await loadProduct(id);
if (!product) return { title: SITE.nameFa };
let storeItem = null;
try {
storeItem = await getStoreItemByProduct(product.id);
} catch {
storeItem = null;
}
const title = productTitle(product);
return {
title: `${title} | ${SITE.nameFa}`,
description: product.summary?.trim() || SITE.tagline,
alternates: { canonical: productHref(product) },
...torobMetadata(product, storeItem),
};
} catch {
return { title: SITE.nameFa };
}
}
export default async function ProductDetailsPage({ params }: Props) {
const { id, slug } = await params;
const product = await loadProduct(id);
if (!product) notFound();
const title = productTitle(product);
if (decodeURIComponent(slug) !== productNameSlug(title)) {
permanentRedirect(productHref(product));
}
const [storeItem, comments] = await Promise.all([
getStoreItemByProduct(product.id),
getComments("product", product.id),
]);
return (
<>
<PageHero
title={title}
english={product.title}
crumbs={[{ name: "محصولات", href: "/products" }, { name: title }]}
/>
<section className="container-site py-10 md:py-14">
<ProductView product={product} variants={storeItem?.variants ?? []} />
<ProductTabs productId={product.id} descriptionHtml={product.descriptionHtml} comments={comments} />
</section>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound, permanentRedirect } from "next/navigation";
import { getCategoryById, getCategoryBySlug, getProduct, getProductById } from "@/lib/api";
import { categoryHref, productHref } from "@/lib/paths";
/** Legacy `/products/{id-or-slug}` → canonical product or category URL. */
export default async function LegacyProductOrCategoryRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const productById = await getProductById(id);
if (productById) permanentRedirect(productHref(productById));
const productBySlug = await getProduct(id);
if (productBySlug) permanentRedirect(productHref(productBySlug));
const categoryById = await getCategoryById(id);
if (categoryById) permanentRedirect(categoryHref(categoryById));
const categoryBySlug = await getCategoryBySlug(id);
if (categoryBySlug) permanentRedirect(categoryHref(categoryBySlug));
notFound();
}
-111
View File
@@ -1,111 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Pagination } from "@/components/Pagination";
import { ProductGrid } from "@/components/ProductGrid";
import { ProductTabs } from "@/components/ProductTabs";
import { ProductView } from "@/components/ProductView";
import { getCategories, getCategoryBySlug, getComments, getProduct, getStoreItemByProduct } from "@/lib/api";
import { listMappedProductsForCategory } from "@/lib/mappers";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
try {
const product = await getProduct(slug);
if (product) {
return {
title: `${product.nameFa || product.title} | ${SITE.nameFa}`,
description: product.summary || SITE.tagline,
};
}
const category = await getCategoryBySlug(slug);
if (category) {
return {
title: `${category.nameFa || category.name} | ${SITE.nameFa}`,
description: SITE.tagline,
};
}
} catch {
// fall through
}
return { title: SITE.nameFa };
}
export default async function ProductOrCategoryPage({ params, searchParams }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
if (product) {
const [storeItem, comments] = await Promise.all([
getStoreItemByProduct(product.id),
getComments("product", product.id),
]);
return (
<>
<PageHero
title={product.nameFa || product.title}
english={product.title}
crumbs={[{ name: "محصولات", href: "/products" }, { name: product.nameFa || product.title }]}
/>
<section className="container-site py-10 md:py-14">
<ProductView product={product} variants={storeItem?.variants ?? []} />
<ProductTabs productId={product.id} descriptionHtml={product.descriptionHtml} comments={comments} />
</section>
</>
);
}
const [category, categories] = await Promise.all([getCategoryBySlug(slug), getCategories()]);
if (!category) notFound();
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const children = categories.filter((item) => item.parentId === category.id);
const data = await listMappedProductsForCategory(
category.id,
children.map((item) => item.id),
page,
16,
);
return (
<>
<PageHero
title={category.nameFa || category.name}
english={category.name}
crumbs={[{ name: "محصولات", href: "/products" }, { name: category.nameFa || category.name }]}
/>
<section className="container-site py-10 md:py-14">
{children.length > 0 && (
<div className="mb-8 flex flex-wrap gap-2">
{children.map((item) => (
<Link
key={item.id}
href={`/products/${item.slug}`}
className="rounded-full border border-black/10 px-4 py-2 text-sm hover:border-brand hover:text-brand"
>
{item.nameFa || item.name}
</Link>
))}
</div>
)}
<ProductGrid products={data.items} />
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
makeHref={(nextPage) => (nextPage > 1 ? `/products/${slug}?page=${nextPage}` : `/products/${slug}`)}
/>
</section>
</>
);
}
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, permanentRedirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Pagination } from "@/components/Pagination";
import { ProductGrid } from "@/components/ProductGrid";
import { getCategories, getCategoryById } from "@/lib/api";
import { listMappedProductsForCategory } from "@/lib/mappers";
import { categoryHref, categoryNameSlug, categoryTitle } from "@/lib/paths";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ id: string; slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
try {
const category = await getCategoryById(id);
if (!category) return { title: SITE.nameFa };
const title = categoryTitle(category);
return {
title: `${title} | ${SITE.nameFa}`,
description: category.description?.trim() || `خرید ${title} از فروشگاه لوازم ورزشی و کوهنوردی سالار استور`,
alternates: { canonical: categoryHref(category) },
};
} catch {
return { title: SITE.nameFa };
}
}
export default async function CategoryProductsPage({ params, searchParams }: Props) {
const { id, slug } = await params;
const [category, categories] = await Promise.all([getCategoryById(id), getCategories()]);
if (!category || category.isActive === false) notFound();
const title = categoryTitle(category);
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
if (decodeURIComponent(slug) !== categoryNameSlug(title)) {
const href = categoryHref(category);
permanentRedirect(page > 1 ? `${href}?page=${page}` : href);
}
const children = categories.filter((item) => item.parentId === category.id);
const data = await listMappedProductsForCategory(
category.id,
children.map((item) => item.id),
page,
16,
);
const baseHref = categoryHref(category);
return (
<>
<PageHero
title={title}
english={category.name}
crumbs={[{ name: "محصولات", href: "/products" }, { name: title }]}
/>
<section className="container-site py-10 md:py-14">
{children.length > 0 && (
<div className="mb-8 flex flex-wrap gap-2">
{children.map((item) => (
<Link
key={item.id}
href={categoryHref(item)}
className="rounded-full border border-black/10 px-4 py-2 text-sm hover:border-brand hover:text-brand"
>
{item.nameFa || item.name}
</Link>
))}
</div>
)}
<ProductGrid products={data.items} />
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
makeHref={(nextPage) => (nextPage > 1 ? `${baseHref}?page=${nextPage}` : baseHref)}
/>
</section>
</>
);
}
+2 -1
View File
@@ -7,7 +7,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `محصولات | ${SITE.nameFa}`,
description: SITE.tagline,
description: "خرید لوازم ورزشی و کوهنوردی از فروشگاه سالار استور",
alternates: { canonical: "/products" },
};
type Props = {
+27 -10
View File
@@ -1,8 +1,19 @@
import Image from "next/image";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
export function AboutUs({
images = [],
aspectRatio,
}: {
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const image = images[0];
export function AboutUs() {
return (
<section id="about" className="container-site mt-14">
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
@@ -14,15 +25,21 @@ export function AboutUs() {
مشاهده بیشتر
</Link>
</div>
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
{image ? (
<div className="relative overflow-hidden rounded-2xl bg-white" style={slotAspectStyle(aspectRatio, "8:5")}>
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
</div>
) : (
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
)}
</div>
</section>
);
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { businessDashboardUrl, customerDashboardUrl } from "@/lib/customer";
import { useSession } from "@/lib/session";
import { IconChevron, IconUser } from "./Icons";
const itemClass =
"block w-full px-3 py-2.5 text-right text-sm text-ink transition-colors hover:bg-brand-soft hover:text-brand";
export function AccountMenu({
displayName,
isWebsiteAdmin,
onLogout,
compact = false,
}: {
displayName: string;
isWebsiteAdmin: boolean;
onLogout: () => void;
compact?: boolean;
}) {
const [open, setOpen] = useState(false);
const [handoffBusy, setHandoffBusy] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { getAccessToken } = useSession();
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);
}
}
return (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="menu"
className={`flex max-w-[12rem] items-center gap-2 rounded-full px-3 py-2 text-sm hover:bg-zinc-100 ${
compact ? "w-full justify-between px-0 hover:bg-transparent" : ""
}`}
>
<span className="flex min-w-0 items-center gap-2">
<IconUser />
<span className="truncate font-medium">
{displayName} عزیز
</span>
</span>
<IconChevron className={`w-3.5 h-3.5 shrink-0 transition-transform ${open ? "rotate-90" : "-rotate-90"}`} />
</button>
{open && (
<div
role="menu"
className={`absolute z-50 mt-2 min-w-[11rem] overflow-hidden rounded-2xl border border-black/5 bg-white py-1 shadow-xl ${
compact ? "start-0 end-0" : "start-0"
}`}
>
<a role="menuitem" href={customerDashboardUrl("/profile")} className={itemClass} onClick={() => setOpen(false)}>
پروفایل من
</a>
<a role="menuitem" href={customerDashboardUrl("/orders")} className={itemClass} onClick={() => setOpen(false)}>
سفارشات من
</a>
{isWebsiteAdmin && (
<a
role="menuitem"
href={businessDashboardUrl()}
className={itemClass}
onClick={openBusinessDashboard}
>
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
</a>
)}
<button
type="button"
role="menuitem"
className={`${itemClass} text-brand hover:text-[#e51f1f]`}
onClick={() => {
setOpen(false);
onLogout();
}}
>
خروج
</button>
</div>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { CartProvider } from "@/lib/cart";
import { SessionProvider } from "@/lib/session";
export function AppProviders({
children,
businessId,
}: {
children: React.ReactNode;
businessId: string;
}) {
return (
<SessionProvider businessId={businessId}>
<CartProvider>{children}</CartProvider>
</SessionProvider>
);
}
+98 -30
View File
@@ -1,48 +1,116 @@
import Image from "next/image";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { banners3, banners4 } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { Media } from "./Media";
export function BannerGrid4() {
const banner4Overlay = {
background: "linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
};
type BannerSlotProps = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
};
export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-2 gap-3 md:gap-4">
{banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt="" fill sizes="50vw" className="object-cover object-center" />
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
}}
/>
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
{images.length > 0
? images.map((image) => {
const title = image.titleFa?.trim();
const english = image.titleEn?.trim();
const subtitle = image.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
const frame = (
<div
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(aspectRatio, "21:9")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
<div className="pointer-events-none absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
{title ? (
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
) : null}
</div>
</>
) : null}
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="block">
{frame}
</a>
) : (
<div key={image.id}>{frame}</div>
);
})
: banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt={banner.title} fill sizes="50vw" className="object-cover object-center" />
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
}
export function BannerRow3() {
export function BannerRow({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt="" className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
{images.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
{images.map((image) => (
<div key={image.id} className="overflow-hidden rounded-2xl">
<SlotImage
image={image}
alt={slotAlt(image, "بنر سالار استور")}
imgClassName="h-auto w-full object-cover"
aspectRatio={aspectRatio}
fallbackAspect="12:5"
/>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
)}
</section>
);
}
+3 -3
View File
@@ -18,11 +18,11 @@ export async function BlogCarousel() {
<Carousel ariaLabel="مقالات">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
key={post.id || post.slug}
href={post.href || `/blog/${post.slug}`}
className="snap-start shrink-0 w-[240px] sm:w-[280px] rounded-2xl bg-white border border-black/5 overflow-hidden hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="280px" />
<Media src={post.image} alt={post.title} className="aspect-[16/10]" sizes="280px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
+45 -9
View File
@@ -1,20 +1,56 @@
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { brands } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { Carousel } from "./Carousel";
export function Brands() {
type Props = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
};
export function Brands({ images = [], aspectRatio }: Props) {
return (
<section className="container-site mt-12">
<h2 className="section-title mb-6">برندها</h2>
<Carousel ariaLabel="برندها">
{brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image src={brand.image} alt={brand.name} width={180} height={80} className="max-h-[72px] w-auto object-contain" />
</div>
))}
{images.length > 0
? images.map((image) => {
const card = (
<div
className="snap-start shrink-0 w-[168px] sm:w-[196px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
style={slotAspectStyle(aspectRatio, "2:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={slotAlt(image, "برند")}
imgClassName="max-h-[72px] w-auto object-contain"
/>
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="snap-start shrink-0">
{card}
</a>
) : (
<div key={image.id}>{card}</div>
);
})
: brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image
src={brand.image}
alt={brand.name}
width={180}
height={80}
className="max-h-[72px] w-auto object-contain"
/>
</div>
))}
</Carousel>
</section>
);
+181
View File
@@ -0,0 +1,181 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { customerContinueCheckoutUrl } from "@/lib/customer";
import { useCart } from "@/lib/cart";
import { formatPrice } from "@/lib/format";
import { PLACEHOLDER } from "@/lib/data";
import { useSession } from "@/lib/session";
import { IconClose } from "./Icons";
import { Media } from "./Media";
type Props = {
open: boolean;
onClose: () => void;
};
export function CartPopup({ open, onClose }: Props) {
const { isAuthenticated } = useSession();
const { items, itemCount, total, removeItem, setQuantity } = useCart();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!mounted || !open) return null;
const continueHref = customerContinueCheckoutUrl(isAuthenticated, items);
return createPortal(
<div>
<button type="button" className="fixed inset-0 z-[120] bg-black/40" aria-label="بستن" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
aria-label="سبد خرید"
className="fixed inset-y-0 left-0 z-[130] flex h-dvh w-full max-w-md flex-col bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-black/5 px-5 py-4">
<div>
<h2 className="text-base font-extrabold">سبد خرید</h2>
{itemCount > 0 && (
<p className="mt-0.5 text-xs text-zinc-400">
{itemCount.toLocaleString("fa-IR")} کالا
</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="grid size-9 place-items-center rounded-full hover:bg-zinc-100"
>
<IconClose className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
{items.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<p className="text-sm text-zinc-500">سبد خرید شما خالی است</p>
</div>
) : (
<ul className="space-y-4">
{items.map((item) => (
<li key={item.id} className="flex gap-3 border-b border-black/5 pb-4 last:border-0">
<Link
href={item.slug ? `/products/${item.slug}` : "/products"}
onClick={onClose}
className="size-20 shrink-0 overflow-hidden rounded-xl border border-black/5"
>
<Media src={item.image || PLACEHOLDER.product} alt={item.name} className="size-full" sizes="80px" />
</Link>
<div className="min-w-0 flex-1">
<Link
href={item.slug ? `/products/${item.slug}` : "/products"}
onClick={onClose}
className="line-clamp-2 text-sm font-medium leading-6 hover:text-brand"
>
{item.name}
</Link>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center overflow-hidden rounded-lg border border-black/10">
<button
type="button"
aria-label="افزایش"
onClick={() => setQuantity(item.id, item.quantity + 1)}
className="grid size-8 place-items-center text-sm hover:bg-zinc-50"
>
+
</button>
<span className="min-w-7 text-center text-xs font-bold">
{item.quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
aria-label="کاهش"
onClick={() => setQuantity(item.id, item.quantity - 1)}
className="grid size-8 place-items-center text-sm hover:bg-zinc-50"
>
−
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{formatPrice(item.price * item.quantity)}
<span className="mr-0.5 text-[11px] font-normal text-zinc-400">تومان</span>
</span>
<button
type="button"
onClick={() => removeItem(item.id)}
aria-label="حذف"
className="grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-brand-soft hover:text-brand"
>
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden>
<path
d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8 0v12a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V7M10 11v5M14 11v5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-black/5 px-5 py-4">
{items.length > 0 && (
<div className="mb-3 flex items-center justify-between text-sm">
<span className="text-zinc-500">جمع کل</span>
<span className="font-extrabold">
{formatPrice(total)}
<span className="mr-0.5 text-xs font-normal text-zinc-400">تومان</span>
</span>
</div>
)}
{items.length > 0 ? (
<a
href={continueHref}
onClick={onClose}
className="flex w-full items-center justify-center rounded-xl bg-brand py-3 text-sm text-white hover:bg-[#e51f1f]"
>
ادامه
</a>
) : (
<button
type="button"
onClick={onClose}
className="flex w-full items-center justify-center rounded-xl bg-brand-soft py-3 text-sm font-medium text-brand"
>
ادامه خرید
</button>
)}
</div>
</div>
</div>,
document.body,
);
}
+84 -23
View File
@@ -1,9 +1,22 @@
import Link from "next/link";
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { categories } from "@/lib/data";
import { getCategories } from "@/lib/api";
import { categoryHref } from "@/lib/paths";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { DrawFrame } from "./DrawFrame";
export function Categories() {
export async function Categories({
images = [],
aspectRatio,
}: {
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const apiCategories = images.length > 0 ? [] : await getCategories();
const bySlug = new Map(apiCategories.map((item) => [item.slug, item]));
return (
<section className="container-site mt-12">
<div className="flex items-end justify-between mb-6">
@@ -13,28 +26,76 @@ export function Categories() {
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
{categories.map((cat) => (
<Link
key={cat.slug}
href={`/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span dir="ltr" className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase">
{cat.english}
</span>
</span>
<DrawFrame />
</Link>
))}
{images.length > 0
? images.map((image) => {
const nameFa = image.titleFa?.trim();
const nameEn = image.titleEn?.trim();
const card = (
<div className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand">
<div
className="size-[72px] sm:size-20 shrink-0 overflow-hidden"
style={slotAspectStyle(aspectRatio, "1:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={slotAlt(image, nameFa || nameEn || "دسته‌بندی")}
imgClassName="h-full w-full object-contain"
/>
</div>
{nameFa || nameEn ? (
<span className="min-w-0">
{nameFa ? (
<span className="block text-lg font-medium text-ink leading-none">{nameFa}</span>
) : null}
{nameEn ? (
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{nameEn}
</span>
) : null}
</span>
) : null}
<DrawFrame />
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl}>
{card}
</a>
) : (
<div key={image.id}>{card}</div>
);
})
: categories.map((cat) => {
const api = bySlug.get(cat.slug);
return (
<Link
key={cat.slug}
href={api ? categoryHref(api) : `/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{cat.english}
</span>
</span>
<DrawFrame />
</Link>
);
})}
</div>
</section>
);
+1 -1
View File
@@ -35,7 +35,7 @@ export function Footer() {
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
{features.map((item) => (
<div key={item.title} className="flex items-center gap-3">
<Image src={item.image} alt="" width={48} height={48} className="w-12 h-12 object-contain" />
<Image src={item.image} alt={item.title} width={48} height={48} className="w-12 h-12 object-contain" />
<h3 className="text-sm font-medium text-ink">{item.title}</h3>
</div>
))}
+61 -40
View File
@@ -2,10 +2,14 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { cartCount } from "@/lib/cart";
import { parentCategories } from "@/lib/data";
import { useState } from "react";
import { customerLoginUrl } from "@/lib/customer";
import type { MenuCategory } from "@/lib/nav";
import { SITE } from "@/lib/site";
import { useCart } from "@/lib/cart";
import { useSession } from "@/lib/session";
import { AccountMenu } from "./AccountMenu";
import { CartPopup } from "./CartPopup";
import { IconCart, IconChevron, IconClose, IconMenu, IconUser } from "./Icons";
import { SearchBox } from "./SearchBox";
@@ -16,21 +20,14 @@ const nav = [
{ name: "تماس با ما", href: "/contactus" },
];
export function Header() {
export function Header({ menuCategories = [] }: { menuCategories?: MenuCategory[] }) {
const [open, setOpen] = useState(false);
const [mega, setMega] = useState(false);
const [count, setCount] = useState(0);
useEffect(() => {
const sync = () => setCount(cartCount());
sync();
window.addEventListener("cart-updated", sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener("cart-updated", sync);
window.removeEventListener("storage", sync);
};
}, []);
const [cartOpen, setCartOpen] = useState(false);
const { itemCount } = useCart();
const { isAuthenticated, user, logout, isWebsiteAdmin } = useSession();
const displayName = user?.firstName?.trim() || "حساب من";
const loginHref = customerLoginUrl("/");
return (
<header className="sticky top-0 z-50 bg-white border-b border-black/5">
@@ -63,16 +60,16 @@ export function Header() {
</Link>
{mega && (
<div className="absolute top-full right-0 w-[760px] rounded-2xl bg-white border border-black/5 shadow-xl p-5 grid grid-cols-4 gap-5">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="block mb-3 text-sm font-bold text-brand">
{menuCategories.map((cat) => (
<div key={cat.href}>
<Link href={cat.href} className="block mb-3 text-sm font-bold text-brand">
{cat.name}
</Link>
<ul className="space-y-1.5">
{cat.children.slice(0, 8).map((child) => (
<li key={child.slug}>
<li key={child.href}>
<Link
href={`/products/${child.slug}`}
href={child.href}
className="block text-[13px] text-zinc-600 hover:text-ink"
>
{child.name}
@@ -95,19 +92,29 @@ export function Header() {
<SearchBox className="hidden md:block ms-4 max-w-sm flex-1 rounded-full bg-zinc-100 px-3" />
<div className="flex items-center gap-2 ms-auto">
<a
href={SITE.customerUrl}
className="flex items-center gap-2 rounded-full px-3 py-2 text-sm hover:bg-zinc-100"
{isAuthenticated ? (
<div className="hidden sm:block">
<AccountMenu displayName={displayName} isWebsiteAdmin={isWebsiteAdmin} onLogout={logout} />
</div>
) : (
<a href={loginHref} className="flex items-center gap-2 rounded-full px-3 py-2 text-sm hover:bg-zinc-100">
<IconUser />
<span className="hidden sm:inline">ورود / ثبت نام</span>
</a>
)}
<button
type="button"
onClick={() => setCartOpen(true)}
aria-label={itemCount > 0 ? `سبد خرید، ${itemCount.toLocaleString("fa-IR")} کالا` : "سبد خرید"}
className="relative grid place-items-center w-10 h-10 rounded-full hover:bg-zinc-100"
>
<IconUser />
<span className="hidden sm:inline">ورود / ثبت نام</span>
</a>
<Link href="/cart" className="relative grid place-items-center w-10 h-10 rounded-full hover:bg-zinc-100">
<IconCart className="w-6 h-6 text-brand" />
<span className="absolute top-1 left-1 grid place-items-center w-4 h-4 rounded-full bg-brand text-[10px] text-white">
{count.toLocaleString("fa-IR")}
</span>
</Link>
{itemCount > 0 && (
<span className="absolute top-1 left-1 grid place-items-center min-w-4 h-4 rounded-full bg-brand px-0.5 text-[10px] text-white">
{itemCount.toLocaleString("fa-IR")}
</span>
)}
</button>
</div>
</div>
@@ -124,15 +131,15 @@ export function Header() {
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
<div className="space-y-4 mb-6">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="font-bold text-brand" onClick={() => setOpen(false)}>
{menuCategories.map((cat) => (
<div key={cat.href}>
<Link href={cat.href} className="font-bold text-brand" onClick={() => setOpen(false)}>
{cat.name}
</Link>
<ul className="mt-2 grid grid-cols-2 gap-1">
{cat.children.slice(0, 6).map((child) => (
<li key={child.slug}>
<Link href={`/products/${child.slug}`} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
<li key={child.href}>
<Link href={child.href} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
{child.name}
</Link>
</li>
@@ -147,13 +154,27 @@ export function Header() {
{item.name}
</Link>
))}
<a href={SITE.customerUrl} className="py-2">
ورود / ثبت نام
</a>
{isAuthenticated ? (
<AccountMenu
displayName={displayName}
isWebsiteAdmin={isWebsiteAdmin}
compact
onLogout={() => {
logout();
setOpen(false);
}}
/>
) : (
<a href={loginHref} className="py-2">
ورود / ثبت نام
</a>
)}
</div>
</aside>
</div>
)}
<CartPopup open={cartOpen} onClose={() => setCartOpen(false)} />
</header>
);
}
+210 -71
View File
@@ -3,103 +3,242 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { SlotImage } from "@/components/SlotImage";
import { heroSides, heroSlides } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
const overlayStyle = {
background:
"linear-gradient(to left, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.72) 42%, rgba(0,0,0,0.28) 72%, transparent 100%)",
};
export function HeroSlider() {
type Props = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
sideImages?: StaticImageWithUrl[];
sideAspectRatio?: string;
};
export function HeroSlider({
images = [],
aspectRatio,
sideImages = [],
sideAspectRatio,
}: Props) {
const slides = images;
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const count = slides.length > 0 ? slides.length : heroSlides.length;
useEffect(() => {
if (paused) return;
setIndex((current) => (count ? current % count : 0));
}, [count]);
useEffect(() => {
if (paused || count < 2) return;
const id = window.setInterval(() => {
setIndex((current) => (current + 1) % heroSlides.length);
setIndex((current) => (current + 1) % count);
}, 5000);
return () => window.clearInterval(id);
}, [paused]);
}, [paused, count]);
const sliderStyle = slotAspectStyle(aspectRatio, "21:8");
return (
<section className="container-site pt-5">
<div
className="relative overflow-hidden rounded-2xl aspect-[21/8] min-h-[240px] md:min-h-[360px]"
className="relative overflow-hidden rounded-2xl min-h-[240px] md:min-h-[360px]"
style={sliderStyle ?? { aspectRatio: "21 / 8" }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt=""
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
{slides.length > 0
? slides.map((slide, i) => {
const title = slide.titleFa?.trim();
const english = slide.titleEn?.trim();
const subtitle = slide.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
return (
<div
key={slide.id}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<SlotImage image={slide} fill alt={slotAlt(slide, `اسلاید ${i + 1}`)} />
{hasCopy ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
style={overlayStyle}
/>
<div className="pointer-events-none absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right">
{title ? (
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
{subtitle}
</p>
) : null}
</div>
</>
) : null}
</div>
);
})
: heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt={slide.title}
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
/>
</div>
))}
{slides.length === 0 ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
style={overlayStyle}
/>
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
>
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
{slide.subtitle}
</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
</>
) : null}
{count > 1 ? (
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{Array.from({ length: count }, (_, i) => (
<button
key={slides[i]?.id ?? heroSlides[i]?.title ?? i}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
))}
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase">
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">{slide.subtitle}</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{heroSlides.map((slide, i) => (
<button
key={slide.title}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
) : null}
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image src={side.image} alt="" fill sizes="600px" className="object-cover object-[28%_center]" />
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]" style={overlayStyle} />
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
{sideImages.length > 0
? sideImages.map((image) => {
const title = image.titleFa?.trim();
const english = image.titleEn?.trim();
const subtitle = image.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
const frame = (
<div
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(sideAspectRatio, "13:5")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
style={overlayStyle}
/>
<div className="pointer-events-none absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
{title ? (
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
) : null}
</div>
</>
) : null}
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="block">
{frame}
</a>
) : (
<div key={image.id}>{frame}</div>
);
})
: heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image
src={side.image}
alt={side.title}
fill
sizes="600px"
className="object-cover object-[28%_center]"
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
style={overlayStyle}
/>
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
+2 -2
View File
@@ -8,14 +8,14 @@ export function ProductCard({ product }: { product: Product }) {
const unavailable = product.stock === 0 || product.price === 0;
return (
<Link href={product.slug ? `/products/${product.slug}` : "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
<Link href={product.href || "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
{off > 0 && (
<span className="absolute top-2 left-2 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
{off}%
</span>
)}
<div className="relative">
<Media src={product.image} alt="" className="aspect-square rounded-lg" sizes="220px" />
<Media src={product.image} alt={product.name} className="aspect-square rounded-lg" sizes="220px" />
</div>
<h3 className="mt-2 px-1 text-[13px] leading-6 text-ink line-clamp-2 min-h-12">{product.name}</h3>
<div className="mt-auto px-1 pb-1 pt-2">
+1 -1
View File
@@ -22,7 +22,7 @@ export function ProductShelf({ id, image, products, ariaLabel, tone = "red" }: P
<div className="rounded-2xl p-4 md:p-5" style={{ background: gradients[tone] }}>
<div className="flex items-stretch gap-3">
<div className="relative w-[150px] sm:w-[190px] shrink-0 min-h-[300px]">
<Image src={image} alt="" fill sizes="190px" className="object-contain object-center" />
<Image src={image} alt={ariaLabel} fill sizes="190px" className="object-contain object-center" />
</div>
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
{products.map((product) => (
+6 -18
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { useState, type FormEvent } from "react";
import type { ApiComment } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { SITE } from "@/lib/site";
import { customerLoginUrl } from "@/lib/customer";
import { useSession } from "@/lib/session";
import { RichContent } from "./RichContent";
type Props = {
@@ -23,26 +24,13 @@ const tabs: Tab[] = [
{ id: "comments", fa: "نظرات", en: "Comments" },
];
function readToken() {
if (typeof window === "undefined") return "";
return (
window.localStorage.getItem("accessToken") ||
window.localStorage.getItem("meshkee_access_token") ||
""
);
}
export function ProductTabs({ productId, descriptionHtml, comments: initialComments }: Props) {
const [tab, setTab] = useState<"description" | "comments">("description");
const [comments, setComments] = useState(initialComments);
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const authorName = "کاربر";
useEffect(() => {
setLoggedIn(Boolean(readToken()));
}, []);
const { isAuthenticated: loggedIn, user } = useSession();
const authorName = user?.firstName?.trim() || "کاربر";
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -157,7 +145,7 @@ export function ProductTabs({ productId, descriptionHtml, comments: initialComme
<div className="rounded-xl bg-zinc-50 px-4 py-5 text-center">
<p className="text-sm text-zinc-600">برای ثبت نظر ابتدا وارد حساب کاربری شوید.</p>
<a
href={SITE.customerUrl}
href={customerLoginUrl("/")}
className="mt-3 inline-block text-sm text-brand"
>
ورود / ثبت نام
+12 -10
View File
@@ -29,9 +29,10 @@ export function ProductView({ product, variants }: Props) {
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const name = product.nameFa || product.title;
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
const price = variant ? (variant.discountedPrice ?? variant.price) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? variant.price : undefined;
const price = variant ? (variant.discountedPrice ?? variant.price ?? 0) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? (variant.price ?? undefined) : undefined;
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
const unavailable = !price || stock === 0;
const off = discountPercent(price, oldPrice);
@@ -45,11 +46,11 @@ export function ProductView({ product, variants }: Props) {
{off}%
</span>
)}
<Media src={image} alt={product.nameFa || product.title} className="aspect-square" sizes="560px" priority />
<Media src={image} alt={name} className="aspect-square" sizes="560px" priority />
</div>
{images.length > 1 && (
<div className="mt-3 grid grid-cols-6 gap-2">
{images.map((src) => (
{images.map((src, index) => (
<button
key={src}
type="button"
@@ -58,7 +59,7 @@ export function ProductView({ product, variants }: Props) {
src === image ? "border-brand" : "border-black/10"
}`}
>
<Media src={src} alt="" className="size-full" sizes="80px" />
<Media src={src} alt={`${name} — تصویر ${(index + 1).toLocaleString("fa-IR")}`} className="size-full" sizes="80px" />
</button>
))}
</div>
@@ -67,7 +68,7 @@ export function ProductView({ product, variants }: Props) {
<div className="rounded-2xl border border-black/5 p-5 md:p-6 lg:col-span-6">
{product.categoryNameFa && <p className="mb-2 text-xs text-zinc-400">{product.categoryNameFa}</p>}
<h1 className="text-xl md:text-2xl font-extrabold leading-9">{product.nameFa || product.title}</h1>
<p className="text-xl md:text-2xl font-extrabold leading-9">{name}</p>
{product.title && product.nameFa && (
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
{product.title}
@@ -131,19 +132,20 @@ export function ProductView({ product, variants }: Props) {
</div>
<button
type="button"
disabled={!variant?.id}
onClick={() => {
if (!variant?.id) return;
addToCart({
productId: product.id,
variantId: variant?.id,
id: variant.id,
slug: product.slug,
name: product.nameFa || product.title,
image: images[0] || PLACEHOLDER.product,
price,
qty,
quantity: qty,
});
setAdded(true);
}}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-brand px-5 py-2.5 text-sm text-white hover:bg-[#e51f1f]"
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-brand px-5 py-2.5 text-sm text-white hover:bg-[#e51f1f] disabled:opacity-60"
>
<IconCart className="w-5 h-5 text-white" />
{added ? "به سبد اضافه شد" : "افزودن به سبد خرید"}
+2 -2
View File
@@ -100,11 +100,11 @@ export function SearchBox({ className = "", onNavigate }: Props) {
return (
<li key={product.id}>
<Link
href={product.slug ? `/products/${product.slug}` : `/products?q=${encodeURIComponent(query)}`}
href={product.href || `/products?q=${encodeURIComponent(query)}`}
onClick={onNavigate}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
>
<Media src={product.image} alt="" className="size-12 shrink-0 rounded-lg" sizes="48px" />
<Media src={product.image} alt={product.name} className="size-12 shrink-0 rounded-lg" sizes="48px" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] leading-6">{product.name}</p>
{unavailable ? (
+49
View File
@@ -0,0 +1,49 @@
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
type Props = {
image: StaticImageWithUrl;
alt?: string;
className?: string;
imgClassName?: string;
aspectRatio?: string;
fallbackAspect?: string;
fill?: boolean;
};
export function SlotImage({
image,
alt,
className = "",
imgClassName,
aspectRatio,
fallbackAspect,
fill = false,
}: Props) {
const style = fill ? undefined : slotAspectStyle(aspectRatio, fallbackAspect);
const img = (
// Slot URLs come from Meshkee CDN and may not be in next/image remotePatterns.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image.url}
alt={slotAlt(image, alt)}
style={style}
className={
imgClassName ??
(fill
? "absolute inset-0 h-full w-full object-cover"
: "h-auto w-full object-cover")
}
/>
);
if (!image.linkUrl) {
return className ? <div className={className}>{img}</div> : img;
}
return (
<a href={image.linkUrl} className={className || (fill ? "absolute inset-0 block" : "block")}>
{img}
</a>
);
}
+63 -9
View File
@@ -87,13 +87,18 @@ export type ApiCategory = {
name: string;
nameFa: string | null;
slug: string;
description?: string | null;
isActive?: boolean;
};
export type ApiVariant = {
id: string;
productId?: string;
productTitle?: string | null;
productNameFa?: string | null;
productImage?: string | null;
label?: string | null;
price: number;
price: number | null;
discountedPrice?: number | null;
stockQuantity?: number | null;
sortOrder?: number;
@@ -152,6 +157,18 @@ export async function getProduct(slug: string) {
}
}
export async function getProductById(productId: string) {
try {
const data = await apiGet<{ product: ApiProduct }>(
`${tenant()}/products/by-id/${encodeURIComponent(productId)}`,
);
return data.product ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
}
@@ -166,6 +183,16 @@ export async function getBlog(slug: string) {
}
}
export async function getBlogById(blogId: string) {
try {
const data = await apiGet<{ blog: ApiBlog }>(`${tenant()}/blogs/by-id/${encodeURIComponent(blogId)}`);
return data.blog ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function getStoreSpecials() {
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
return data.items ?? [];
@@ -173,8 +200,10 @@ export async function getStoreSpecials() {
export async function getStoreItemByProduct(productId: string) {
try {
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
return data.storeItem;
const data = await apiGet<{ storeItem: ApiStoreItem | null }>(
`${tenant()}/store-items/by-product/${productId}`,
);
return data.storeItem ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
@@ -195,6 +224,37 @@ export async function getCategoryBySlug(slug: string) {
return categories.find((category) => category.slug === slug) ?? null;
}
export async function getCategoryById(categoryId: string) {
try {
const data = await apiGet<{ category: ApiCategory }>(
`${tenant()}/categories/by-id/${encodeURIComponent(categoryId)}`,
);
return data.category ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
const categories = await getCategories();
return categories.find((category) => category.id === categoryId) ?? null;
}
throw error;
}
}
export type ApiTenant = {
id: string | number;
name: string;
nameFa?: string | null;
slug?: string;
domain?: string;
};
export async function getTenant() {
try {
return await apiGet<ApiTenant>(`/tenants/${API.domain}`);
} catch {
return null;
}
}
export async function getComments(entityType: "product" | "blog", entityId: string) {
try {
const data = await apiGet<{ items: ApiComment[] }>(`${tenant()}/comments`, { entityType, entityId });
@@ -203,9 +263,3 @@ export async function getComments(entityType: "product" | "blog", entityId: stri
return [];
}
}
export async function resolveProductSlug(productId: string, name: string) {
if (!name) return "";
const { items } = await getProducts({ name, pageSize: 20 });
return items.find((item) => item.id === productId)?.slug ?? "";
}
-39
View File
@@ -1,39 +0,0 @@
export type CartItem = {
productId: string;
variantId?: string;
slug: string;
name: string;
image: string;
price: number;
qty: number;
};
const KEY = "salar-cart";
export function getCart(): CartItem[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as CartItem[]) : [];
} catch {
return [];
}
}
export function cartCount() {
return getCart().reduce((sum, item) => sum + item.qty, 0);
}
export function addToCart(item: CartItem) {
const cart = getCart();
const index = cart.findIndex(
(entry) => entry.productId === item.productId && entry.variantId === item.variantId,
);
if (index >= 0) {
cart[index] = { ...cart[index], qty: cart[index].qty + item.qty };
} else {
cart.push(item);
}
window.localStorage.setItem(KEY, JSON.stringify(cart));
window.dispatchEvent(new Event("cart-updated"));
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { GuestCartLine } from "./customer";
export type CartItem = GuestCartLine;
const KEY = "meshkee-guest-cart";
const LEGACY_KEY = "salar-cart";
const CART_EVENT = "cart-updated";
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 parseItems(raw: string | null): CartItem[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map((entry) => normalizeLine(entry))
.filter((item): item is CartItem => Boolean(item));
} catch {
return [];
}
}
function normalizeLine(entry: unknown): CartItem | null {
if (!entry || typeof entry !== "object") return null;
const item = entry as Record<string, unknown>;
const id = String(item.id ?? item.variantId ?? item.storeItemVariantId ?? "").trim();
if (!id) return null;
const quantity = Math.max(1, Math.floor(Number(item.quantity ?? item.qty) || 1));
const price = Number(item.price) || 0;
const name = String(item.name ?? "محصول");
const slug = item.slug ? String(item.slug) : undefined;
const image = item.image != null && item.image !== "" ? String(item.image) : undefined;
return { id, name, ...(slug ? { slug } : {}), price, quantity, ...(image ? { image } : {}) };
}
function writeSharedCookie(items: CartItem[]) {
if (typeof document === "undefined") return;
const domain = cookieDomain();
const maxAge = 60 * 60 * 24 * 30;
const encoded = encodeURIComponent(
JSON.stringify(
items.map(({ id, name, slug, price, quantity }) => ({
id,
name,
slug,
price,
quantity,
image: null as string | null,
})),
),
);
if (encoded.length > 3500) return;
let cookie = `${KEY}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`;
if (domain) cookie += `; domain=${domain}`;
document.cookie = cookie;
}
function notify() {
window.dispatchEvent(new Event(CART_EVENT));
}
export function getCart(): CartItem[] {
if (typeof window === "undefined") return [];
const primary = parseItems(window.localStorage.getItem(KEY));
if (primary.length > 0) return primary;
const legacy = parseItems(window.localStorage.getItem(LEGACY_KEY));
if (legacy.length > 0) {
writeCart(legacy);
window.localStorage.removeItem(LEGACY_KEY);
return legacy;
}
return [];
}
export function writeCart(items: CartItem[]) {
if (typeof window === "undefined") return;
window.localStorage.setItem(KEY, JSON.stringify(items));
window.localStorage.removeItem(LEGACY_KEY);
try {
writeSharedCookie(items);
} catch {
// ignore cookie failures
}
notify();
}
export function cartCount() {
return getCart().reduce((sum, item) => sum + item.quantity, 0);
}
export function cartTotal() {
return getCart().reduce((sum, item) => sum + item.price * item.quantity, 0);
}
export function addToCart(item: CartItem) {
const cart = getCart();
const quantity = Math.max(1, Math.floor(item.quantity) || 1);
const index = cart.findIndex((entry) => entry.id === item.id);
if (index >= 0) {
cart[index] = {
...cart[index],
...item,
quantity: cart[index].quantity + quantity,
};
} else {
cart.push({ ...item, quantity });
}
writeCart(cart);
}
export function setCartQuantity(id: string, quantity: number) {
if (quantity < 1) {
writeCart(getCart().filter((item) => item.id !== id));
return;
}
writeCart(getCart().map((item) => (item.id === id ? { ...item, quantity } : item)));
}
export function removeFromCart(id: string) {
writeCart(getCart().filter((item) => item.id !== id));
}
type CartContextValue = {
items: CartItem[];
itemCount: number;
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
setQuantity: (id: string, quantity: number) => void;
};
const CartContext = createContext<CartContextValue | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const sync = useCallback(() => setItems(getCart()), []);
useEffect(() => {
sync();
window.addEventListener(CART_EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(CART_EVENT, sync);
window.removeEventListener("storage", sync);
};
}, [sync]);
const addItem = useCallback((item: CartItem) => addToCart(item), []);
const removeItem = useCallback((id: string) => removeFromCart(id), []);
const setQuantity = useCallback((id: string, quantity: number) => setCartQuantity(id, quantity), []);
const itemCount = useMemo(() => items.reduce((sum, item) => sum + item.quantity, 0), [items]);
const total = useMemo(() => items.reduce((sum, item) => sum + item.price * item.quantity, 0), [items]);
const value = useMemo(
() => ({ items, itemCount, total, addItem, removeItem, setQuantity }),
[items, itemCount, total, addItem, removeItem, setQuantity],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within CartProvider");
return ctx;
}
+59
View File
@@ -0,0 +1,59 @@
import { BUSINESS_ORIGIN, CUSTOMER_ORIGIN } from "./site";
export const GUEST_CART_URL_PARAM = "guestCart";
export type GuestCartLine = {
id: string;
name: string;
slug?: string;
price: number;
quantity: number;
image?: string | null;
};
export function encodeGuestCartForUrl(items: GuestCartLine[]): string {
const payload = items.map(({ id, name, slug, price, quantity, image }) => ({
id,
name,
...(slug ? { slug } : {}),
price,
quantity,
image: image ?? null,
}));
const json = JSON.stringify(payload);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function customerDashboardUrl(path = "/"): string {
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${CUSTOMER_ORIGIN}${normalized}`;
}
export function businessDashboardUrl(): string {
return `${BUSINESS_ORIGIN}/`;
}
export function customerLoginUrl(redirectPath = "/", cartItems?: GuestCartLine[]): string {
let redirect = redirectPath.startsWith("/") ? redirectPath : `/${redirectPath}`;
if (cartItems && cartItems.length > 0) {
const join = redirect.includes("#") ? "&" : "#";
redirect = `${redirect}${join}${GUEST_CART_URL_PARAM}=${encodeGuestCartForUrl(cartItems)}`;
}
const url = new URL("/login", CUSTOMER_ORIGIN);
url.searchParams.set("redirect", redirect);
return url.toString();
}
export function customerContinueCheckoutUrl(isAuthenticated: boolean, cartItems: GuestCartLine[] = []): string {
if (isAuthenticated) {
const url = new URL("/checkout/cart", CUSTOMER_ORIGIN);
if (cartItems.length > 0) {
url.hash = `${GUEST_CART_URL_PARAM}=${encodeGuestCartForUrl(cartItems)}`;
}
return url.toString();
}
return customerLoginUrl("/checkout/cart", cartItems);
}
+3
View File
@@ -7,6 +7,7 @@ export type Product = {
id: string;
name: string;
slug: string;
href?: string;
price: number;
oldPrice?: number;
stock?: number | null;
@@ -14,8 +15,10 @@ export type Product = {
};
export type Post = {
id?: string;
slug: string;
title: string;
href?: string;
date: string;
image: ImageSrc;
abstract?: string;
+17 -17
View File
@@ -3,13 +3,13 @@ import {
getBlogs,
getProducts,
getStoreSpecials,
resolveProductSlug,
type ApiBlog,
type ApiProduct,
type ApiSpecialItem,
type ApiVariant,
} from "./api";
import { formatDate } from "./format";
import { blogHref, productHref } from "./paths";
function productImage(url?: string | null) {
return url || PLACEHOLDER.product;
@@ -23,35 +23,39 @@ function pickVariantPrice(variants: ApiVariant[]) {
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
const pool = inStock.length ? inStock : variants;
const best = pool.reduce((current, next) => {
const currentPrice = current.discountedPrice ?? current.price;
const nextPrice = next.discountedPrice ?? next.price;
const currentPrice = current.discountedPrice ?? current.price ?? 0;
const nextPrice = next.discountedPrice ?? next.price ?? 0;
return nextPrice < currentPrice ? next : current;
});
const price = best.discountedPrice ?? best.price ?? 0;
const oldPrice = best.discountedPrice ? best.price : undefined;
const oldPrice = best.discountedPrice ? (best.price ?? undefined) : undefined;
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
return { price, oldPrice, stock };
}
export function mapProduct(item: ApiProduct): Product {
const name = item.nameFa || item.title;
return {
id: item.id,
name: item.nameFa || item.title,
name,
slug: item.slug,
href: productHref(item),
price: item.store?.minPrice ?? 0,
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
};
}
export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
export function mapSpecialItem(item: ApiSpecialItem): Product {
const { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
const name = item.productNameFa || item.productTitle;
return {
id: item.productId,
name: item.productNameFa || item.productTitle,
slug,
name,
slug: "",
href: productHref(item.productId, name),
price,
oldPrice,
stock,
@@ -61,8 +65,10 @@ export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
export function mapBlog(item: ApiBlog): Post {
return {
id: item.id,
slug: item.slug,
title: item.title,
href: blogHref(item),
date: formatDate(item.publishedAt || item.createdAt || ""),
image: item.titleImageUrl || PLACEHOLDER.blog,
abstract: item.abstract?.trim() || undefined,
@@ -75,16 +81,10 @@ export async function getSpecialProducts(key: string): Promise<Product[]> {
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
if (!special) return [];
const items = special.items
return special.items
.filter((item) => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder);
return Promise.all(
items.map(async (item) => {
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
return mapSpecialItem(item, slug);
}),
);
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item) => mapSpecialItem(item));
} catch {
return [];
}
+42
View File
@@ -0,0 +1,42 @@
import { API } from "./site";
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(`${API.base}${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);
}
+26
View File
@@ -0,0 +1,26 @@
import { getCategories, type ApiCategory } from "./api";
import { parentCategories } from "./data";
import { categoryHref } from "./paths";
export type MenuCategory = {
name: string;
href: string;
children: { name: string; href: string }[];
};
function hrefForSlug(categories: ApiCategory[], slug: string) {
const match = categories.find((category) => category.slug === slug);
return match ? categoryHref(match) : `/products/${slug}`;
}
export async function getMenuCategories(): Promise<MenuCategory[]> {
const categories = await getCategories();
return parentCategories.map((parent) => ({
name: parent.name,
href: hrefForSlug(categories, parent.slug),
children: parent.children.map((child) => ({
name: child.name,
href: hrefForSlug(categories, child.slug),
})),
}));
}
+40
View File
@@ -0,0 +1,40 @@
import type { ApiBlog, ApiCategory, ApiProduct } from "./api";
import { detailPath, slugifyForUrl } from "./seo-slug";
export function productTitle(product: Pick<ApiProduct, "id" | "nameFa" | "title">) {
return product.nameFa?.trim() || product.title?.trim() || String(product.id);
}
export function categoryTitle(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
return category.nameFa?.trim() || category.name?.trim() || String(category.id);
}
/** Public product URL: /products/{id}/{nameFaSlug} — never use legacy DB slug. */
export function productHref(product: Pick<ApiProduct, "id" | "nameFa" | "title"> | string, title?: string | null) {
if (typeof product === "string") {
return detailPath("/products", product, title?.trim() || product);
}
return detailPath("/products", product.id, productTitle(product));
}
/** Public category URL: /products/category/{id}/{nameFaSlug} */
export function categoryHref(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
return detailPath("/products/category", category.id, categoryTitle(category));
}
/** Public blog URL: /blog/{id}/{titleSlug} */
export function blogHref(blog: Pick<ApiBlog, "id" | "title">) {
return detailPath("/blog", blog.id, blog.title);
}
export function productNameSlug(title: string) {
return slugifyForUrl(title);
}
export function categoryNameSlug(title: string) {
return slugifyForUrl(title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
+20 -26
View File
@@ -1,30 +1,24 @@
import type { StaticImageData } from "next/image";
import bradenCollum from "@/src/assets/images/samples/braden-collum-9HI8UJMSdZA-unsplash.jpg";
import fabioComparelli from "@/src/assets/images/samples/fabio-comparelli-uq2E2V4LhCY-unsplash.jpg";
import glenJackson from "@/src/assets/images/samples/glen-jackson-mzZVGFfMOkA-unsplash.jpg";
import henryXu from "@/src/assets/images/samples/henry-xu-0XHo26Jb8dk-unsplash.jpg";
import jesseGardner from "@/src/assets/images/samples/jesse-gardner-wTVr4HR4SBI-unsplash.jpg";
import jiscaLucia from "@/src/assets/images/samples/jisca-lucia-HVTVOWDKTCk-unsplash.jpg";
import kevinSchmid from "@/src/assets/images/samples/kevin-schmid-AvEY5UPoJZM-unsplash.jpg";
import paulGreen from "@/src/assets/images/samples/paul-green-gOHfFgwyDNM-unsplash.jpg";
import phillipsJacobe from "@/src/assets/images/samples/phillips-jacobe-3Oq0jNYLp28-unsplash.jpg";
import seanBenesh from "@/src/assets/images/samples/sean-benesh-VnmbcgAfL3Q-unsplash.jpg";
import sylvainMauroux from "@/src/assets/images/samples/sylvain-mauroux-OIuzDRYA3cw-unsplash.jpg";
import victorLarracuente from "@/src/assets/images/samples/victor-larracuente-JQfYGhUcDSg-unsplash.jpg";
/**
* Placeholder art that lives in `public/` (tracked in git).
* Do not import files from `src/assets/images/samples/` — that folder is
* gitignored, so production checkout + `next build` would fail with
* "Module not found" even though a local disk still has the JPGs.
*/
const placeholder = (file: string) => `/images/placeholders/${file}`;
export const samples = {
glenJackson,
henryXu,
jesseGardner,
jiscaLucia,
fabioComparelli,
sylvainMauroux,
phillipsJacobe,
paulGreen,
victorLarracuente,
seanBenesh,
kevinSchmid,
bradenCollum,
glenJackson: placeholder("slider-1.png"),
henryXu: placeholder("slider-2.png"),
jesseGardner: placeholder("slider-3.png"),
jiscaLucia: placeholder("slider-side-1.png"),
fabioComparelli: placeholder("slider-side-2.png"),
sylvainMauroux: placeholder("about.png"),
phillipsJacobe: placeholder("banner-4-1.png"),
paulGreen: placeholder("banner-4-2.png"),
victorLarracuente: placeholder("banner-4-3.png"),
seanBenesh: placeholder("banner-4-4.png"),
kevinSchmid: placeholder("banner-3-1.png"),
bradenCollum: placeholder("banner-3-2.png"),
} as const;
export const sampleList: StaticImageData[] = Object.values(samples);
export const sampleList: string[] = Object.values(samples);
+20
View File
@@ -0,0 +1,20 @@
/**
* Build a URL path segment from a title (Persian / Arabic / Latin).
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
*/
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+175
View File
@@ -0,0 +1,175 @@
"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 "./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);
}, []);
const clearTokens = useCallback(() => {
writeToken(SHARED_ACCESS_TOKEN_KEY, null);
writeToken(SHARED_REFRESH_TOKEN_KEY, null);
writeToken("accessToken", null);
writeToken("meshkee_access_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;
}
+123
View File
@@ -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;
}
+9 -3
View File
@@ -1,16 +1,22 @@
export const DOMAIN = "salarstore.com";
export const DOMAIN = process.env.NEXT_PUBLIC_DOMAIN || "salarstore.com";
export const API = {
base: "https://api.meshkee.com/api/v1",
base: process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/$/, "") || "https://api.meshkee.com/api/v1",
domain: DOMAIN,
};
export const CUSTOMER_ORIGIN =
process.env.NEXT_PUBLIC_CUSTOMER_ORIGIN?.replace(/\/$/, "") || `https://customer.${DOMAIN}`;
export const BUSINESS_ORIGIN =
process.env.NEXT_PUBLIC_BUSINESS_ORIGIN?.replace(/\/$/, "") || `https://business.${DOMAIN}`;
export const SITE = {
nameFa: "سالار استور",
nameEn: "SALAR STORE",
tagline: "فروشگاه لوازم ورزشی و کوهنوردی",
domain: DOMAIN,
customerUrl: `https://customer.${DOMAIN}`,
customerUrl: CUSTOMER_ORIGIN,
phone: "09127491700",
phoneDisplay: "0912 749 1700",
address: "قم، بسیج (هنرستان)، نبش کوچه ۹",
+28
View File
@@ -0,0 +1,28 @@
{
"baseUrl": "https://salarstore.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/aboutus",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contactus",
"priority": 0.5
},
{
"path": "/products",
"changefreq": "daily",
"priority": 0.9
}
]
}
+198
View File
@@ -0,0 +1,198 @@
import { API } from "./site";
export type StaticImage = {
id: string;
url?: string | null;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number;
};
export type StaticImageWithUrl = StaticImage & { url: string };
export type StaticImageSlot = {
id: string;
key: string;
pageKey?: string;
kind: "single" | "list";
label?: string;
aspectRatio?: string;
recommendedWidth?: number;
itemCount?: number | null;
sortOrder?: number;
images?: StaticImage[];
};
export type StaticImageCatalogSlot = {
key: string;
label: string;
kind: "single" | "list";
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
/**
* Slot keys the dashboard Refresh button imports from
* GET /meshkee/static-image-slots. Keep aspectRatio + recommendedWidth in
* sync with the live CSS when a slot's size changes.
*/
export const STATIC_IMAGE_KEYS = {
slider: "slider",
twoBannerBelowSlider: "two-banner-below-slider",
categories: "categories",
fourBanner: "four-banner",
twoBannerRow: "two-banner-row",
brands: "brands",
aboutUsImage: "about-us-image",
} as const;
/** Previous catalog keys still returned by the tenant API until dashboard Refresh. */
const SLOT_KEY_ALIASES: Record<string, string[]> = {
"two-banner-below-slider": ["banners-below-slider"],
"four-banner": ["four-banner-section"],
"two-banner-row": ["three-banner-row"],
};
export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
{
key: STATIC_IMAGE_KEYS.slider,
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "21:8",
itemCount: null,
recommendedWidth: 1200,
},
{
key: STATIC_IMAGE_KEYS.twoBannerBelowSlider,
label: "Two banners below slider",
kind: "list",
pageKey: "home",
aspectRatio: "13:5",
itemCount: 2,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.categories,
label: "Homepage categories",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 160,
},
{
key: STATIC_IMAGE_KEYS.fourBanner,
label: "Four banner section",
kind: "list",
pageKey: "home",
aspectRatio: "21:9",
itemCount: 4,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.twoBannerRow,
label: "Two banner row",
kind: "list",
pageKey: "home",
aspectRatio: "12:5",
itemCount: 2,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.brands,
label: "Brand logos",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: null,
recommendedWidth: 196,
},
{
key: STATIC_IMAGE_KEYS.aboutUsImage,
label: "About us images",
kind: "list",
pageKey: "home",
aspectRatio: "8:5",
itemCount: 2,
recommendedWidth: 720,
},
];
export function slotAspectStyle(
ratio?: string | null,
fallback?: string,
): { aspectRatio: string } | undefined {
const value = ratio?.trim() || fallback;
if (!value) return undefined;
return { aspectRatio: value.replace(":", " / ") };
}
export function slotAlt(image: StaticImage, fallback = ""): string {
return image.titleFa?.trim() || image.titleEn?.trim() || fallback;
}
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const aliases = SLOT_KEY_ALIASES[key] ?? [];
return slots.find((slot) => slot.key === key) ?? slots.find((slot) => aliases.includes(slot.key));
}
export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
const slot = slotByKey(slots, key);
const catalog = STATIC_IMAGE_SLOT_CATALOG.find((item) => item.key === key);
const raw = slot?.images ?? [];
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const result: StaticImageWithUrl[] = [];
for (const image of sorted) {
const url = typeof image.url === "string" ? image.url.trim() : "";
if (!url) continue;
result.push({ ...image, url });
}
const kind = catalog?.kind ?? slot?.kind ?? "list";
const limit = catalog?.itemCount ?? (kind === "single" ? 1 : null);
if (kind === "single" || limit === 1) return result.slice(0, 1);
if (limit && limit > 0) return result.slice(0, limit);
return result;
}
export function firstImageForSlot(
slots: StaticImageSlot[],
key: string,
): StaticImageWithUrl | undefined {
return imagesForSlot(slots, key)[0];
}
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
if (pageKey) url.searchParams.set("pageKey", pageKey);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) return [];
const data = (await response.json()) as { items?: StaticImageSlot[] };
return data.items ?? [];
} catch {
return [];
}
}
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
const keys = [key, ...(SLOT_KEY_ALIASES[key] ?? [])];
for (const candidate of keys) {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${candidate}`);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) continue;
return (await response.json()) as StaticImageSlot;
} catch {
// try next alias
}
}
return null;
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import type { ApiProduct, ApiStoreItem, ApiVariant } from "./api";
function sellingPrice(variant: ApiVariant): number {
const value = variant.discountedPrice ?? variant.price;
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
}
function inStock(variant: ApiVariant): boolean {
return variant.stockQuantity === null || (typeof variant.stockQuantity === "number" && variant.stockQuantity > 0);
}
export function pickTorobVariant(variants: ApiVariant[]): ApiVariant | undefined {
if (!variants.length) return undefined;
const available = variants.filter(inStock);
const pool = available.length ? available : variants;
return pool.reduce((cheapest, variant) =>
sellingPrice(variant) < sellingPrice(cheapest) ? variant : cheapest,
);
}
function toHttpsUrl(url?: string | null): string | undefined {
if (!url) return undefined;
const trimmed = url.trim();
if (trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("http://")) return `https://${trimmed.slice("http://".length)}`;
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return undefined;
}
export function torobMetadata(product: ApiProduct, storeItem: ApiStoreItem | null): Pick<Metadata, "other" | "openGraph"> {
const variant = storeItem ? pickTorobVariant(storeItem.variants ?? []) : undefined;
const price = storeItem && variant ? sellingPrice(variant) : 0;
const available = Boolean(storeItem && variant && inStock(variant));
const name = variant?.productNameFa || variant?.productTitle || product.nameFa || product.title;
const image =
toHttpsUrl(variant?.productImage) ||
toHttpsUrl(product.thumbnailUrl) ||
toHttpsUrl(product.image) ||
toHttpsUrl(product.thumbnail) ||
toHttpsUrl(product.images?.[0]?.url);
const other: Record<string, string> = {
product_id: product.id,
product_name: name,
product_price: String(price),
availability: available ? "instock" : "outofstock",
};
if (variant && variant.discountedPrice != null) {
other.product_old_price = String(typeof variant.price === "number" ? Math.trunc(variant.price) : 0);
}
return {
other,
...(image ? { openGraph: { images: [{ url: image }] } } : {}),
};
}
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
localPatterns: [
{
+3 -1
View File
@@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"next": "16.3.1",
+93
View File
@@ -0,0 +1,93 @@
/**
* Scans app page routes and writes lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "app");
const outFile = path.join(root, "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "daily", priority: 1.0 },
"/products": { changefreq: "daily", priority: 0.9 },
"/blog": { changefreq: "weekly", priority: 0.8 },
"/aboutus": { priority: 0.6 },
"/contactus": { priority: 0.5 },
};
function websiteDomain() {
return (
process.env.NEXT_PUBLIC_DOMAIN ||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
"salarstore.com"
);
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (entry.name === "page.tsx" || entry.name === "page.ts" || entry.name === "page.jsx" || entry.name === "page.js") {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`);
}
main();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB