Compare commits

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

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

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

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

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 KiB