Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84147e765 | ||
|
|
f42c30a4b7 | ||
|
|
49b83223f1 | ||
|
|
46e4835360 | ||
|
|
16340c3b48 | ||
|
|
ad05241a77 |
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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]">
|
||||
{hero ? (
|
||||
<SlotImage image={hero} fill alt={slotAlt(hero, SITE.nameFa)} />
|
||||
) : (
|
||||
<Image
|
||||
src="/images/pages/about-bg.webp"
|
||||
alt=""
|
||||
alt={SITE.nameFa}
|
||||
fill
|
||||
priority
|
||||
sizes="1200px"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
@@ -42,6 +59,13 @@ 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">
|
||||
{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}
|
||||
@@ -49,6 +73,7 @@ export default function AboutPage() {
|
||||
height={603}
|
||||
className="h-auto w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const res = await meshkeeFetch("/auth/handoff", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
|
||||
}
|
||||
|
||||
return jsonResponse(res.data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const res = await meshkeeFetch("/auth/me", { token });
|
||||
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
|
||||
return jsonResponse(res.data);
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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">
|
||||
<AppProviders businessId={tenant ? String(tenant.id) : ""}>
|
||||
<TopHeader />
|
||||
<Header />
|
||||
<Header menuCategories={menuCategories} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,6 +25,11 @@ export function AboutUs() {
|
||||
مشاهده بیشتر
|
||||
</Link>
|
||||
</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"
|
||||
@@ -23,6 +39,7 @@ export function AboutUs() {
|
||||
className="h-auto w-full max-w-sm object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,79 @@
|
||||
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) => (
|
||||
{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="" fill sizes="50vw" className="object-cover object-center" />
|
||||
<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={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
@@ -33,16 +85,32 @@ export function BannerGrid4() {
|
||||
);
|
||||
}
|
||||
|
||||
export function BannerRow3() {
|
||||
export function BannerRow({ images = [], aspectRatio }: BannerSlotProps) {
|
||||
return (
|
||||
<section className="container-site mt-12">
|
||||
{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="" className="aspect-[2.4/1]" sizes="33vw" />
|
||||
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
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) => (
|
||||
{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" />
|
||||
<Image
|
||||
src={brand.image}
|
||||
alt={brand.name}
|
||||
width={180}
|
||||
height={80}
|
||||
className="max-h-[72px] w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,10 +26,54 @@ 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) => (
|
||||
{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={`/products/${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
|
||||
@@ -28,13 +85,17 @@ export function Categories() {
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
<Link href="/cart" className="relative grid place-items-center w-10 h-10 rounded-full hover:bg-zinc-100">
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<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")}
|
||||
{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>
|
||||
</Link>
|
||||
)}
|
||||
</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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,33 +3,101 @@
|
||||
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) => (
|
||||
{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}
|
||||
@@ -39,7 +107,7 @@ export function HeroSlider() {
|
||||
>
|
||||
<Image
|
||||
src={slide.image}
|
||||
alt=""
|
||||
alt={slide.title}
|
||||
fill
|
||||
sizes="1200px"
|
||||
priority={i === 0}
|
||||
@@ -48,8 +116,12 @@ export function HeroSlider() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
|
||||
|
||||
{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`}
|
||||
@@ -58,10 +130,15 @@ export function HeroSlider() {
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<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]"
|
||||
@@ -70,11 +147,14 @@ export function HeroSlider() {
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{count > 1 ? (
|
||||
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
|
||||
{heroSlides.map((slide, i) => (
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<button
|
||||
key={slide.title}
|
||||
key={slides[i]?.id ?? heroSlides[i]?.title ?? i}
|
||||
type="button"
|
||||
aria-label={`اسلاید ${i + 1}`}
|
||||
onClick={() => setIndex(i)}
|
||||
@@ -82,18 +162,77 @@ export function HeroSlider() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{heroSides.map((side) => (
|
||||
{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="" fill sizes="600px" className="object-cover object-[28%_center]" />
|
||||
<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="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">
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
ورود / ثبت نام
|
||||
|
||||
@@ -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 ? "به سبد اضافه شد" : "افزودن به سبد خرید"}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "قم، بسیج (هنرستان)، نبش کوچه ۹",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
localPatterns: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 968 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 447 KiB |
|
Before Width: | Height: | Size: 812 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 973 KiB |
|
Before Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 825 KiB |