Build the Salar Store storefront and connect products, blogs, and home specials to the Meshkee API.
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { PageHero } from "@/components/PageHero";
|
||||
import { SITE } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `درباره ما | ${SITE.nameFa}`,
|
||||
description: SITE.about,
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero title="درباره ما" english="About Us" />
|
||||
<section className="container-site py-10 md:py-14">
|
||||
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
|
||||
<Image
|
||||
src="/images/pages/about-bg.webp"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="1200px"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to left, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.55) 48%, rgba(0,0,0,0.2) 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-[1] max-w-xl p-6 md:p-10 text-white">
|
||||
<h2 className="text-2xl md:text-4xl font-extrabold mb-4">{SITE.nameFa}</h2>
|
||||
<p className="text-sm md:text-base leading-8 text-white/90 text-justify">{SITE.about}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
|
||||
<div>
|
||||
<p className="font-carter text-brand tracking-[0.2em] text-sm mb-2">SALAR STORE</p>
|
||||
<h2 className="section-title mb-4">سالار استور</h2>
|
||||
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
|
||||
</div>
|
||||
<div className="relative mx-auto w-full max-w-xs">
|
||||
<Image
|
||||
src="/images/pages/about-side.png"
|
||||
alt={SITE.nameFa}
|
||||
width={300}
|
||||
height={603}
|
||||
className="h-auto w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { API } from "@/lib/site";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API.base}/tenants/${API.domain}/comments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await response.json();
|
||||
return Response.json(data, { status: response.status });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { listMappedProducts } from "@/lib/mappers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const query = new URL(request.url).searchParams.get("q")?.trim() ?? "";
|
||||
if (query.length < 2) {
|
||||
return Response.json({ items: [] });
|
||||
}
|
||||
|
||||
const data = await listMappedProducts({ name: query, page: 1, pageSize: 8 });
|
||||
return Response.json({ items: data.items, total: data.total });
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Media } from "@/components/Media";
|
||||
import { PageHero } from "@/components/PageHero";
|
||||
import { RichContent } from "@/components/RichContent";
|
||||
import { getBlog } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { PLACEHOLDER } from "@/lib/data";
|
||||
import { SITE } from "@/lib/site";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
try {
|
||||
const blog = await getBlog(slug);
|
||||
if (!blog) return { title: SITE.nameFa };
|
||||
return {
|
||||
title: `${blog.title} | ${SITE.nameFa}`,
|
||||
description: blog.abstract || SITE.aboutShort,
|
||||
};
|
||||
} catch {
|
||||
return { title: SITE.nameFa };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const blog = await getBlog(slug);
|
||||
if (!blog) notFound();
|
||||
|
||||
const author = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
title={blog.title}
|
||||
english="Blog"
|
||||
crumbs={[{ name: "وبلاگ", href: "/blog" }, { name: blog.title }]}
|
||||
/>
|
||||
<article className="container-site py-10 md:py-14">
|
||||
<Media
|
||||
src={blog.titleImageUrl || PLACEHOLDER.blog}
|
||||
alt={blog.title}
|
||||
className="aspect-[16/7] rounded-2xl"
|
||||
sizes="1200px"
|
||||
priority
|
||||
/>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-zinc-400">
|
||||
{blog.publishedAt || blog.createdAt ? <time>{formatDate(blog.publishedAt || blog.createdAt || "")}</time> : null}
|
||||
{blog.categoryName ? <span>{blog.categoryName}</span> : null}
|
||||
{author ? <span>{author}</span> : null}
|
||||
</div>
|
||||
{blog.abstract ? <p className="mt-4 text-zinc-600 leading-8">{blog.abstract}</p> : null}
|
||||
<div className="mt-8">
|
||||
<RichContent html={blog.mainTextHtml} />
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Media } from "@/components/Media";
|
||||
import { PageHero } from "@/components/PageHero";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { getBlogs } from "@/lib/api";
|
||||
import { mapBlog } from "@/lib/mappers";
|
||||
import { SITE } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `وبلاگ | ${SITE.nameFa}`,
|
||||
description: "آخرین مقالات سالار استور",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
};
|
||||
|
||||
export default async function BlogPage({ searchParams }: Props) {
|
||||
const { page: pageParam } = await searchParams;
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
let data: Awaited<ReturnType<typeof getBlogs>> = { items: [], total: 0, page, pageSize: 12 };
|
||||
try {
|
||||
data = await getBlogs({ page, pageSize: 12 });
|
||||
} catch {
|
||||
data = { items: [], total: 0, page, pageSize: 12 };
|
||||
}
|
||||
const posts = data.items.map(mapBlog);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title="وبلاگ سالار استور" english="Blog" />
|
||||
<section className="container-site py-10 md:py-14">
|
||||
{posts.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="overflow-hidden rounded-2xl border border-black/5 bg-white hover:border-brand/40"
|
||||
>
|
||||
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="400px" />
|
||||
<div className="p-4">
|
||||
<time className="text-xs text-zinc-400">{post.date}</time>
|
||||
<h2 className="mt-2 text-base font-bold leading-8 line-clamp-2">{post.title}</h2>
|
||||
{post.abstract ? (
|
||||
<p className="mt-1 text-sm leading-7 text-zinc-500 line-clamp-2">{post.abstract}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-16 text-center text-sm text-zinc-500">مقالهای یافت نشد.</p>
|
||||
)}
|
||||
<Pagination
|
||||
page={data.page}
|
||||
pageSize={data.pageSize}
|
||||
total={data.total}
|
||||
makeHref={(nextPage) => (nextPage > 1 ? `/blog?page=${nextPage}` : "/blog")}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
import { PageHero } from "@/components/PageHero";
|
||||
import { IconInstagram, IconPhone, IconPin, IconTelegram, IconWhatsapp } from "@/components/Icons";
|
||||
import { SITE } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `تماس با ما | ${SITE.nameFa}`,
|
||||
description: SITE.address,
|
||||
};
|
||||
|
||||
const contacts = [
|
||||
{
|
||||
href: `tel:${SITE.phone}`,
|
||||
label: SITE.phoneDisplay,
|
||||
dir: "ltr" as const,
|
||||
numeric: true,
|
||||
icon: IconPhone,
|
||||
},
|
||||
{
|
||||
href: SITE.whatsapp,
|
||||
label: SITE.phoneDisplay,
|
||||
dir: "ltr" as const,
|
||||
numeric: true,
|
||||
icon: IconWhatsapp,
|
||||
},
|
||||
{
|
||||
href: SITE.instagram,
|
||||
label: SITE.instagramHandle,
|
||||
dir: "ltr" as const,
|
||||
numeric: false,
|
||||
icon: IconInstagram,
|
||||
},
|
||||
{
|
||||
href: SITE.telegram,
|
||||
label: SITE.telegramHandle,
|
||||
dir: "ltr" as const,
|
||||
numeric: false,
|
||||
icon: IconTelegram,
|
||||
},
|
||||
{
|
||||
href: undefined,
|
||||
label: SITE.address,
|
||||
dir: undefined,
|
||||
numeric: false,
|
||||
icon: IconPin,
|
||||
},
|
||||
];
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero title="تماس با ما" english="Contact Us" />
|
||||
<section className="container-site py-10 md:py-14">
|
||||
<div className="grid gap-8 lg:grid-cols-[280px_1fr] items-start">
|
||||
<div className="rounded-2xl border border-black/5 divide-y divide-black/5">
|
||||
{contacts.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const content = (
|
||||
<>
|
||||
<span className="grid place-items-center size-10 rounded-full bg-zinc-50 text-brand shrink-0">
|
||||
<Icon className="w-5 h-5" />
|
||||
</span>
|
||||
<span dir={item.dir} className={item.numeric ? "font-oswald tracking-wide" : ""}>
|
||||
{item.label}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!item.href) {
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-3 px-4 py-4 text-sm">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
key={item.href + item.label}
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 px-4 py-4 text-sm hover:text-brand"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,260 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #292d32;
|
||||
--color-brand: #fc2424;
|
||||
--color-brand-soft: #ffecec;
|
||||
--color-ink: #292d32;
|
||||
--color-muted: #f5f6f9;
|
||||
--font-sans: var(--font-vazir), Tahoma, sans-serif;
|
||||
--font-oswald: var(--font-oswald), Impact, sans-serif;
|
||||
--font-carter: var(--font-carter-one), Impact, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: #ffffff;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.font-oswald {
|
||||
font-family: var(--font-oswald);
|
||||
}
|
||||
|
||||
.font-carter {
|
||||
font-family: var(--font-carter);
|
||||
}
|
||||
|
||||
.container-site {
|
||||
width: min(1200px, calc(100% - 2rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
background-color: #fbfbfc;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Ccircle cx='2' cy='2' r='1' fill='%23292d32' fill-opacity='0.06'/%3E%3C/svg%3E");
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #fc2424;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.footer-bottom-bar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.footer-bottom-site {
|
||||
color: #9c9c9c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meshkee-credit {
|
||||
align-items: center;
|
||||
color: #9c9c9c;
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover {
|
||||
color: #e0e0e0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.meshkee-credit__frame {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.meshkee-credit__line {
|
||||
background: #777;
|
||||
position: absolute;
|
||||
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit__line--l {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
transition-delay: 0s;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--b {
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition-delay: 0.14s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--r {
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: bottom;
|
||||
transition-delay: 0.28s;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--t {
|
||||
height: 1px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: right;
|
||||
transition-delay: 0.42s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__line--l,
|
||||
.meshkee-credit:hover .meshkee-credit__line--r {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__line--b,
|
||||
.meshkee-credit:hover .meshkee-credit__line--t {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
|
||||
transition-delay: 0.42s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
|
||||
transition-delay: 0.28s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
|
||||
transition-delay: 0.14s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.meshkee-credit__logo {
|
||||
aspect-ratio: 1 / 1;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: 42px;
|
||||
mix-blend-mode: screen;
|
||||
object-fit: contain;
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
width: auto;
|
||||
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.meshkee-credit__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
min-height: 42px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.meshkee-credit__label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meshkee-credit__name {
|
||||
color: #cfcfcf;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.footer-bottom-bar {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.draw-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
border: 1.5px solid currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.draw-frame-host:hover .draw-frame {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.rich-content {
|
||||
color: #444;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.rich-content h2,
|
||||
.rich-content h3,
|
||||
.rich-content h4 {
|
||||
color: var(--color-ink);
|
||||
font-weight: 800;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.rich-content p,
|
||||
.rich-content li {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.rich-content ul,
|
||||
.rich-content ol {
|
||||
padding-inline-start: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.rich-content a {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.rich-content img {
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
height: auto;
|
||||
margin: 1.25rem auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Carter_One, Oswald, Vazirmatn } from "next/font/google";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Header } from "@/components/Header";
|
||||
import { TopHeader } from "@/components/TopHeader";
|
||||
import { SITE } from "@/lib/site";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
const vazir = Vazirmatn({
|
||||
subsets: ["arabic", "latin"],
|
||||
variable: "--font-vazir",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const oswald = Oswald({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-oswald",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const carter = Carter_One({
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
variable: "--font-carter-one",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: `${SITE.nameFa} | ${SITE.tagline}`,
|
||||
description: SITE.aboutShort,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="fa" dir="rtl" className={`${vazir.variable} ${oswald.variable} ${carter.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col bg-white text-ink">
|
||||
<TopHeader />
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section className="container-site py-24 text-center">
|
||||
<h1 className="text-3xl font-extrabold">صفحه پیدا نشد</h1>
|
||||
<p className="mt-3 text-zinc-500">محتوای مورد نظر در دسترس نیست.</p>
|
||||
<Link href="/" className="mt-6 inline-block text-brand">
|
||||
بازگشت به خانه
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,24 @@
|
||||
import Image from "next/image";
|
||||
import { AboutUs } from "@/components/AboutUs";
|
||||
import { BannerGrid4, BannerRow3 } from "@/components/Banners";
|
||||
import { BlogCarousel } from "@/components/BlogCarousel";
|
||||
import { Brands } from "@/components/Brands";
|
||||
import { Categories } from "@/components/Categories";
|
||||
import { HeroSlider } from "@/components/HeroSlider";
|
||||
import { SpecialSale } from "@/components/SpecialSale";
|
||||
import { TopSelling } from "@/components/TopSelling";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<>
|
||||
<HeroSlider />
|
||||
<SpecialSale />
|
||||
<Categories />
|
||||
<BannerGrid4 />
|
||||
<TopSelling />
|
||||
<BannerRow3 />
|
||||
<Brands />
|
||||
<AboutUs />
|
||||
<BlogCarousel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHero } from "@/components/PageHero";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { ProductGrid } from "@/components/ProductGrid";
|
||||
import { listMappedProducts } from "@/lib/mappers";
|
||||
import { SITE } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `محصولات | ${SITE.nameFa}`,
|
||||
description: SITE.tagline,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ q?: string; page?: string }>;
|
||||
};
|
||||
|
||||
export default async function ProductsPage({ searchParams }: Props) {
|
||||
const { q, page: pageParam } = await searchParams;
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
const pageSize = 16;
|
||||
const data = await listMappedProducts({ page, pageSize, name: q });
|
||||
|
||||
const makeHref = (nextPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
if (nextPage > 1) params.set("page", String(nextPage));
|
||||
const query = params.toString();
|
||||
return query ? `/products?${query}` : "/products";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title={q ? `جستجو: ${q}` : "محصولات"} english="Products" />
|
||||
<section className="container-site py-10 md:py-14">
|
||||
<ProductGrid products={data.items} />
|
||||
<Pagination page={data.page} pageSize={data.pageSize} total={data.total} makeHref={makeHref} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SITE } from "@/lib/site";
|
||||
|
||||
export function AboutUs() {
|
||||
return (
|
||||
<section id="about" className="container-site mt-14">
|
||||
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
|
||||
<div>
|
||||
<p className="font-carter text-brand tracking-[0.2em] text-sm mb-2">SALAR STORE</p>
|
||||
<h2 className="section-title mb-4">درباره ما</h2>
|
||||
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
|
||||
<Link href="/aboutus" className="mt-5 inline-block text-sm text-brand">
|
||||
مشاهده بیشتر
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
|
||||
<Image
|
||||
src="/images/logo/logo.png"
|
||||
alt={SITE.nameFa}
|
||||
width={360}
|
||||
height={120}
|
||||
className="h-auto w-full max-w-sm object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { banners3, banners4 } from "@/lib/data";
|
||||
import { Media } from "./Media";
|
||||
|
||||
export function BannerGrid4() {
|
||||
return (
|
||||
<section className="container-site mt-12">
|
||||
<div className="grid grid-cols-2 gap-3 md:gap-4">
|
||||
{banners4.map((banner) => (
|
||||
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
|
||||
<div className="relative aspect-[21/9] min-h-[140px]">
|
||||
<Image src={banner.image} alt="" fill sizes="50vw" className="object-cover object-center" />
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
|
||||
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
|
||||
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
|
||||
{banner.english}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BannerRow3() {
|
||||
return (
|
||||
<section className="container-site mt-12">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
|
||||
{banners3.map((banner) => (
|
||||
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
|
||||
<Media src={banner.image} alt="" className="aspect-[2.4/1]" sizes="33vw" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import { getLatestPosts } from "@/lib/mappers";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { Media } from "./Media";
|
||||
|
||||
export async function BlogCarousel() {
|
||||
const posts = await getLatestPosts();
|
||||
if (!posts.length) return null;
|
||||
|
||||
return (
|
||||
<section className="container-site mt-14">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<h2 className="section-title">آخرین مقالات</h2>
|
||||
<Link href="/blog" className="text-sm text-brand">
|
||||
همه مقالات
|
||||
</Link>
|
||||
</div>
|
||||
<Carousel ariaLabel="مقالات">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="snap-start shrink-0 w-[240px] sm:w-[280px] rounded-2xl bg-white border border-black/5 overflow-hidden hover:border-brand/40"
|
||||
>
|
||||
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="280px" />
|
||||
<div className="p-4">
|
||||
<time className="text-xs text-zinc-400">{post.date}</time>
|
||||
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
|
||||
{post.abstract ? (
|
||||
<p className="mt-1 text-xs leading-6 text-zinc-500 line-clamp-2">{post.abstract}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Image from "next/image";
|
||||
import { brands } from "@/lib/data";
|
||||
import { Carousel } from "./Carousel";
|
||||
|
||||
export function Brands() {
|
||||
return (
|
||||
<section className="container-site mt-12">
|
||||
<h2 className="section-title mb-6">برندها</h2>
|
||||
<Carousel ariaLabel="برندها">
|
||||
{brands.map((brand) => (
|
||||
<div
|
||||
key={brand.name}
|
||||
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
|
||||
>
|
||||
<Image src={brand.image} alt={brand.name} width={180} height={80} className="max-h-[72px] w-auto object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { IconArrow } from "./Icons";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
flush?: boolean;
|
||||
tight?: boolean;
|
||||
};
|
||||
|
||||
export function Carousel({ children, className = "", ariaLabel, flush = false, tight = false }: Props) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
|
||||
const move = (direction: number) => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
const delta = Math.max(el.clientWidth * 0.55, 240) * direction;
|
||||
el.scrollBy({ left: delta, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
ref={scroller}
|
||||
aria-label={ariaLabel}
|
||||
className={`flex overflow-x-auto scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${
|
||||
flush ? "divide-x divide-zinc-200" : tight ? "gap-2" : "gap-4 pb-2"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="قبلی"
|
||||
onClick={() => move(1)}
|
||||
className="absolute top-1/2 right-2 z-10 -translate-y-1/2 hidden md:grid place-items-center size-8 rounded-md bg-white text-ink shadow-sm border border-black/10 hover:bg-brand hover:text-white"
|
||||
>
|
||||
<IconArrow dir="right" className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بعدی"
|
||||
onClick={() => move(-1)}
|
||||
className="absolute top-1/2 left-2 z-10 -translate-y-1/2 hidden md:grid place-items-center size-8 rounded-md bg-white text-ink shadow-sm border border-black/10 hover:bg-brand hover:text-white"
|
||||
>
|
||||
<IconArrow dir="left" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { categories } from "@/lib/data";
|
||||
import { DrawFrame } from "./DrawFrame";
|
||||
|
||||
export function Categories() {
|
||||
return (
|
||||
<section className="container-site mt-12">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<h2 className="section-title">دستهبندیها</h2>
|
||||
<Link href="/products" className="text-sm text-brand">
|
||||
همه محصولات
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.slug}
|
||||
href={`/products/${cat.slug}`}
|
||||
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
|
||||
>
|
||||
<Image
|
||||
src={cat.image}
|
||||
alt={cat.name}
|
||||
width={88}
|
||||
height={88}
|
||||
className="size-[72px] sm:size-20 shrink-0 object-contain"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
|
||||
<span dir="ltr" className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase">
|
||||
{cat.english}
|
||||
</span>
|
||||
</span>
|
||||
<DrawFrame />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function ContactForm() {
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="rounded-2xl bg-zinc-50 p-5 md:p-8"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSent(true);
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl font-extrabold mb-1">نظرتون با ما به اشتراک بزارید</h2>
|
||||
<p dir="ltr" className="mb-6 font-carter text-[10px] tracking-[0.35em] text-zinc-400 uppercase">
|
||||
SEND MESSAGE
|
||||
</p>
|
||||
|
||||
{sent ? (
|
||||
<p className="rounded-xl bg-white px-4 py-6 text-sm leading-7 text-zinc-600">
|
||||
پیام شما ثبت شد. بهزودی از طریق تماس یا پیام پاسخ میدهیم.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
<input
|
||||
required
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="نام و نام خانوادگی"
|
||||
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="ایمیل"
|
||||
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
name="cell_number"
|
||||
type="tel"
|
||||
placeholder="شماره همراه"
|
||||
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
|
||||
dir="ltr"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="موضوع"
|
||||
className="w-full rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
name="message"
|
||||
placeholder="پیام"
|
||||
rows={6}
|
||||
className="w-full resize-y rounded-xl border border-black/5 bg-white px-4 py-3 text-sm outline-none focus:border-brand"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-2 inline-flex items-center justify-center rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white hover:bg-[#e01e1e]"
|
||||
>
|
||||
ارسال پیام
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function DrawFrame() {
|
||||
return <span className="draw-frame" aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { features, footerLinks } from "@/lib/data";
|
||||
import { SITE } from "@/lib/site";
|
||||
import { IconInstagram, IconPhone, IconPin, IconTelegram, IconWhatsapp } from "./Icons";
|
||||
|
||||
function MeshkeeCredit() {
|
||||
return (
|
||||
<a href={SITE.meshkeeUrl} target="_blank" rel="noopener noreferrer" dir="ltr" className="meshkee-credit">
|
||||
<span className="meshkee-credit__frame" aria-hidden="true">
|
||||
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||
</span>
|
||||
<Image
|
||||
src="/images/logo/meshkee-white.png"
|
||||
alt="Meshkee"
|
||||
width={42}
|
||||
height={42}
|
||||
className="meshkee-credit__logo"
|
||||
/>
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-16 bg-ink text-white">
|
||||
<div className="bg-white">
|
||||
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
|
||||
{features.map((item) => (
|
||||
<div key={item.title} className="flex items-center gap-3">
|
||||
<Image src={item.image} alt="" width={48} height={48} className="w-12 h-12 object-contain" />
|
||||
<h3 className="text-sm font-medium text-ink">{item.title}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container-site py-12 grid gap-10 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="lg:col-span-1">
|
||||
<div className="mb-4 inline-flex rounded-xl bg-white px-4 py-3">
|
||||
<Image src="/images/logo/logo.png" alt={SITE.nameFa} width={160} height={44} className="h-11 w-auto" />
|
||||
</div>
|
||||
<p className="text-sm leading-8 text-white/70 text-justify">{SITE.aboutShort}</p>
|
||||
<div className="flex items-center gap-3 mt-5">
|
||||
<a href={SITE.telegram} aria-label="تلگرام" className="grid place-items-center w-9 h-9 rounded-full bg-white/10 hover:bg-brand">
|
||||
<IconTelegram />
|
||||
</a>
|
||||
<a href={SITE.instagram} aria-label="اینستاگرام" className="grid place-items-center w-9 h-9 rounded-full bg-white/10 hover:bg-brand">
|
||||
<IconInstagram />
|
||||
</a>
|
||||
<a href={SITE.whatsapp} aria-label="واتساپ" className="grid place-items-center w-9 h-9 rounded-full bg-white/10 hover:bg-brand">
|
||||
<IconWhatsapp />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-lg font-bold">پیوندها</h4>
|
||||
<ul className="space-y-2.5 text-sm text-white/70">
|
||||
{footerLinks.pages.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="hover:text-white">
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-lg font-bold">راهنمایی و پشتیبانی</h4>
|
||||
<ul className="space-y-2.5 text-sm text-white/70">
|
||||
{footerLinks.support.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="hover:text-white">
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 text-lg font-bold">راههای ارتباطی</h4>
|
||||
<a href={`tel:${SITE.phone}`} className="flex items-center gap-3 text-sm">
|
||||
<span className="grid place-items-center w-10 h-10 rounded-full bg-white/10 text-brand shrink-0">
|
||||
<IconPhone className="w-4 h-4" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-white/50 text-xs mb-0.5">تلفن تماس</span>
|
||||
<span className="font-oswald tracking-wide text-base" dir="ltr">
|
||||
{SITE.phoneDisplay}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<p className="mt-4 flex items-start gap-3 text-sm text-white/70">
|
||||
<span className="grid place-items-center w-10 h-10 rounded-full bg-white/10 text-brand shrink-0">
|
||||
<IconPin className="w-4 h-4" />
|
||||
</span>
|
||||
{SITE.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1d1d1d]">
|
||||
<div className="container-site footer-bottom-bar py-4">
|
||||
<p className="footer-bottom-site">{SITE.nameFa}</p>
|
||||
<MeshkeeCredit />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cartCount } from "@/lib/cart";
|
||||
import { parentCategories } from "@/lib/data";
|
||||
import { SITE } from "@/lib/site";
|
||||
import { IconCart, IconChevron, IconClose, IconMenu, IconUser } from "./Icons";
|
||||
import { SearchBox } from "./SearchBox";
|
||||
|
||||
const nav = [
|
||||
{ name: "فروش ویژه", href: "#special-sale" },
|
||||
{ name: "درباره ما", href: "/aboutus" },
|
||||
{ name: "سوالات متداول", href: "/frequently-asked-questions" },
|
||||
{ name: "تماس با ما", href: "/contactus" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mega, setMega] = useState(false);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setCount(cartCount());
|
||||
sync();
|
||||
window.addEventListener("cart-updated", sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener("cart-updated", sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-black/5">
|
||||
<div className="container-site flex h-[76px] items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden grid place-items-center w-10 h-10 text-ink"
|
||||
aria-label="منو"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<IconMenu />
|
||||
</button>
|
||||
|
||||
<Link href="/" className="shrink-0">
|
||||
<Image src="/images/logo/logo.png" alt={SITE.nameFa} width={150} height={40} className="h-10 w-auto" priority />
|
||||
</Link>
|
||||
|
||||
<nav className="hidden lg:flex items-center gap-1 mr-4 text-sm">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setMega(true)}
|
||||
onMouseLeave={() => setMega(false)}
|
||||
>
|
||||
<Link
|
||||
href="/products"
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg font-medium hover:text-brand"
|
||||
>
|
||||
محصولات
|
||||
<IconChevron className="w-3.5 h-3.5 -rotate-90" />
|
||||
</Link>
|
||||
{mega && (
|
||||
<div className="absolute top-full right-0 w-[760px] rounded-2xl bg-white border border-black/5 shadow-xl p-5 grid grid-cols-4 gap-5">
|
||||
{parentCategories.map((cat) => (
|
||||
<div key={cat.slug}>
|
||||
<Link href={`/products/${cat.slug}`} className="block mb-3 text-sm font-bold text-brand">
|
||||
{cat.name}
|
||||
</Link>
|
||||
<ul className="space-y-1.5">
|
||||
{cat.children.slice(0, 8).map((child) => (
|
||||
<li key={child.slug}>
|
||||
<Link
|
||||
href={`/products/${child.slug}`}
|
||||
className="block text-[13px] text-zinc-600 hover:text-ink"
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{nav.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="px-3 py-2 rounded-lg hover:text-brand">
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<SearchBox className="hidden md:block ms-4 max-w-sm flex-1 rounded-full bg-zinc-100 px-3" />
|
||||
|
||||
<div className="flex items-center gap-2 ms-auto">
|
||||
<a
|
||||
href={SITE.customerUrl}
|
||||
className="flex items-center gap-2 rounded-full px-3 py-2 text-sm hover:bg-zinc-100"
|
||||
>
|
||||
<IconUser />
|
||||
<span className="hidden sm:inline">ورود / ثبت نام</span>
|
||||
</a>
|
||||
<Link href="/cart" className="relative grid place-items-center w-10 h-10 rounded-full hover:bg-zinc-100">
|
||||
<IconCart className="w-6 h-6 text-brand" />
|
||||
<span className="absolute top-1 left-1 grid place-items-center w-4 h-4 rounded-full bg-brand text-[10px] text-white">
|
||||
{count.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<button className="absolute inset-0 bg-black/40" aria-label="بستن" onClick={() => setOpen(false)} />
|
||||
<aside className="absolute inset-y-0 right-0 w-[86%] max-w-sm bg-white p-5 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Image src="/images/logo/logo.png" alt={SITE.nameFa} width={130} height={36} className="h-9 w-auto" />
|
||||
<button type="button" aria-label="بستن منو" onClick={() => setOpen(false)}>
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
|
||||
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
|
||||
<div className="space-y-4 mb-6">
|
||||
{parentCategories.map((cat) => (
|
||||
<div key={cat.slug}>
|
||||
<Link href={`/products/${cat.slug}`} className="font-bold text-brand" onClick={() => setOpen(false)}>
|
||||
{cat.name}
|
||||
</Link>
|
||||
<ul className="mt-2 grid grid-cols-2 gap-1">
|
||||
{cat.children.slice(0, 6).map((child) => (
|
||||
<li key={child.slug}>
|
||||
<Link href={`/products/${child.slug}`} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
|
||||
{child.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-black/5 pt-4">
|
||||
{nav.map((item) => (
|
||||
<Link key={item.href} href={item.href} className="py-2" onClick={() => setOpen(false)}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<a href={SITE.customerUrl} className="py-2">
|
||||
ورود / ثبت نام
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { heroSides, heroSlides } from "@/lib/data";
|
||||
|
||||
const overlayStyle = {
|
||||
background:
|
||||
"linear-gradient(to left, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.72) 42%, rgba(0,0,0,0.28) 72%, transparent 100%)",
|
||||
};
|
||||
|
||||
export function HeroSlider() {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const id = window.setInterval(() => {
|
||||
setIndex((current) => (current + 1) % heroSlides.length);
|
||||
}, 5000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [paused]);
|
||||
|
||||
return (
|
||||
<section className="container-site pt-5">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl aspect-[21/8] min-h-[240px] md:min-h-[360px]"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
{heroSlides.map((slide, i) => (
|
||||
<div
|
||||
key={slide.title}
|
||||
aria-hidden={i !== index}
|
||||
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
||||
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={slide.image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="1200px"
|
||||
priority={i === 0}
|
||||
className="object-cover object-[28%_center]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
|
||||
|
||||
{heroSlides.map((slide, i) => (
|
||||
<div
|
||||
key={`${slide.title}-copy`}
|
||||
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
|
||||
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
|
||||
<p dir="ltr" className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase">
|
||||
{slide.english}
|
||||
</p>
|
||||
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">{slide.subtitle}</p>
|
||||
<Link
|
||||
href={slide.href}
|
||||
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
|
||||
>
|
||||
{slide.button}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
|
||||
{heroSlides.map((slide, i) => (
|
||||
<button
|
||||
key={slide.title}
|
||||
type="button"
|
||||
aria-label={`اسلاید ${i + 1}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{heroSides.map((side) => (
|
||||
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
|
||||
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
|
||||
<Image src={side.image} alt="" fill sizes="600px" className="object-cover object-[28%_center]" />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]" style={overlayStyle} />
|
||||
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
|
||||
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
|
||||
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
|
||||
{side.english}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export function IconSearch({ className = "w-5 h-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M20 20l-3.5-3.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconUser({ className = "w-5 h-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="3.5" />
|
||||
<path d="M5 19c1.5-3.5 4-5 7-5s5.5 1.5 7 5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCart({ className = "w-6 h-6" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path
|
||||
d="M6.2 7h11.6l.86 11.2A1.8 1.8 0 0 1 16.87 20H7.13a1.8 1.8 0 0 1-1.79-1.8L6.2 7Z"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M9 7V5.6A3 3 0 0 1 12 2.6 3 3 0 0 1 15 5.6V7" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevron({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 6l-6 6 6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMenu({ className = "w-6 h-6" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClose({ className = "w-6 h-6" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPhone({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M7 3h4l1 5-3 2a12 12 0 0 0 5 5l2-3 5 1v4c0 1-1 2-2 2C10 19 5 14 5 5c0-1 1-2 2-2z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPin({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 21s7-5.4 7-11a7 7 0 1 0-14 0c0 5.6 7 11 7 11z" />
|
||||
<circle cx="12" cy="10" r="2.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrow({ dir = "left", className = "w-5 h-5" }: { dir?: "left" | "right"; className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
{dir === "left" ? (
|
||||
<path d="M15 5l-7 7 7 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTelegram({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.5 4.3 18.2 20c-.25 1.1-.9 1.35-1.82.84l-5.04-3.7-2.43 2.34c-.27.27-.5.5-.1.5l.9-4.1 10.2-9.2c.44-.4-.1-.62-.68-.23L6.4 13.2 2.45 12c-1.07-.33-1.08-1.07.23-1.62l16.9-6.52c.9-.34 1.68.2 1.92 1.44z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconInstagram({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="5" />
|
||||
<circle cx="12" cy="12" r="3.5" />
|
||||
<circle cx="17.3" cy="6.7" r="0.8" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWhatsapp({ className = "w-4 h-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.04 3.5A8.45 8.45 0 0 0 4.4 15.7L3.5 20.5l4.92-.9A8.45 8.45 0 1 0 12.04 3.5zm4.9 12.05c-.2.57-1.18 1.05-1.64 1.12-.42.06-.95.09-1.54-.1-.35-.11-.8-.26-1.38-.51-2.43-1.05-4-3.5-4.13-3.66-.12-.16-1.02-1.36-1.02-2.6 0-1.23.65-1.84.88-2.09.23-.25.5-.31.67-.31h.48c.15 0 .36-.06.56.43.2.5.68 1.73.74 1.86.06.12.1.27.02.44-.08.16-.12.27-.24.41-.12.15-.25.33-.36.44-.12.12-.24.25-.1.49.14.24.62 1.02 1.33 1.65.92.82 1.69 1.07 1.93 1.19.24.12.38.1.52-.06.14-.16.6-.7.76-.94.16-.24.32-.2.54-.12.22.08 1.4.66 1.64.78.24.12.4.18.46.28.06.1.06.58-.14 1.15z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Image, { type StaticImageData } from "next/image";
|
||||
|
||||
type Props = {
|
||||
src: string | StaticImageData;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
sizes?: string;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function Media({ src, alt = "", className = "", sizes, priority }: Props) {
|
||||
const remote = typeof src === "string" && /^https?:\/\//.test(src);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-zinc-100 ${className}`}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes={sizes ?? "100vw"}
|
||||
className="object-cover"
|
||||
priority={priority}
|
||||
unoptimized={remote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Crumb = {
|
||||
name: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
english: string;
|
||||
crumbs?: Crumb[];
|
||||
};
|
||||
|
||||
export function PageHero({ title, english, crumbs }: Props) {
|
||||
const trail = crumbs ?? [{ name: title }];
|
||||
|
||||
return (
|
||||
<div className="page-hero border-b border-black/5">
|
||||
<div className="container-site flex items-end justify-between gap-4 py-4 md:py-5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-zinc-400 mb-1">
|
||||
<Link href="/" className="hover:text-brand">
|
||||
خانه
|
||||
</Link>
|
||||
{trail.map((crumb) => (
|
||||
<span key={crumb.name}>
|
||||
<span className="mx-1.5">/</span>
|
||||
{crumb.href ? (
|
||||
<Link href={crumb.href} className="hover:text-brand">
|
||||
{crumb.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{crumb.name}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<h1 className="text-xl md:text-2xl font-extrabold">{title}</h1>
|
||||
</div>
|
||||
<p dir="ltr" className="shrink-0 pb-0.5 font-carter text-[10px] tracking-[0.4em] text-zinc-400 uppercase">
|
||||
{english}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
makeHref: (page: number) => string;
|
||||
};
|
||||
|
||||
export function Pagination({ page, pageSize, total, makeHref }: Props) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (pages <= 1) return null;
|
||||
|
||||
const prev = page > 1 ? page - 1 : null;
|
||||
const next = page < pages ? page + 1 : null;
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex items-center justify-center gap-3 text-sm">
|
||||
{prev ? (
|
||||
<Link href={makeHref(prev)} className="rounded-lg border border-black/10 px-4 py-2 hover:border-brand hover:text-brand">
|
||||
قبلی
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-lg border border-black/5 px-4 py-2 text-zinc-300">قبلی</span>
|
||||
)}
|
||||
<span className="text-zinc-500">
|
||||
صفحه {page} از {pages}
|
||||
</span>
|
||||
{next ? (
|
||||
<Link href={makeHref(next)} className="rounded-lg border border-black/10 px-4 py-2 hover:border-brand hover:text-brand">
|
||||
بعدی
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-lg border border-black/5 px-4 py-2 text-zinc-300">بعدی</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { discountPercent, formatPrice } from "@/lib/format";
|
||||
import type { Product } from "@/lib/data";
|
||||
import { Media } from "./Media";
|
||||
|
||||
export function ProductCard({ product }: { product: Product }) {
|
||||
const off = discountPercent(product.price, product.oldPrice);
|
||||
const unavailable = product.stock === 0 || product.price === 0;
|
||||
|
||||
return (
|
||||
<Link href={product.slug ? `/products/${product.slug}` : "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
|
||||
{off > 0 && (
|
||||
<span className="absolute top-2 left-2 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
|
||||
{off}%
|
||||
</span>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Media src={product.image} alt="" className="aspect-square rounded-lg" sizes="220px" />
|
||||
</div>
|
||||
<h3 className="mt-2 px-1 text-[13px] leading-6 text-ink line-clamp-2 min-h-12">{product.name}</h3>
|
||||
<div className="mt-auto px-1 pb-1 pt-2">
|
||||
{unavailable ? (
|
||||
<span className="text-sm text-zinc-400">ناموجود</span>
|
||||
) : (
|
||||
<div className="ms-auto flex flex-col items-end leading-tight">
|
||||
{product.oldPrice ? (
|
||||
<span className="text-[12px] font-medium text-brand line-through">
|
||||
{formatPrice(product.oldPrice)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[15px] font-bold text-[#333]">
|
||||
{formatPrice(product.price)}
|
||||
<span className="mr-1 text-[12px] font-normal">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Product } from "@/lib/data";
|
||||
import { ProductCard } from "./ProductCard";
|
||||
|
||||
export function ProductGrid({ products }: { products: Product[] }) {
|
||||
if (!products.length) {
|
||||
return <p className="py-16 text-center text-sm text-zinc-500">محصولی یافت نشد.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="rounded-xl border border-black/5 bg-white">
|
||||
<ProductCard product={product} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Image from "next/image";
|
||||
import type { Product } from "@/lib/data";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { ProductCard } from "./ProductCard";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
image: string;
|
||||
products: Product[];
|
||||
ariaLabel: string;
|
||||
tone?: "red" | "blue";
|
||||
};
|
||||
|
||||
const gradients = {
|
||||
red: "linear-gradient(90deg, #ff3b3b 0%, #fc2424 45%, #d91010 100%)",
|
||||
blue: "linear-gradient(90deg, #4db3ff 0%, #1e88e5 48%, #0d5fb8 100%)",
|
||||
};
|
||||
|
||||
export function ProductShelf({ id, image, products, ariaLabel, tone = "red" }: Props) {
|
||||
return (
|
||||
<section id={id} className="container-site mt-10">
|
||||
<div className="rounded-2xl p-4 md:p-5" style={{ background: gradients[tone] }}>
|
||||
<div className="flex items-stretch gap-3">
|
||||
<div className="relative w-[150px] sm:w-[190px] shrink-0 min-h-[300px]">
|
||||
<Image src={image} alt="" fill sizes="190px" className="object-contain object-center" />
|
||||
</div>
|
||||
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="snap-start shrink-0 w-[180px] sm:w-[200px]">
|
||||
<ProductCard product={product} />
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import type { ApiComment } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { SITE } from "@/lib/site";
|
||||
import { RichContent } from "./RichContent";
|
||||
|
||||
type Props = {
|
||||
productId: string;
|
||||
descriptionHtml?: string | null;
|
||||
comments: ApiComment[];
|
||||
};
|
||||
|
||||
type Tab = {
|
||||
id: "description" | "comments";
|
||||
fa: string;
|
||||
en: string;
|
||||
};
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: "description", fa: "توضیحات محصول", en: "Description" },
|
||||
{ id: "comments", fa: "نظرات", en: "Comments" },
|
||||
];
|
||||
|
||||
function readToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return (
|
||||
window.localStorage.getItem("accessToken") ||
|
||||
window.localStorage.getItem("meshkee_access_token") ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTabs({ productId, descriptionHtml, comments: initialComments }: Props) {
|
||||
const [tab, setTab] = useState<"description" | "comments">("description");
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const authorName = "کاربر";
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(Boolean(readToken()));
|
||||
}, []);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!loggedIn) return;
|
||||
const form = event.currentTarget;
|
||||
const text = String(new FormData(form).get("text") || "").trim();
|
||||
if (!text) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const response = await fetch("/api/comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entityType: "product",
|
||||
entityId: productId,
|
||||
authorName,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
setSent(true);
|
||||
form.reset();
|
||||
setComments((current) => [
|
||||
{
|
||||
id: `local-${Date.now()}`,
|
||||
authorName,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-10 rounded-2xl border border-black/5">
|
||||
<div className="flex justify-center gap-5 border-b border-black/5">
|
||||
{tabs.map((item) => {
|
||||
const active = tab === item.id;
|
||||
const label = item.id === "comments" && comments.length
|
||||
? `${item.fa} (${comments.length.toLocaleString("fa-IR")})`
|
||||
: item.fa;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
className={`px-2 py-3 text-center ${
|
||||
active ? "border-b-2 border-brand text-brand" : "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
<span className={`block text-base leading-none ${active ? "font-bold" : ""}`}>{label}</span>
|
||||
<span dir="ltr" className="mt-0.5 block font-oswald text-[9px] font-normal leading-none tracking-[0.16em] uppercase opacity-70">
|
||||
{item.en}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-5 md:p-8">
|
||||
{tab === "description" ? (
|
||||
descriptionHtml ? (
|
||||
<RichContent html={descriptionHtml} />
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">توضیحی برای این محصول ثبت نشده است.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{comments.length ? (
|
||||
comments.map((comment) => (
|
||||
<article key={comment.id} className="rounded-xl bg-zinc-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-zinc-400">
|
||||
<span className="font-bold text-ink">{comment.authorName || "کاربر"}</span>
|
||||
{comment.createdAt ? <time>{formatDate(comment.createdAt)}</time> : null}
|
||||
</div>
|
||||
<p className="text-sm leading-7 text-zinc-600">{comment.text}</p>
|
||||
</article>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">هنوز نظری ثبت نشده است.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loggedIn ? (
|
||||
<form onSubmit={onSubmit} className="rounded-xl border border-black/5 p-4">
|
||||
{sent ? (
|
||||
<p className="text-sm leading-7 text-zinc-600">نظر شما ثبت شد و پس از تایید نمایش داده میشود.</p>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
required
|
||||
name="text"
|
||||
placeholder="نظر خود را بنویسید..."
|
||||
className="min-w-0 flex-1 rounded-xl border border-black/5 bg-zinc-50 px-4 py-2.5 text-sm outline-none focus:border-brand"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="shrink-0 rounded-xl bg-brand px-5 py-2.5 text-sm text-white disabled:opacity-60"
|
||||
>
|
||||
ارسال
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div className="rounded-xl bg-zinc-50 px-4 py-5 text-center">
|
||||
<p className="text-sm text-zinc-600">برای ثبت نظر ابتدا وارد حساب کاربری شوید.</p>
|
||||
<a
|
||||
href={SITE.customerUrl}
|
||||
className="mt-3 inline-block text-sm text-brand"
|
||||
>
|
||||
ورود / ثبت نام
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ApiProduct, ApiVariant } from "@/lib/api";
|
||||
import { addToCart } from "@/lib/cart";
|
||||
import { PLACEHOLDER } from "@/lib/data";
|
||||
import { discountPercent, formatPrice } from "@/lib/format";
|
||||
import { IconCart } from "./Icons";
|
||||
import { Media } from "./Media";
|
||||
|
||||
type Props = {
|
||||
product: ApiProduct;
|
||||
variants: ApiVariant[];
|
||||
};
|
||||
|
||||
export function ProductView({ product, variants }: Props) {
|
||||
const images = useMemo(() => {
|
||||
const urls = [
|
||||
product.thumbnailUrl,
|
||||
product.image,
|
||||
product.thumbnail,
|
||||
...(product.images ?? []).map((item) => item.url),
|
||||
].filter((url): url is string => Boolean(url));
|
||||
return [...new Set(urls)];
|
||||
}, [product]);
|
||||
|
||||
const [image, setImage] = useState(images[0] || PLACEHOLDER.product);
|
||||
const [variantId, setVariantId] = useState(variants[0]?.id);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
|
||||
const price = variant ? (variant.discountedPrice ?? variant.price) : (product.store?.minPrice ?? 0);
|
||||
const oldPrice = variant?.discountedPrice ? variant.price : undefined;
|
||||
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
|
||||
const unavailable = !price || stock === 0;
|
||||
const off = discountPercent(price, oldPrice);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-12 items-start">
|
||||
<div className="lg:col-span-6">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-black/5">
|
||||
{off > 0 && (
|
||||
<span className="absolute top-3 left-3 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
|
||||
{off}%
|
||||
</span>
|
||||
)}
|
||||
<Media src={image} alt={product.nameFa || product.title} className="aspect-square" sizes="560px" priority />
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<div className="mt-3 grid grid-cols-6 gap-2">
|
||||
{images.map((src) => (
|
||||
<button
|
||||
key={src}
|
||||
type="button"
|
||||
onClick={() => setImage(src)}
|
||||
className={`relative aspect-square overflow-hidden rounded-lg border ${
|
||||
src === image ? "border-brand" : "border-black/10"
|
||||
}`}
|
||||
>
|
||||
<Media src={src} alt="" className="size-full" sizes="80px" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-black/5 p-5 md:p-6 lg:col-span-6">
|
||||
{product.categoryNameFa && <p className="mb-2 text-xs text-zinc-400">{product.categoryNameFa}</p>}
|
||||
<h1 className="text-xl md:text-2xl font-extrabold leading-9">{product.nameFa || product.title}</h1>
|
||||
{product.title && product.nameFa && (
|
||||
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
|
||||
{product.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{variants.length > 1 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm text-zinc-500">انتخاب تنوع</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variants.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setVariantId(item.id)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm ${
|
||||
item.id === variant?.id ? "border-brand text-brand" : "border-black/10 text-zinc-600"
|
||||
}`}
|
||||
>
|
||||
{item.label || item.selections?.map((selection) => selection.value).join(" / ") || "تنوع"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
{unavailable ? (
|
||||
<span className="text-lg text-zinc-400">ناموجود</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{oldPrice ? <span className="text-sm font-medium text-brand line-through">{formatPrice(oldPrice)}</span> : null}
|
||||
<span className="text-2xl font-extrabold">
|
||||
{formatPrice(price)}
|
||||
<span className="mr-1 text-sm font-normal">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!unavailable && (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center overflow-hidden rounded-xl border border-black/10">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="کاهش"
|
||||
onClick={() => setQty((value) => Math.max(1, value - 1))}
|
||||
className="grid size-10 place-items-center text-lg hover:bg-zinc-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="min-w-10 text-center text-sm font-bold">{qty.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="افزایش"
|
||||
onClick={() => setQty((value) => Math.min(stock || 99, value + 1))}
|
||||
className="grid size-10 place-items-center text-lg hover:bg-zinc-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: variant?.id,
|
||||
slug: product.slug,
|
||||
name: product.nameFa || product.title,
|
||||
image: images[0] || PLACEHOLDER.product,
|
||||
price,
|
||||
qty,
|
||||
});
|
||||
setAdded(true);
|
||||
}}
|
||||
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-brand px-5 py-2.5 text-sm text-white hover:bg-[#e51f1f]"
|
||||
>
|
||||
<IconCart className="w-5 h-5 text-white" />
|
||||
{added ? "به سبد اضافه شد" : "افزودن به سبد خرید"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function RichContent({ html }: { html?: string | null }) {
|
||||
if (!html) return null;
|
||||
|
||||
return <div className="rich-content" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { Product } from "@/lib/data";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { IconSearch } from "./Icons";
|
||||
import { Media } from "./Media";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
export function SearchBox({ className = "", onNavigate }: Props) {
|
||||
const listId = useId();
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [items, setItems] = useState<Product[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const term = query.trim();
|
||||
if (term.length < 2) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(term)}`, { signal: controller.signal });
|
||||
const data = (await response.json()) as { items?: Product[] };
|
||||
setItems(data.items ?? []);
|
||||
setOpen(true);
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name !== "AbortError") {
|
||||
setItems([]);
|
||||
setOpen(true);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 160);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointer = (event: MouseEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointer);
|
||||
return () => document.removeEventListener("mousedown", onPointer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={root} className={`relative ${className}`}>
|
||||
<form action="/products" className="flex w-full items-center">
|
||||
<input
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => {
|
||||
if (query.trim().length >= 2) setOpen(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}}
|
||||
placeholder="جستجو ..."
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
className="w-full min-w-0 flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button type="submit" aria-label="جستجو" className="text-zinc-500 hover:text-brand">
|
||||
<IconSearch />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id={listId}
|
||||
className="absolute top-[calc(100%+8px)] right-0 left-0 z-50 overflow-hidden rounded-2xl border border-black/8 bg-white shadow-xl"
|
||||
>
|
||||
{loading && !items.length ? (
|
||||
<p className="px-4 py-3 text-sm text-zinc-400">در حال جستجو...</p>
|
||||
) : items.length ? (
|
||||
<ul>
|
||||
{items.map((product) => {
|
||||
const unavailable = product.stock === 0 || product.price === 0;
|
||||
return (
|
||||
<li key={product.id}>
|
||||
<Link
|
||||
href={product.slug ? `/products/${product.slug}` : `/products?q=${encodeURIComponent(query)}`}
|
||||
onClick={onNavigate}
|
||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
|
||||
>
|
||||
<Media src={product.image} alt="" className="size-12 shrink-0 rounded-lg" sizes="48px" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] leading-6">{product.name}</p>
|
||||
{unavailable ? (
|
||||
<p className="text-xs text-zinc-400">ناموجود</p>
|
||||
) : (
|
||||
<p className="text-xs text-zinc-500">
|
||||
{formatPrice(product.price)} <span>تومان</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-4 py-3 text-sm text-zinc-400">نتیجهای پیدا نشد.</p>
|
||||
)}
|
||||
<Link
|
||||
href={`/products?q=${encodeURIComponent(query.trim())}`}
|
||||
onClick={onNavigate}
|
||||
className="block border-t border-black/5 px-4 py-2.5 text-center text-sm text-brand"
|
||||
>
|
||||
مشاهده همه نتایج
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PLACEHOLDER } from "@/lib/data";
|
||||
import { getSpecialProducts } from "@/lib/mappers";
|
||||
import { ProductShelf } from "./ProductShelf";
|
||||
|
||||
export async function SpecialSale() {
|
||||
const products = await getSpecialProducts("Special-Sale");
|
||||
if (!products.length) return null;
|
||||
|
||||
return (
|
||||
<ProductShelf
|
||||
id="special-sale"
|
||||
image={PLACEHOLDER.specialSale}
|
||||
products={products}
|
||||
ariaLabel="فروش ویژه"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SITE } from "@/lib/site";
|
||||
import { IconInstagram, IconPhone, IconPin, IconTelegram, IconWhatsapp } from "./Icons";
|
||||
|
||||
export function TopHeader() {
|
||||
return (
|
||||
<div className="bg-ink text-white text-xs">
|
||||
<div className="container-site flex h-10 items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<a href={`tel:${SITE.phone}`} className="flex items-center gap-1.5 hover:text-brand-soft">
|
||||
<IconPhone className="w-3.5 h-3.5 text-brand" />
|
||||
<span className="font-oswald tracking-wide" dir="ltr">
|
||||
{SITE.phoneDisplay}
|
||||
</span>
|
||||
</a>
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-white/70">
|
||||
<IconPin className="w-3.5 h-3.5 text-brand" />
|
||||
{SITE.address}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden md:block text-white/70">ارسال به سراسر ایران</span>
|
||||
<a href={SITE.telegram} aria-label="تلگرام" className="hover:text-brand">
|
||||
<IconTelegram />
|
||||
</a>
|
||||
<a href={SITE.instagram} aria-label="اینستاگرام" className="hover:text-brand">
|
||||
<IconInstagram />
|
||||
</a>
|
||||
<a href={SITE.whatsapp} aria-label="واتساپ" className="hover:text-brand">
|
||||
<IconWhatsapp />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PLACEHOLDER } from "@/lib/data";
|
||||
import { getSpecialProducts } from "@/lib/mappers";
|
||||
import { ProductShelf } from "./ProductShelf";
|
||||
|
||||
export async function TopSelling() {
|
||||
const products = await getSpecialProducts("Top-Selling");
|
||||
if (!products.length) return null;
|
||||
|
||||
return (
|
||||
<ProductShelf
|
||||
image={PLACEHOLDER.topSelling}
|
||||
products={products}
|
||||
ariaLabel="محصولات پرفروش"
|
||||
tone="blue"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { API } from "./site";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
async function apiGet<T>(path: string, query?: Query): Promise<T> {
|
||||
const url = new URL(`${API.base}${path}`);
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, { next: { revalidate: 120 } });
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, `API ${response.status} ${path}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type ApiProduct = {
|
||||
id: string;
|
||||
title: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
summary?: string | null;
|
||||
descriptionHtml?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
categoryNameFa?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
image?: string | null;
|
||||
thumbnail?: string | null;
|
||||
images?: { mediaId: string; url: string }[];
|
||||
commentCount?: number;
|
||||
store?: {
|
||||
variantCount: number;
|
||||
minPrice: number | null;
|
||||
maxPrice: number | null;
|
||||
inStock: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ApiComment = {
|
||||
id: string;
|
||||
authorName?: string | null;
|
||||
text?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type ApiBlog = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
categoryName?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
author?: {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ApiCategory = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
export type ApiVariant = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
price: number;
|
||||
discountedPrice?: number | null;
|
||||
stockQuantity?: number | null;
|
||||
sortOrder?: number;
|
||||
selections?: {
|
||||
variationId: string;
|
||||
variationName: string;
|
||||
optionId: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ApiSpecialItem = {
|
||||
id: string;
|
||||
productId: string;
|
||||
productTitle: string;
|
||||
productNameFa: string | null;
|
||||
productImage: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
variants: ApiVariant[];
|
||||
};
|
||||
|
||||
export type ApiStoreSpecial = {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
items: ApiSpecialItem[];
|
||||
};
|
||||
|
||||
export type ApiStoreItem = {
|
||||
id: string;
|
||||
productId: string;
|
||||
variants: ApiVariant[];
|
||||
};
|
||||
|
||||
const tenant = () => `/tenants/${API.domain}`;
|
||||
|
||||
export async function getProducts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
} = {}) {
|
||||
return apiGet<Paginated<ApiProduct>>(`${tenant()}/products`, query);
|
||||
}
|
||||
|
||||
export async function getProduct(slug: string) {
|
||||
try {
|
||||
const data = await apiGet<{ product: ApiProduct }>(`${tenant()}/products/${slug}`);
|
||||
return data.product;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
|
||||
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
|
||||
}
|
||||
|
||||
export async function getBlog(slug: string) {
|
||||
try {
|
||||
const data = await apiGet<{ blog: ApiBlog }>(`${tenant()}/blogs/${slug}`);
|
||||
return data.blog;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoreSpecials() {
|
||||
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function getStoreItemByProduct(productId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
|
||||
return data.storeItem;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategories() {
|
||||
try {
|
||||
const data = await apiGet<{ items: ApiCategory[] }>(`${tenant()}/categories`, { entityType: "product" });
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCategoryBySlug(slug: string) {
|
||||
const categories = await getCategories();
|
||||
return categories.find((category) => category.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export async function getComments(entityType: "product" | "blog", entityId: string) {
|
||||
try {
|
||||
const data = await apiGet<{ items: ApiComment[] }>(`${tenant()}/comments`, { entityType, entityId });
|
||||
return data.items ?? [];
|
||||
} catch {
|
||||
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 ?? "";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export type CartItem = {
|
||||
productId: string;
|
||||
variantId?: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
image: string;
|
||||
price: number;
|
||||
qty: number;
|
||||
};
|
||||
|
||||
const KEY = "salar-cart";
|
||||
|
||||
export function getCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as CartItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function cartCount() {
|
||||
return getCart().reduce((sum, item) => sum + item.qty, 0);
|
||||
}
|
||||
|
||||
export function addToCart(item: CartItem) {
|
||||
const cart = getCart();
|
||||
const index = cart.findIndex(
|
||||
(entry) => entry.productId === item.productId && entry.variantId === item.variantId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
cart[index] = { ...cart[index], qty: cart[index].qty + item.qty };
|
||||
} else {
|
||||
cart.push(item);
|
||||
}
|
||||
window.localStorage.setItem(KEY, JSON.stringify(cart));
|
||||
window.dispatchEvent(new Event("cart-updated"));
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import type { StaticImageData } from "next/image";
|
||||
import { samples } from "./samples";
|
||||
|
||||
export type ImageSrc = string | StaticImageData;
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: number;
|
||||
oldPrice?: number;
|
||||
stock?: number | null;
|
||||
image: ImageSrc;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
image: ImageSrc;
|
||||
abstract?: string;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
name: string;
|
||||
english: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
export type ParentCategory = {
|
||||
name: string;
|
||||
slug: string;
|
||||
children: { name: string; slug: string }[];
|
||||
};
|
||||
|
||||
export const heroSlides = [
|
||||
{
|
||||
image: samples.glenJackson,
|
||||
title: "بهترین لوازم کمپ",
|
||||
english: "GEAR FOR THE WILD",
|
||||
subtitle: "از سالار استور",
|
||||
button: "شروع به خرید",
|
||||
href: "/products",
|
||||
},
|
||||
{
|
||||
image: samples.henryXu,
|
||||
title: "همراه شما در کوهستان",
|
||||
english: "BUILT FOR THE SUMMIT",
|
||||
subtitle: "تجهیزات حرفهای کوهنوردی",
|
||||
button: "مشاهده محصولات",
|
||||
href: "/products/camping-and-mountaineering",
|
||||
},
|
||||
{
|
||||
image: samples.jesseGardner,
|
||||
title: "کمپینگ فراموشنشدنی",
|
||||
english: "NIGHTS WORTH THE CLIMB",
|
||||
subtitle: "چادر، کیسه خواب و بیشتر",
|
||||
button: "خرید چادر",
|
||||
href: "/products/sleeping-tent",
|
||||
},
|
||||
];
|
||||
|
||||
export const heroSides = [
|
||||
{
|
||||
image: samples.jiscaLucia,
|
||||
title: "پوشاک طبیعتگردی",
|
||||
english: "WEAR THE TRAIL",
|
||||
subtitle: "آماده برای هر مسیر",
|
||||
href: "/products/clothing",
|
||||
},
|
||||
{
|
||||
image: samples.fabioComparelli,
|
||||
title: "ماجراجویی ادامه دارد",
|
||||
english: "KEEP GOING",
|
||||
subtitle: "لوازم کمپ و کوه",
|
||||
href: "/products/camping-and-mountaineering",
|
||||
},
|
||||
];
|
||||
|
||||
export const PLACEHOLDER = {
|
||||
product: "/images/placeholders/product.png",
|
||||
blog: "/images/placeholders/blog.png",
|
||||
about: samples.jiscaLucia,
|
||||
specialSale: "/images/promo/special-sale.png",
|
||||
topSelling: "/images/promo/top-selling.png",
|
||||
slider: heroSlides.map((slide) => slide.image),
|
||||
sliderSides: heroSides.map((side) => side.image),
|
||||
banners4: [samples.phillipsJacobe, samples.paulGreen, samples.victorLarracuente, samples.seanBenesh],
|
||||
banners3: [samples.kevinSchmid, samples.bradenCollum, samples.jesseGardner],
|
||||
};
|
||||
|
||||
export const parentCategories: ParentCategory[] = [
|
||||
{
|
||||
name: "پوشاک",
|
||||
slug: "clothing",
|
||||
children: [
|
||||
{ name: "کاپشن", slug: "jacket" },
|
||||
{ name: "بادگیر", slug: "windbreaker" },
|
||||
{ name: "گتر", slug: "mountaineering-gaiter" },
|
||||
{ name: "دستمال سر و گردن", slug: "neck-scarf" },
|
||||
{ name: "تیشرت", slug: "t-shirt" },
|
||||
{ name: "بیس لایر", slug: "base-layer" },
|
||||
{ name: "پانچو", slug: "raincoat" },
|
||||
{ name: "شلوار", slug: "pants" },
|
||||
{ name: "کلاه", slug: "hat" },
|
||||
{ name: "دستکش", slug: "gloves" },
|
||||
{ name: "جوراب", slug: "socks" },
|
||||
{ name: "عینک", slug: "glasses" },
|
||||
{ name: "کیف کمری", slug: "waist-bag" },
|
||||
{ name: "سویشرت", slug: "sweatshirt" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "کفش",
|
||||
slug: "shoe",
|
||||
children: [
|
||||
{ name: "صندل", slug: "sandal" },
|
||||
{ name: "کفش کوهنوردی", slug: "shoe" },
|
||||
{ name: "یخ شکن", slug: "crampons" },
|
||||
{ name: "لوازم جانبی کفش", slug: "shoe-accessories" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "کمپینگ و کوهنوردی",
|
||||
slug: "camping-and-mountaineering",
|
||||
children: [
|
||||
{ name: "کیسه خواب", slug: "sleeping-bag" },
|
||||
{ name: "کوله پشتی", slug: "backpack" },
|
||||
{ name: "چادر خواب", slug: "sleeping-tent" },
|
||||
{ name: "قاشق چنگال", slug: "spoon-fork" },
|
||||
{ name: "ظروف", slug: "containers" },
|
||||
{ name: "باتوم کوهنوردی", slug: "mountaineering-baton" },
|
||||
{ name: "چاقو", slug: "knife" },
|
||||
{ name: "کپسول و اجاق گاز", slug: "travel-stove" },
|
||||
{ name: "فندک و چخماق", slug: "lighter" },
|
||||
{ name: "چراغ قوه و هدلایت", slug: "flashlight-and-headlights" },
|
||||
{ name: "زیرانداز", slug: "underlay" },
|
||||
{ name: "سایه بان", slug: "camping-canopy" },
|
||||
{ name: "فلاسک", slug: "flask" },
|
||||
{ name: "بیل و کلنگ", slug: "camping-shovel-and-pickaxe" },
|
||||
{ name: "صندلی", slug: "camping-chairs" },
|
||||
{ name: "تبر", slug: "ax" },
|
||||
{ name: "نظافتی و سلامتی", slug: "cleanliness-and-health" },
|
||||
{ name: "باتری", slug: "battery" },
|
||||
{ name: "بالش", slug: "pillow" },
|
||||
{ name: "امداد و نجات", slug: "amdad-o-ngat" },
|
||||
{ name: "لوازم جانبی", slug: "accessories" },
|
||||
{ name: "بخاری", slug: "heater" },
|
||||
{ name: "کیسه آب و کمل بک", slug: "kamel-beck" },
|
||||
{ name: "شعله افکن", slug: "flame-thrower" },
|
||||
{ name: "قطب نما", slug: "compass" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "صخره و یخ",
|
||||
slug: "rock-and-ice",
|
||||
children: [
|
||||
{ name: "طناب", slug: "rope" },
|
||||
{ name: "کارابین", slug: "carabiner" },
|
||||
{ name: "یخ شکن", slug: "crampons" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const categories: Category[] = [
|
||||
{ name: "اجاق گاز", english: "STOVE", slug: "travel-stove", image: "/images/categories/stove.png" },
|
||||
{ name: "تیشرت", english: "T-SHIRT", slug: "t-shirt", image: "/images/categories/tshirt.png" },
|
||||
{ name: "شلوار", english: "PANTS", slug: "pants", image: "/images/categories/pants.png" },
|
||||
{ name: "چاقو", english: "KNIFE", slug: "knife", image: "/images/categories/knife.png" },
|
||||
{ name: "کفش", english: "SHOES", slug: "shoe", image: "/images/categories/shoe.png" },
|
||||
{ name: "کلاه", english: "HAT", slug: "hat", image: "/images/categories/hat.png" },
|
||||
{ name: "هدلایت", english: "HEADLAMP", slug: "flashlight-and-headlights", image: "/images/categories/headlamp.png" },
|
||||
{ name: "ظروف", english: "COOKWARE", slug: "containers", image: "/images/categories/cookware.png" },
|
||||
{ name: "چادر", english: "TENT", slug: "sleeping-tent", image: "/images/categories/tent.png" },
|
||||
{ name: "باتوم", english: "POLES", slug: "mountaineering-baton", image: "/images/categories/pole.png" },
|
||||
{ name: "کیسه خواب", english: "SLEEPING BAG", slug: "sleeping-bag", image: "/images/categories/sleeping-bag.png" },
|
||||
{ name: "کوله پشتی", english: "BACKPACK", slug: "backpack", image: "/images/categories/backpack.png" },
|
||||
];
|
||||
|
||||
export const specialSaleProducts: Product[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "چراغ سیار خودرو یین زین مدل YX-B067",
|
||||
slug: "yin-xin-yx-b067",
|
||||
price: 490000,
|
||||
oldPrice: 550000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "دستمال سر و گردن زمستانی پیچی جیر",
|
||||
slug: "winter-neck-scarf",
|
||||
price: 290000,
|
||||
oldPrice: 400000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "کیف کمک های اولیه سالار SALAR",
|
||||
slug: "salar-first-aid-kit",
|
||||
price: 405000,
|
||||
oldPrice: 450000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "قاشق چنگال سفری مدل کارابین دار",
|
||||
slug: "carabiner-travel-cutlery",
|
||||
price: 490000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "قاشق چنگال مسافرتی تاشو مدل ارتشی",
|
||||
slug: "army-folding-cutlery",
|
||||
price: 550000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "گل برف باتوم کوهنوردی",
|
||||
slug: "trekking-pole-snow-basket",
|
||||
price: 96800,
|
||||
oldPrice: 110000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "کلاه طوفان پلار هپی اوتدور",
|
||||
slug: "happy-outdoor-storm-cap",
|
||||
price: 450000,
|
||||
oldPrice: 560000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
name: "کلاه پلار نورث فیس",
|
||||
slug: "north-face-polar-hat",
|
||||
price: 745000,
|
||||
oldPrice: 1260000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
name: "محافظ سرسخمه باتوم مدل کلاسیک",
|
||||
slug: "classic-pole-tip-protector",
|
||||
price: 70400,
|
||||
oldPrice: 80000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
name: "سرسخمه یدکی باتوم",
|
||||
slug: "spare-trekking-pole-tip",
|
||||
price: 176000,
|
||||
oldPrice: 220000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
name: "گل سنگ باتوم کوهنوردی",
|
||||
slug: "trekking-pole-mud-basket",
|
||||
price: 59500,
|
||||
oldPrice: 70000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
];
|
||||
|
||||
export const topSellingProducts: Product[] = [
|
||||
{
|
||||
id: "ts-1",
|
||||
name: "طناب پاراکورد فینو طرح ساده (۲۰متر)",
|
||||
slug: "paracord-fino-20m",
|
||||
price: 420000,
|
||||
stock: 1,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-2",
|
||||
name: "کلاه زمستانی مدل شکاری طرح پوست",
|
||||
slug: "hunter-winter-hat",
|
||||
price: 250000,
|
||||
stock: 1,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-3",
|
||||
name: "صندل آب نوردی (ساحلی) نسیم مدل کلمبیا",
|
||||
slug: "nasim-columbia-sandals",
|
||||
price: 400000,
|
||||
stock: 2,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-4",
|
||||
name: "کلاه روسی طرح پوست",
|
||||
slug: "russian-hat",
|
||||
price: 250000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-5",
|
||||
name: "کلاه پلار ۵.۱۱",
|
||||
slug: "polar-hat-511",
|
||||
price: 350000,
|
||||
stock: 0,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-6",
|
||||
name: "پانچو نایلونی اوا EVA (بارانی)",
|
||||
slug: "eva-raincoat",
|
||||
price: 350000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-7",
|
||||
name: "کلاه پلار نورث فیس",
|
||||
slug: "north-face-polar-hat",
|
||||
price: 745000,
|
||||
oldPrice: 1260000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-8",
|
||||
name: "لیوان تاشو لایت مای فایر BACK UP CUP",
|
||||
slug: "light-my-fire-backup-cup",
|
||||
price: 0,
|
||||
stock: 0,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-9",
|
||||
name: "کارابین پیچ c1 (غیر فنی)",
|
||||
slug: "carabiner-c1",
|
||||
price: 130000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
{
|
||||
id: "ts-10",
|
||||
name: "آفتابه مسافرتی A2",
|
||||
slug: "travel-aftabeh-a2",
|
||||
price: 120000,
|
||||
image: PLACEHOLDER.product,
|
||||
},
|
||||
];
|
||||
|
||||
export const banners4 = [
|
||||
{
|
||||
href: "/products/camping-and-mountaineering",
|
||||
image: PLACEHOLDER.banners4[0],
|
||||
title: "کمپینگ و کوهنوردی",
|
||||
english: "CAMP & CLIMB",
|
||||
subtitle: "تجهیزات طبیعتگردی",
|
||||
},
|
||||
{
|
||||
href: "/products/clothing",
|
||||
image: PLACEHOLDER.banners4[1],
|
||||
title: "پوشاک",
|
||||
english: "TRAIL APPAREL",
|
||||
subtitle: "لباس مناسب هر مسیر",
|
||||
},
|
||||
{
|
||||
href: "/products/shoe",
|
||||
image: PLACEHOLDER.banners4[2],
|
||||
title: "کفش",
|
||||
english: "FOOTWEAR",
|
||||
subtitle: "کفش کوه و طبیعت",
|
||||
},
|
||||
{
|
||||
href: "/products/rock-and-ice",
|
||||
image: PLACEHOLDER.banners4[3],
|
||||
title: "صخره و یخ",
|
||||
english: "ROCK & ICE",
|
||||
subtitle: "تجهیزات فنی صعود",
|
||||
},
|
||||
];
|
||||
|
||||
export const banners3 = [
|
||||
{ href: "/products", image: PLACEHOLDER.banners3[0], alt: "همه محصولات سالار استور" },
|
||||
{ href: "/special-sale", image: PLACEHOLDER.banners3[1], alt: "فروش ویژه سالار استور" },
|
||||
{ href: "/festival", image: PLACEHOLDER.banners3[2], alt: "جشنواره سالار استور" },
|
||||
];
|
||||
|
||||
export const brands = [
|
||||
{ name: "Deuter", image: "/images/brands/deuter.png" },
|
||||
{ name: "The North Face", image: "/images/brands/north-face.png" },
|
||||
{ name: "Pekvnew", image: "/images/brands/pekvnew.webp" },
|
||||
{ name: "Scarpa", image: "/images/brands/scarpa.png" },
|
||||
{ name: "Mammut", image: "/images/brands/mammut.png" },
|
||||
{ name: "Salewa", image: "/images/brands/salewa.png" },
|
||||
{ name: "Gerber", image: "/images/brands/gerber.png" },
|
||||
{ name: "Salomon", image: "/images/brands/salomon.png" },
|
||||
{ name: "Jack Wolfskin", image: "/images/brands/jack-wolfskin.png" },
|
||||
{ name: "Armour", image: "/images/brands/armour.png" },
|
||||
{ name: "Wenger", image: "/images/brands/wenger.png" },
|
||||
{ name: "Petzl", image: "/images/brands/petzl.png" },
|
||||
];
|
||||
|
||||
export const posts = [
|
||||
{ slug: "camelbak", title: "کمل بک چیست و چه کاربردی دارد؟", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "mountaineering-side-effects", title: "عوارض کوهنوردی برای زنان و مردان", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "trekking-pole-parts", title: "معرفی اجزای باتوم کوهنوردی", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "sunglasses-guide", title: "نحوه انتخاب عینک آفتابی کوهنوردی", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "sleeping-bag-sleep", title: "نحوه خوابیدن در کیسه خواب", date: "۱۴۰۲/۱۰/۲۳", image: PLACEHOLDER.blog },
|
||||
{ slug: "avalanche", title: "بهمن چیست و چگونه به وجود میآید؟", date: "۱۴۰۲/۱۰/۱۶", image: PLACEHOLDER.blog },
|
||||
{ slug: "survival-kit", title: "راهنمای ساخت کیت بقا کوهنوردی", date: "۱۴۰۲/۰۹/۲۷", image: PLACEHOLDER.blog },
|
||||
{ slug: "stove-buying-guide", title: "فاکتورهای مهم در خرید سرشعله کوهنوردی", date: "۱۴۰۲/۰۹/۲۷", image: PLACEHOLDER.blog },
|
||||
];
|
||||
|
||||
export const footerLinks = {
|
||||
pages: [
|
||||
{ name: "درباره ما", href: "/aboutus" },
|
||||
{ name: "تماس با ما", href: "/contactus" },
|
||||
{ name: "محصولات", href: "/products" },
|
||||
{ name: "وبلاگ سالار استور", href: "/blog" },
|
||||
],
|
||||
support: [
|
||||
{ name: "سوالات متداول", href: "/frequently-asked-questions" },
|
||||
{ name: "راهنمای خرید از سالار استور", href: "/shopping-help" },
|
||||
{ name: "نحوه ارسال و مرجوع", href: "/method-sending-returning" },
|
||||
{ name: "هزینههای ارسال", href: "/shipping-cost" },
|
||||
],
|
||||
bestsellers: [
|
||||
{ name: "گل برف باتوم کوهنوردی", href: "/products/trekking-pole-snow-basket" },
|
||||
{ name: "کلاه پلار نورث فیس", href: "/products/north-face-polar-hat" },
|
||||
{ name: "چاقو گربر مدل ۱۱۴", href: "/products/gerber-114" },
|
||||
{ name: "پانچو یا بارانی نایلونی", href: "/products/eva-raincoat" },
|
||||
],
|
||||
};
|
||||
|
||||
export const features = [
|
||||
{ title: "ارسال از ۲ روز کاری", image: "/images/icons/shipping.webp" },
|
||||
{ title: "ضمانت اصل بودن کالا", image: "/images/icons/guarantee.webp" },
|
||||
{ title: "پشتیبانی ۷ روز هفته", image: "/images/icons/support.webp" },
|
||||
{ title: "ارسال به سراسر ایران", image: "/images/icons/nationwide.webp" },
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
export function formatPrice(value: number, locale: "en-US" | "fa-IR" = "fa-IR") {
|
||||
return value.toLocaleString(locale);
|
||||
}
|
||||
|
||||
export function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("fa-IR");
|
||||
}
|
||||
|
||||
export function discountPercent(price: number, oldPrice?: number) {
|
||||
if (!oldPrice || oldPrice <= price) return 0;
|
||||
return Math.round(((oldPrice - price) / oldPrice) * 100);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { PLACEHOLDER, type Post, type Product } from "./data";
|
||||
import {
|
||||
getBlogs,
|
||||
getProducts,
|
||||
getStoreSpecials,
|
||||
resolveProductSlug,
|
||||
type ApiBlog,
|
||||
type ApiProduct,
|
||||
type ApiSpecialItem,
|
||||
type ApiVariant,
|
||||
} from "./api";
|
||||
import { formatDate } from "./format";
|
||||
|
||||
function productImage(url?: string | null) {
|
||||
return url || PLACEHOLDER.product;
|
||||
}
|
||||
|
||||
function pickVariantPrice(variants: ApiVariant[]) {
|
||||
if (!variants.length) {
|
||||
return { price: 0, oldPrice: undefined as number | undefined, stock: 0 };
|
||||
}
|
||||
|
||||
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
|
||||
const pool = inStock.length ? inStock : variants;
|
||||
const best = pool.reduce((current, next) => {
|
||||
const currentPrice = current.discountedPrice ?? current.price;
|
||||
const nextPrice = next.discountedPrice ?? next.price;
|
||||
return nextPrice < currentPrice ? next : current;
|
||||
});
|
||||
|
||||
const price = best.discountedPrice ?? best.price ?? 0;
|
||||
const oldPrice = best.discountedPrice ? best.price : undefined;
|
||||
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
|
||||
|
||||
return { price, oldPrice, stock };
|
||||
}
|
||||
|
||||
export function mapProduct(item: ApiProduct): Product {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.nameFa || item.title,
|
||||
slug: item.slug,
|
||||
price: item.store?.minPrice ?? 0,
|
||||
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
|
||||
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
|
||||
const { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
|
||||
return {
|
||||
id: item.productId,
|
||||
name: item.productNameFa || item.productTitle,
|
||||
slug,
|
||||
price,
|
||||
oldPrice,
|
||||
stock,
|
||||
image: productImage(item.productImage),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBlog(item: ApiBlog): Post {
|
||||
return {
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
date: formatDate(item.publishedAt || item.createdAt || ""),
|
||||
image: item.titleImageUrl || PLACEHOLDER.blog,
|
||||
abstract: item.abstract?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSpecialProducts(key: string): Promise<Product[]> {
|
||||
try {
|
||||
const specials = await getStoreSpecials();
|
||||
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
|
||||
if (!special) return [];
|
||||
|
||||
const items = special.items
|
||||
.filter((item) => item.isActive)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
|
||||
return mapSpecialItem(item, slug);
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestPosts(pageSize = 12): Promise<Post[]> {
|
||||
try {
|
||||
const data = await getBlogs({ page: 1, pageSize });
|
||||
return data.items.map(mapBlog);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMappedProducts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
categoryId?: string;
|
||||
}) {
|
||||
try {
|
||||
const data = await getProducts(query);
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map(mapProduct),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: query.page ?? 1,
|
||||
pageSize: query.pageSize ?? 12,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMappedProductsForCategory(
|
||||
categoryId: string,
|
||||
childIds: string[],
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) {
|
||||
const ids = [categoryId, ...childIds];
|
||||
if (ids.length === 1) {
|
||||
return listMappedProducts({ page, pageSize, categoryId });
|
||||
}
|
||||
|
||||
const results = await Promise.all(ids.map((id) => listMappedProducts({ page: 1, pageSize: 50, categoryId: id })));
|
||||
const seen = new Set<string>();
|
||||
const items = results.flatMap((result) => result.items).filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
items: items.slice(start, start + pageSize),
|
||||
total: items.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StaticImageData } from "next/image";
|
||||
import bradenCollum from "@/src/assets/images/samples/braden-collum-9HI8UJMSdZA-unsplash.jpg";
|
||||
import fabioComparelli from "@/src/assets/images/samples/fabio-comparelli-uq2E2V4LhCY-unsplash.jpg";
|
||||
import glenJackson from "@/src/assets/images/samples/glen-jackson-mzZVGFfMOkA-unsplash.jpg";
|
||||
import henryXu from "@/src/assets/images/samples/henry-xu-0XHo26Jb8dk-unsplash.jpg";
|
||||
import jesseGardner from "@/src/assets/images/samples/jesse-gardner-wTVr4HR4SBI-unsplash.jpg";
|
||||
import jiscaLucia from "@/src/assets/images/samples/jisca-lucia-HVTVOWDKTCk-unsplash.jpg";
|
||||
import kevinSchmid from "@/src/assets/images/samples/kevin-schmid-AvEY5UPoJZM-unsplash.jpg";
|
||||
import paulGreen from "@/src/assets/images/samples/paul-green-gOHfFgwyDNM-unsplash.jpg";
|
||||
import phillipsJacobe from "@/src/assets/images/samples/phillips-jacobe-3Oq0jNYLp28-unsplash.jpg";
|
||||
import seanBenesh from "@/src/assets/images/samples/sean-benesh-VnmbcgAfL3Q-unsplash.jpg";
|
||||
import sylvainMauroux from "@/src/assets/images/samples/sylvain-mauroux-OIuzDRYA3cw-unsplash.jpg";
|
||||
import victorLarracuente from "@/src/assets/images/samples/victor-larracuente-JQfYGhUcDSg-unsplash.jpg";
|
||||
|
||||
export const samples = {
|
||||
glenJackson,
|
||||
henryXu,
|
||||
jesseGardner,
|
||||
jiscaLucia,
|
||||
fabioComparelli,
|
||||
sylvainMauroux,
|
||||
phillipsJacobe,
|
||||
paulGreen,
|
||||
victorLarracuente,
|
||||
seanBenesh,
|
||||
kevinSchmid,
|
||||
bradenCollum,
|
||||
} as const;
|
||||
|
||||
export const sampleList: StaticImageData[] = Object.values(samples);
|
||||
@@ -0,0 +1,29 @@
|
||||
export const DOMAIN = "salarstore.com";
|
||||
|
||||
export const API = {
|
||||
base: "https://api.meshkee.com/api/v1",
|
||||
domain: DOMAIN,
|
||||
};
|
||||
|
||||
export const SITE = {
|
||||
nameFa: "سالار استور",
|
||||
nameEn: "SALAR STORE",
|
||||
tagline: "فروشگاه لوازم ورزشی و کوهنوردی",
|
||||
domain: DOMAIN,
|
||||
customerUrl: `https://customer.${DOMAIN}`,
|
||||
phone: "09127491700",
|
||||
phoneDisplay: "0912 749 1700",
|
||||
address: "قم، بسیج (هنرستان)، نبش کوچه ۹",
|
||||
instagram: "https://www.instagram.com/salarsport1354/",
|
||||
instagramHandle: "salar.sport1354",
|
||||
telegram: "https://t.me/salarsport1354",
|
||||
telegramHandle: "salarkooh",
|
||||
whatsapp: "https://wa.me/09127491700",
|
||||
email: "info@salarstore.com",
|
||||
meshkeeUrl: "https://meshkee.com",
|
||||
founded: 1394,
|
||||
about:
|
||||
"فروشگاه لوازم ورزشی سالار از سال ۱۳۹۴ فعالیت خود را شروع کرد. مدیر فروشگاه حسین سالاری مربی کوهپیمایی، یخنوردی و کوهنوردی از فدراسیون کوهنوردی ایران میباشد. هدف ما سلامتی و آرامش ورزشکاران است، به همین دلیل ما در سالار استور میکوشیم تا بهترین اجناس و محصولات را برای شما ورزشکاران عزیز فراهم کنیم. در این راه امیدواریم کمک کوچکی به جامعه کوهنوردی و ورزش ایران کرده باشیم.",
|
||||
aboutShort:
|
||||
"سالار استور همگام شما در ورزش با بیش از ۸ سال تجربه و فروش در حوزه ورزشی و خدمترسانی به ورزشکاران.",
|
||||
};
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
localPatterns: [
|
||||
{
|
||||
pathname: "/images/**",
|
||||
},
|
||||
],
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "**.parspack.net", pathname: "/**" },
|
||||
{ protocol: "https", hostname: "cdn.meshkee.com", pathname: "/**" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "salar-tmp",
|
||||
"name": "salar-store",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
Create a website for sport and mountain climbing stuff
|
||||
You can inspire from or clone this old website: salarstore.com
|
||||
You can use banners, icon and color Pallete from this website
|
||||
|
||||
Pick data from current website - all data is valid
|
||||
Lang:fa
|
||||
Directions: rtl
|
||||
Use next js
|
||||
Color palette like website
|
||||
use flat design
|
||||
|
||||
Design only homepage
|
||||
Use Persian font like iran-yekan or kalameh or iran sans and some cool sport like English font
|
||||
Do not use images - show only placeholders images - use empty image file so they can be replace easily
|
||||
Do not use text over the images or banners - even slider - I will update it
|
||||
|
||||
Sections are:
|
||||
- Top header bar
|
||||
- Header
|
||||
- Sliders - two images under it
|
||||
- special sale carousel - use and image as first card - like the old website
|
||||
- categories - use images from old website
|
||||
- 4 banner banner - like the old website
|
||||
- Product Top selling carousel
|
||||
- 3 banner
|
||||
- brands carousel - images from ol website
|
||||
- About us
|
||||
- Latest blog carousel
|
||||
- footer
|
||||
|
||||
For header, as first item add a products, with drop down of top parent categories
|
||||
Other link are: special sale - about us - FAQs - contact us
|
||||
|
||||
Documentation for backend is:https://api.meshkee.com/docs/website
|
||||
But do not connect is unless I say so
|
||||
|
||||
Login or user icon should go to customer.{domainName}.com
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 729 B |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 748 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 906 B |
|
After Width: | Height: | Size: 906 B |
|
After Width: | Height: | Size: 947 B |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 52 KiB |