Initial commit: BRB Store Next.js storefront with Meshkee image slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-21 12:13:41 +03:30
co-authored by Cursor
commit cfa21cbee9
79 changed files with 9482 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
---
description: Keep Meshkee static-image slot catalog in sync with homepage CSS
globs: "{lib/static-images.ts,app/page.tsx,app/meshkee/**,components/home/**}"
alwaysApply: false
---
# Static image slots
Homepage photos come from Meshkee `GET /tenants/{domain}/website/static-images?pageKey=home`. The dashboard catalog is `GET /meshkee/static-image-slots` (`STATIC_IMAGE_SLOTS` in `lib/static-images.ts`).
If you change a slot’s on-page **width, height, or aspect ratio**, update that slot’s `aspectRatio` and `recommendedWidth` in `STATIC_IMAGE_SLOTS` in the same change.
Do not invent upload APIs. `url` → `<img src>`, `linkUrl` → wrap in a link, empty/fail → keep the current placeholder. Use `slot.aspectRatio` from the API when present.
+15
View File
@@ -0,0 +1,15 @@
/node_modules
/.pnp
.pnp.*
.yarn/*
/.next/
/out/
.DS_Store
*.pem
npm-debug.log*
.env*
.vercel
next-env.d.ts
tsconfig.tsbuildinfo
+23
View File
@@ -0,0 +1,23 @@
Domain: brb-store.com
Documentation for backend is:https://api.meshkee.com/docs/website
Login or user icon should go to customer.{domainName}.com
Create product page and details >> connect to store items
Details page:
- use light box gallery
- zoom image on hover
- has two tabs in bottom, description and comments. Comment is for authenticated user if not show a login link button or box
Create blog page and details >> connect to blog
Product Menu should be a mega menu with level 1 and 2 of categories
Search input should be instant search - activate when type more than 2 char
First carousel >> special-sale
Second carousel >> multi-vitamin
Third carousel >> body-building-supplement
update context md for better use in other Ai and systems - update it on every prompt (If required)
+22
View File
@@ -0,0 +1,22 @@
Wire this page to brb-store.com static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- 3 icon image
- categories > duplicatable
- about us image - one slot
- five-banner-section → five slot
- two-banner-row → two slot
- full-row-image > one slot
- two-branch-image > two slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
+18
View File
@@ -0,0 +1,18 @@
Create a website for spare parts of home appliances store from brb-store.com
Download and use banners,images and fonts from main website, icon and color Pallete from main website
For image you can use images in assets/sample images either
Logo from main website either
Pick data from current website - all data is valid
Lang:fa
Directions: rtl
Use next js
Color palette like website
use flat design
Clone only homepage, about us, contact us
Use image oriented design with a red minimal overlay if needed. Product images are white bg images that will read from backend later
update context md for better use in other Ai and systems - update it on every prompt (If required)
+114
View File
@@ -0,0 +1,114 @@
# BRB Store — AI context
Persian RTL storefront for **brb-store.com** (فروشگاه و تعمیرگاه لوازم خانگی برادران برقعی، قم).
Next.js App Router rebuild of the original WillaMall/BeTheme site. Keep this file current — other AI tools read it first.
Live DNS for `www.brb-store.com` currently serves a different tenant; source of truth for copy, images, and palette is the archived original at `https://www.brb-store.ir/` (Wayback 2022) plus `Ai-data/prompt.txt`.
## Stack
- Next.js 16 (App Router, Turbopack) + React 19 + TypeScript + Tailwind v4
- Locale: `fa`, direction: `rtl`
- Fonts: Vazirmatn (Persian) + Montserrat (English kickers). Original site used **BYekan-Edited** + **IRANSans** + **Montserrat**; those files were not recoverable from Wayback (`WillaMall_SP/assets/Fonts/...` 404).
- Icons: Lucide, tinted with brand red (original used WillaMall `WMi-*` icon font, not archived).
- Dev: `npm run dev`
## Brand
| Token | Value | Source |
| --- | --- | --- |
| Brand red | `#e7432b` | BeTheme `color-red` |
| Brand dark | `#c93621` | hover |
| Ink | `#1a1a1a` | |
| Muted | `#6c6363` | original about text |
| Paper | `#f6f3f1` | |
| Footer copy bar | `#efefef` | original `.footer_copy` |
Logo: oval **BRB** (black top / red bottom, “Borgheei Brothers”) at `public/images/brand/logo.png` and `src/assets/images/logo/logo.png`. Favicon from original `.ico`.
Design language comes from the original BeTheme Carpenter homepage, not a generic banner stack:
- Full-bleed kitchen photography with a **white 92% caption card** (the original slider’s 430px white panel), red 3px bar, Montserrat kicker.
- «قطعات ما» header: red bar + English kicker, title, short catalog note.
- Parts grid: **4 columns**, image cards with a **frosted glass** caption box on the photo (`backdrop-filter`), hover zoom + search icon.
- Red (`#e7432b`) as accent — numbers, icons, underlines — not a wash over every photo.
- Story: فروش / قطعات / تعمیرات, ۳۰+ years, two Qom branches, wholesale.
- Homepage **blog carousel** after the three-banner row: snap-scroll cards, arrows, link to `/blog`. Copy lives in `lib/data.ts` (`blogPosts`) until Meshkee.
- Original **category hover carousel** (`CategoryShowcase`): centered «دسته بندی ها» header, tall photo cards, dark bottom bar that expands on hover with «تماس با ما». Data: `showcaseCategories`.
Product tiles stay on **white backgrounds**. Lifestyle extras: `public/images/lifestyle/`.
## Routes (this pass)
Clone **only** these pages:
| Path | Content |
| --- | --- |
| `/` | Homepage + static image slots |
| `/about` | About us (original `/AboutUs`) |
| `/contact` | Contact us (original `/ContactUs`) |
| `/blog` | Article list (local placeholders until Meshkee blog is wired) |
| `/blog/[slug]` | Article detail |
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
Account / user icon → `https://customer.brb-store.com` (`NEXT_PUBLIC_CUSTOMER_PORTAL_URL`). Do not build a local login page.
## Homepage order
`Hero` → `Services` (3 icons) → `CategoryShowcase` → `AboutPreview` → five-banner (2-col + 3-col) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
Caption bars on photos use dark glossy glass (`lib/glossy.ts` `glossyBar`). Hover the card to expand the bar and show `subtext`.
## Static image slots (wired)
`GET /tenants/brb-store.com/website/static-images?pageKey=home`. Catalog: `GET /meshkee/static-image-slots` from `STATIC_IMAGE_SLOTS` in `lib/static-images.ts`. Client: `overlaySlotImages` / `pickSlot`. Empty/fail → local placeholder. `url` → `<img src>`, `linkUrl` → wrap in a link, `titleFa` / `titleEn` / `subtext` → caption. Use `slot.aspectRatio`.
If a slot’s CSS size or ratio changes, update `aspectRatio` and `recommendedWidth` in `STATIC_IMAGE_SLOTS` in the same change.
| key | kind | itemCount | ratio | Local fallback |
| --- | --- | --- | --- | --- |
| `slider` | list | null | 2:1 | `slides` peek carousel, center `1280px` |
| `three-icon-image` | list | 3 | 1:1 | Lucide icons if slot empty |
| `categories` | list | null | 14:23 | `showcaseCategories` tall cards |
| `about-us-image` | single | 1 | 4:3 | `public/images/about/store.jpg` |
| `five-banner-section` | list | 5 | 2:1 | `twoBannerRow` + `threeBannerRow` |
| `two-banner-row` | list | 2 | 2:1 | `bannersBelowSlider` (after blog) |
| `full-row-image` | single | 1 | 32:9 | `public/images/banners/wide.jpg` |
| `two-branch-image` | list | 2 | 2:1 | `branches` photos |
Do not invent upload APIs.
## Original business data (valid — do not invent replacements)
**About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances (vacuum, meat grinder, fan, juicer, mill, steam iron, kettle, mixer, blender, microwave, …). Wholesale and retail, Iran and abroad. Open to manufacturers/traders.
**Branches**
1. قم، خیابان سی متری کیوانفر، روبروی داروخانه الهادی
2. قم، بلوار صدوقی، خیابان امام حسین (ع)، نبش کوچه ۲۱
**Phones:** 0912-309-3407، 0912-651-7619، 025-36604454، 025-32404070، تلفکس 0253-6638866
**Telegram:** https://t.me/borgheeibrothers
**Email display:** Alireza borghei20@gmail.com (`alirezaborghei20@gmail.com`)
**Map:** 34.6564, 50.8696
**Part categories (homepage):** vacuum, juicer/blender/mill, steam cleaner, meat grinder, iron, microwave/toaster, kettle, fan/evaporative cooler, food processor/chopper/hand blender/mixer, battery, bearing, collector.
Appliance mega-menu (household + industrial) is copied from the original header; links currently go to `/#parts` until the catalog is connected.
## Meshkee
Docs: https://api.meshkee.com/docs/website
OpenAPI: https://api.meshkee.com/docs/website/openapi.json
Base: `https://api.meshkee.com/api/v1`
Tenant domain: `brb-store.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN` / `WEBSITE_DOMAIN`).
Wired: homepage static images (`Ai-data/prompt-images-slot.txt`).
Later: `Ai-data/prompt-connect-backend.txt` — products, blog, portfolio, customer portal.
Public reads only. Do not invent CMS/upload/dashboard APIs. Product images are white-bg and will be read from the backend.
## Local content
All copy and nav live in `lib/data.ts`. Downloaded originals are under `public/images/`. Sample lifestyle photos: `assets/sample images/` (copied to `public/images/lifestyle/`).
+59
View File
@@ -0,0 +1,59 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container } from "@/components/Container";
import { PageHero } from "@/components/layout/PageHero";
import { Placeholder } from "@/components/Placeholder";
import { about, features, site, stats } from "@/lib/data";
export const metadata: Metadata = {
title: "درباره ما",
description: about.paragraphs[0],
};
export default function AboutPage() {
return (
<>
<PageHero kicker="About us" title="درباره ما" image="/images/slides/slide-1.jpg" />
<section className="py-20 sm:py-28">
<Container className="grid items-start gap-14 lg:grid-cols-12">
<div className="lg:col-span-6">
<p className="section-kicker">Burgheii Brothers</p>
<h2 className="mt-4 text-2xl font-extrabold leading-snug sm:text-[32px]">{about.title}</h2>
<div className="mt-8 space-y-5 text-[16px] leading-9 text-muted">
{about.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
<Link href="/contact" className="mt-8 inline-flex rounded-xl bg-brand px-6 py-3 text-[14px] font-medium text-white hover:bg-brand-dark">
شروع همکاری / تماس
</Link>
</div>
<div className="relative lg:col-span-6">
<Placeholder src={about.image} alt={site.name} className="w-full rounded-2xl object-cover" />
<div className="mt-5 grid grid-cols-2 gap-3">
{stats.map((stat) => (
<div key={stat.label} className="rounded-2xl bg-paper px-5 py-5">
<p className="text-2xl font-extrabold text-brand">{stat.value}</p>
<p className="mt-1 text-[12px] leading-6 text-muted">{stat.label}</p>
</div>
))}
</div>
</div>
</Container>
</section>
<section className="border-t border-line py-16">
<Container className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{features.map((feature, index) => (
<div key={feature.title} className="border-r border-line pr-5">
<p className="font-latin text-[11px] tracking-[0.28em] text-brand">0{index + 1}</p>
<h3 className="mt-2 text-[16px] font-bold">{feature.title}</h3>
<p className="mt-2 text-[13px] leading-7 text-muted">{feature.text}</p>
</div>
))}
</Container>
</section>
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Container } from "@/components/Container";
import { PageHero } from "@/components/layout/PageHero";
import { blogPosts, getBlogPost } from "@/lib/data";
export function generateStaticParams() {
return blogPosts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) return { title: "مقاله" };
return { title: post.title, description: post.excerpt };
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getBlogPost(slug);
if (!post) notFound();
return (
<>
<PageHero kicker="Blog" title={post.title} image={post.image} />
<section className="py-16 sm:py-24">
<Container className="max-w-3xl">
<p className="font-latin text-[12px] tracking-[0.18em] text-muted">{post.date}</p>
<div className="mt-8 space-y-5 text-[16px] leading-9 text-muted">
{post.body.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
<Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand">
بازگشت به مقالات
<span className="h-px w-10 bg-brand" />
</Link>
</Container>
</section>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container } from "@/components/Container";
import { PageHero } from "@/components/layout/PageHero";
import { Placeholder } from "@/components/Placeholder";
import { blogPosts } from "@/lib/data";
export const metadata: Metadata = {
title: "مقالات",
description: "نکته‌هایی از تعمیرگاه و بورس قطعات لوازم خانگی برادران برقعی.",
};
export default function BlogPage() {
return (
<>
<PageHero kicker="Blog" title="مقالات" image="/images/slides/slide-2.jpg" />
<section className="py-16 sm:py-24">
<Container>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="group block">
<div className="overflow-hidden rounded-2xl">
<Placeholder
src={post.image}
alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p>
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2>
<p className="mt-2 line-clamp-2 text-[14px] leading-7 text-muted">{post.excerpt}</p>
</Link>
))}
</div>
</Container>
</section>
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Mail, MapPin, Send } from "lucide-react";
import { Container } from "@/components/Container";
import { ContactForm } from "@/components/contact/ContactForm";
import { PageHero } from "@/components/layout/PageHero";
import { Placeholder } from "@/components/Placeholder";
import { branches, phones, site } from "@/lib/data";
export const metadata: Metadata = {
title: "ارتباط با ما",
description: "آدرس شعب، تلفن و فرم تماس فروشگاه و تعمیرگاه برادران برقعی در قم.",
};
export default function ContactPage() {
return (
<>
<PageHero kicker="Contact us" title="ارتباط با ما" image="/images/lifestyle/theme-photos-Klby0nxseY8-unsplash.jpg" />
<section className="py-16 sm:py-20">
<Container>
<p className="max-w-2xl text-[16px] leading-8 text-muted">
مجموعه ی فروشگاه و تعمیرگاه برادران برقعی دارای فعالیت در زمینه های فروش لوازم خانگی، فروش قطعات لوازم خانگی
و تعمیرات آنها می باشد.
</p>
<div className="mt-10 grid gap-5 md:grid-cols-2">
{branches.map((branch) => (
<article key={branch.id} className="overflow-hidden rounded-2xl border border-line">
<div className="relative aspect-[16/7]">
<Placeholder src={branch.image} alt={branch.title} className="h-full w-full object-cover" />
</div>
<div className="flex gap-3 p-5">
<MapPin className="mt-1 size-5 shrink-0 text-brand" />
<div>
<h2 className="text-[16px] font-bold">{branch.title}</h2>
<p className="mt-1 text-[14px] leading-7 text-muted">{branch.address}</p>
</div>
</div>
</article>
))}
</div>
</Container>
</section>
<section className="bg-paper py-16 sm:py-20">
<Container className="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-5">
<h2 className="text-[22px] font-bold">تلفن و تلگرام</h2>
<ul className="mt-6 divide-y divide-line overflow-hidden rounded-2xl border border-line bg-white">
{phones.map((phone) => (
<li key={phone.value} className="flex items-center justify-between gap-3 px-4 py-3">
<span className="text-[12px] text-muted">{phone.label}</span>
<a href={phone.href} className="font-latin text-[16px] font-medium hover:text-brand" dir="ltr">
{phone.value}
</a>
</li>
))}
</ul>
<div className="mt-6 space-y-3 text-[14px]">
<a href={`mailto:${site.email}`} className="flex items-center gap-2 hover:text-brand">
<Mail className="size-4 text-brand" />
{site.emailLabel}
</a>
<a
href={site.telegram}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 hover:text-brand"
>
<Send className="size-4 text-brand" />
کانال تلگرام borgheeibrothers
</a>
</div>
</div>
<div className="rounded-2xl bg-white p-6 sm:p-8 lg:col-span-7">
<h2 className="mb-2 text-[22px] font-bold">پیام ارسال کنید</h2>
<p className="mb-6 text-[13px] text-muted">نام، شماره و پیام — پاسخ از شعبه قم.</p>
<ContactForm />
<div className="mt-8 overflow-hidden rounded-2xl">
<iframe
title="موقعیت فروشگاه برادران برقعی"
src={site.map.embed}
className="h-[280px] w-full"
loading="lazy"
/>
</div>
</div>
</Container>
</section>
</>
);
}
+79
View File
@@ -0,0 +1,79 @@
@import "tailwindcss";
:root {
--brand: #e7432b;
--brand-dark: #c93621;
--ink: #161616;
--muted: #6c6363;
--line: #e4ddd8;
--paper: #f4f0ec;
--white: #ffffff;
}
@theme inline {
--color-brand: var(--brand);
--color-brand-dark: var(--brand-dark);
--color-ink: var(--ink);
--color-muted: var(--muted);
--color-line: var(--line);
--color-paper: var(--paper);
--font-sans: var(--font-vazir), Tahoma, sans-serif;
--font-latin: var(--font-montserrat), var(--font-vazir), sans-serif;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--white);
color: var(--ink);
font-family: var(--font-sans);
}
::selection {
background: var(--brand);
color: #fff;
}
.font-latin {
font-family: var(--font-latin);
}
.section-kicker {
font-family: var(--font-latin);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--brand);
}
.glass-box,
.caption-card {
background: rgb(255 255 255 / 0.18);
border: 1px solid rgb(255 255 255 / 0.72);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.9),
0 12px 32px rgb(0 0 0 / 0.1);
border-radius: 1rem;
}
.image-mask {
background: rgb(22 22 22 / 0.55);
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
input,
textarea,
button {
font-family: inherit;
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Montserrat, Vazirmatn } from "next/font/google";
import "./globals.css";
import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { TopBar } from "@/components/layout/TopBar";
import { site } from "@/lib/data";
const vazir = Vazirmatn({
subsets: ["arabic", "latin"],
variable: "--font-vazir",
display: "swap",
weight: ["300", "400", "500", "600", "700", "800"],
});
const montserrat = Montserrat({
subsets: ["latin"],
variable: "--font-montserrat",
display: "swap",
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: {
default: `${site.name} | ${site.tagline}`,
template: `%s | ${site.name}`,
},
description: site.description,
keywords: site.keywords,
icons: { icon: "/favicon.ico" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fa" dir="rtl" className={`${vazir.variable} ${montserrat.variable}`}>
<body className={`${vazir.className} min-h-screen antialiased`}>
<TopBar />
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+136
View File
@@ -0,0 +1,136 @@
import { AboutPreview } from "@/components/home/AboutPreview";
import { BannerRow } from "@/components/home/BannerRow";
import { BlogCarousel } from "@/components/home/BlogCarousel";
import { Branches } from "@/components/home/Branches";
import { CategoryShowcase } from "@/components/home/CategoryShowcase";
import { Hero } from "@/components/home/Hero";
import { Services } from "@/components/home/Services";
import { Stats } from "@/components/home/Stats";
import { WideBanner } from "@/components/home/WideBanner";
import {
about,
bannersBelowSlider,
branches,
services,
showcaseCategories,
slides,
threeBannerRow,
twoBannerRow,
} from "@/lib/data";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const revalidate = 60;
export default async function HomePage() {
const slots = await fetchStaticImageSlots("home");
const slider = overlaySlotImages(
pickSlot(slots, "slider"),
slides.map((slide) => ({
id: slide.id,
image: slide.image,
href: slide.href,
title: slide.title,
kicker: slide.kicker,
})),
"slider",
);
const icons = overlaySlotImages(
pickSlot(slots, "three-icon-image"),
services.map((service) => ({
id: service.id,
image: "",
href: service.href,
title: service.title,
text: service.text,
})),
"three-icon-image",
);
const categories = overlaySlotImages(
pickSlot(slots, "categories"),
showcaseCategories.map((item) => ({
id: item.id,
image: item.image,
href: item.href,
title: item.title,
text: item.text,
})),
"categories",
);
const aboutImage = overlaySlotImages(
pickSlot(slots, "about-us-image"),
[{ id: "about", image: about.image }],
"about-us-image",
);
const fiveBanners = overlaySlotImages(
pickSlot(slots, "five-banner-section"),
[...twoBannerRow, ...threeBannerRow].map((banner) => ({
id: banner.id,
image: banner.image,
href: banner.href,
title: banner.title,
kicker: banner.kicker,
text: banner.text,
})),
"five-banner-section",
);
const twoBanners = overlaySlotImages(
pickSlot(slots, "two-banner-row"),
bannersBelowSlider.map((banner) => ({
id: banner.id,
image: banner.image,
href: banner.href,
title: banner.title,
kicker: banner.kicker,
text: banner.text,
})),
"two-banner-row",
);
const fullRow = overlaySlotImages(
pickSlot(slots, "full-row-image"),
[
{
id: "wide",
image: "/images/banners/wide.jpg",
href: "/contact",
title: "اگر به دنبال قطعه ی خاصی هستید، با ما تماس بگیرید.",
kicker: "Contact us",
},
],
"full-row-image",
);
const branchImages = overlaySlotImages(
pickSlot(slots, "two-branch-image"),
branches.map((branch) => ({
id: branch.id,
image: branch.image,
href: "/contact",
title: branch.title,
text: branch.address,
})),
"two-branch-image",
);
return (
<>
<Hero slides={slider.items} aspectRatio={slider.aspectRatio} />
<Services items={icons.items} />
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
<BannerRow banners={fiveBanners.items.slice(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} />
<BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} />
<BlogCarousel />
<BannerRow banners={twoBanners.items} columns={2} aspectRatio={twoBanners.aspectRatio} />
<Stats />
<WideBanner image={fullRow.items[0]} aspectRatio={fullRow.aspectRatio} />
<Branches images={branchImages.items} aspectRatio={branchImages.aspectRatio} />
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+9
View File
@@ -0,0 +1,9 @@
export function Container({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={`mx-auto w-full max-w-[1280px] px-4 sm:px-6 ${className}`}>{children}</div>;
}
+30
View File
@@ -0,0 +1,30 @@
export function GlassBox({
children,
className = "",
frostSrc,
}: {
children: React.ReactNode;
className?: string;
frostSrc: string;
}) {
return (
<div
className={`glass-box overflow-hidden ${className}`}
style={{
WebkitBackdropFilter: "blur(18px) saturate(160%)",
backdropFilter: "blur(18px) saturate(160%)",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 scale-125 bg-cover bg-center"
style={{
backgroundImage: `url("${frostSrc}")`,
filter: "blur(18px)",
}}
/>
<span className="pointer-events-none absolute inset-0 bg-white/30" />
<div className="relative z-10">{children}</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useRef, useState } from "react";
type PlaceholderProps = {
src: string;
fallbackSrc?: string;
alt?: string;
className?: string;
};
export function Placeholder({ src, fallbackSrc, alt = "", className }: PlaceholderProps) {
const [current, setCurrent] = useState(src);
const failed = useRef(false);
useEffect(() => {
failed.current = false;
setCurrent(src);
}, [src]);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={current}
alt={alt}
className={className}
onError={() => {
if (failed.current || !fallbackSrc || fallbackSrc === current) return;
failed.current = true;
setCurrent(fallbackSrc);
}}
/>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import Link from "next/link";
export function SlotLink({
href,
className,
children,
}: {
href?: string;
className?: string;
children: ReactNode;
}) {
if (!href) return <div className={className}>{children}</div>;
if (href.startsWith("/") || href.startsWith("#")) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<a href={href} className={className}>
{children}
</a>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { FormEvent, useState } from "react";
import { site } from "@/lib/data";
export function ContactForm() {
const [sent, setSent] = useState(false);
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const name = String(data.get("name") || "");
const phone = String(data.get("phone") || "");
const message = String(data.get("message") || "");
const body = encodeURIComponent(`نام: ${name}\nتلفن: ${phone}\n\n${message}`);
window.location.href = `mailto:${site.email}?subject=${encodeURIComponent("پیام از وبسایت BRB")}&body=${body}`;
setSent(true);
};
if (sent) {
return (
<p className="rounded-2xl bg-paper p-6 text-[14px] leading-7 text-muted">
پیام شما آماده ارسال از طریق ایمیل شد. اگر برنامه ایمیل باز نشد، با شماره‌های فروشگاه تماس بگیرید.
</p>
);
}
return (
<form onSubmit={onSubmit} className="space-y-3">
<input
name="name"
required
placeholder="نام و نام خانوادگی"
className="w-full rounded-xl border border-line bg-paper px-4 py-3.5 text-[14px] outline-none focus:border-brand focus:bg-white"
/>
<input
name="phone"
required
dir="ltr"
placeholder="شماره همراه"
className="w-full rounded-xl border border-line bg-paper px-4 py-3.5 text-[14px] outline-none focus:border-brand focus:bg-white"
/>
<textarea
name="message"
required
rows={6}
placeholder="پیام شما"
className="w-full resize-y rounded-xl border border-line bg-paper px-4 py-3.5 text-[14px] outline-none focus:border-brand focus:bg-white"
/>
<button type="submit" className="rounded-xl bg-brand px-8 py-3.5 text-[14px] font-medium text-white hover:bg-brand-dark">
ارسال پیام
</button>
</form>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { about } from "@/lib/data";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
export function AboutPreview({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
const src = image?.image ?? about.image;
const ratio = parseAspectRatio(aspectRatio);
return (
<section className="py-20 sm:py-28">
<Container className="grid items-center gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-5">
<p className="section-kicker">About us</p>
<h2 className="mt-4 text-3xl font-extrabold leading-snug sm:text-[34px]">{about.title}</h2>
<p className="mt-6 text-[17px] leading-9 text-muted">{about.paragraphs[0]}</p>
<p className="mt-4 text-[17px] leading-9 text-muted">{about.paragraphs[1]}</p>
<Link
href="/about"
className="mt-8 inline-flex items-center gap-3 text-[14px] font-bold text-brand"
>
ادامه داستان مجموعه
<span className="h-px w-10 bg-brand" />
</Link>
</div>
<div className="relative lg:col-span-7">
<SlotLink
href={image?.href}
className="relative z-[1] block overflow-hidden rounded-2xl"
>
<span className="relative block w-full" style={ratio ? { aspectRatio: ratio } : undefined}>
<Placeholder
src={src}
fallbackSrc={image?.fallbackSrc ?? about.image}
alt={about.title}
className={`w-full object-cover ${ratio ? "absolute inset-0 h-full" : "relative h-auto"}`}
/>
</span>
</SlotLink>
<div className="absolute -bottom-6 left-0 z-[2] rounded-2xl bg-brand px-8 py-6 text-white sm:-bottom-8 sm:left-8">
<p className="text-5xl font-extrabold leading-none sm:text-6xl">۳۰+</p>
<p className="mt-1 text-[13px] text-white/85">سال خدمت در قم</p>
</div>
</div>
</Container>
</section>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { glossyBar } from "@/lib/glossy";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
export function BannerRow({
banners,
columns,
aspectRatio,
}: {
banners: SlotMediaItem[];
columns: 2 | 3;
aspectRatio?: string;
}) {
const ratio = parseAspectRatio(aspectRatio);
return (
<section className="pb-4">
<Container>
<div className={`grid gap-5 ${columns === 3 ? "md:grid-cols-3" : "md:grid-cols-2"}`}>
{banners.map((banner) => (
<SlotLink key={banner.id} href={banner.href} className="group relative block">
<div
className={`relative overflow-hidden rounded-2xl ${
ratio ? "min-h-[180px]" : columns === 3 ? "aspect-[4/3]" : "aspect-[16/8] min-h-[220px]"
}`}
style={ratio ? { aspectRatio: ratio } : undefined}
>
<Placeholder
src={banner.image}
fallbackSrc={banner.fallbackSrc}
alt={banner.title ?? ""}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
{(banner.title || banner.kicker || banner.text) && (
<div
className="absolute inset-x-3 bottom-3 z-10 overflow-hidden rounded-2xl px-4 py-3 sm:px-5"
style={glossyBar}
>
<div dir="rtl" className="flex min-h-[2.5rem] items-center gap-3">
{banner.title ? (
<h3 className="min-w-0 truncate text-[14px] font-bold text-white sm:text-[16px]">
{banner.title}
</h3>
) : null}
{banner.kicker ? (
<>
<span className="h-5 w-px shrink-0 bg-white/20" />
<p className="font-latin shrink-0 text-[10px] tracking-[0.22em] text-white/60 uppercase">
{banner.kicker}
</p>
</>
) : null}
</div>
{banner.text ? (
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-500 ease-out group-hover:grid-rows-[1fr]">
<div className="min-h-0 overflow-hidden">
<p className="pt-1 pb-1 text-right text-[13px] leading-7 text-white/75">{banner.text}</p>
</div>
</div>
) : null}
</div>
)}
</div>
</SlotLink>
))}
</div>
</Container>
</section>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useRef } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { blogPosts } from "@/lib/data";
export function BlogCarousel() {
const scroller = useRef<HTMLDivElement>(null);
const scrollByCard = (direction: number) => {
const node = scroller.current;
if (!node) return;
const card = node.querySelector("article");
const amount = card ? card.getBoundingClientRect().width + 20 : 340;
node.scrollBy({ left: direction * amount, behavior: "smooth" });
};
return (
<section id="blog" className="py-20 sm:py-24">
<Container>
<div className="mb-10 flex items-end justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<span className="h-[3px] w-8 bg-brand" />
<p className="section-kicker">Blog</p>
</div>
<h2 className="mt-3 text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
نکته‌هایی از تعمیرگاه و بورس قطعات — برای مصرف‌کننده و همکار.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => scrollByCard(1)}
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
aria-label="قبلی"
>
<ChevronRight className="size-4" />
</button>
<button
type="button"
onClick={() => scrollByCard(-1)}
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
aria-label="بعدی"
>
<ChevronLeft className="size-4" />
</button>
<Link href="/blog" className="hidden rounded-xl bg-ink px-5 py-2.5 text-[13px] text-white hover:bg-brand sm:inline-flex">
مشاهده همه
</Link>
</div>
</div>
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto">
{blogPosts.map((post) => (
<article key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start">
<Link href={`/blog/${post.slug}`} className="group block">
<div className="overflow-hidden rounded-2xl">
<Placeholder
src={post.image}
alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p>
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3>
<p className="mt-2 line-clamp-2 text-[13px] leading-7 text-muted">{post.excerpt}</p>
</Link>
</article>
))}
</div>
</Container>
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
import Link from "next/link";
import { MapPin } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { branches } from "@/lib/data";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
export function Branches({ images, aspectRatio }: { images?: SlotMediaItem[]; aspectRatio?: string }) {
const ratio = parseAspectRatio(aspectRatio);
return (
<section className="bg-paper py-20">
<Container>
<div className="mb-10 flex items-end justify-between gap-4">
<div>
<p className="section-kicker">Locations</p>
<h2 className="mt-3 text-3xl font-extrabold">دو شعبه در قم</h2>
</div>
<Link href="/contact" className="hidden text-[13px] font-bold text-brand sm:inline-flex">
مسیر و تلفن‌ها
</Link>
</div>
<div className="grid gap-5 md:grid-cols-2">
{branches.map((branch, index) => {
const media = images?.[index];
const src = media?.image || branch.image;
const href = media?.href || "/contact";
return (
<SlotLink key={branch.id} href={href} className="group overflow-hidden rounded-2xl bg-white">
<div
className={`relative overflow-hidden ${ratio ? "" : "aspect-[16/8]"}`}
style={ratio ? { aspectRatio: ratio } : undefined}
>
<Placeholder
src={src}
fallbackSrc={media?.fallbackSrc ?? branch.image}
alt={media?.title || branch.title}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<div className="flex gap-3 p-6">
<MapPin className="mt-1 size-5 shrink-0 text-brand" />
<div>
<h3 className="text-[18px] font-bold">{media?.title || branch.title}</h3>
<p className="mt-2 text-[14px] leading-7 text-muted">{media?.text || branch.address}</p>
</div>
</div>
</SlotLink>
);
})}
</div>
</Container>
</section>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useRef } from "react";
import { ArrowUpLeft, ChevronLeft, ChevronRight } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { glossyBar } from "@/lib/glossy";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
export function CategoryShowcase({ items, aspectRatio }: { items: SlotMediaItem[]; aspectRatio?: string }) {
const scroller = useRef<HTMLDivElement>(null);
const ratio = parseAspectRatio(aspectRatio);
const scrollByCard = (direction: number) => {
const node = scroller.current;
if (!node) return;
const card = node.querySelector("article");
const amount = card ? card.getBoundingClientRect().width + 20 : 300;
node.scrollBy({ left: direction * amount, behavior: "smooth" });
};
return (
<section id="parts" className="scroll-mt-28 bg-paper py-16 sm:py-20">
<Container>
<div className="mb-10 text-center">
<div className="flex items-center justify-center gap-3">
<span className="h-px w-10 bg-brand" />
<p className="text-[13px] font-medium text-brand">دسته بندی ها</p>
<span className="h-px w-10 bg-brand" />
</div>
<h2 className="mt-3 text-[22px] font-extrabold leading-snug sm:text-[28px]">
دسته بندی فروشگاه لوازم خانگی برادران برقعی
</h2>
</div>
</Container>
<div className="relative">
<button
type="button"
onClick={() => scrollByCard(1)}
className="absolute top-1/2 right-3 z-10 hidden size-10 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm sm:grid lg:right-6"
aria-label="قبلی"
>
<ChevronRight className="size-4" />
</button>
<button
type="button"
onClick={() => scrollByCard(-1)}
className="absolute top-1/2 left-3 z-10 hidden size-10 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm sm:grid lg:left-6"
aria-label="بعدی"
>
<ChevronLeft className="size-4" />
</button>
<div
ref={scroller}
className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto px-4 sm:px-6 lg:px-[max(1.5rem,calc((100vw-1280px)/2+1.5rem))]"
>
{items.map((item) => (
<article
key={item.id}
className="group relative w-[min(78vw,280px)] shrink-0 snap-start overflow-hidden rounded-2xl"
style={ratio ? { aspectRatio: ratio } : { height: 460 }}
>
<SlotLink href={item.href} className="block h-full">
<Placeholder
src={item.image}
fallbackSrc={item.fallbackSrc}
alt={item.title ?? ""}
className="h-full w-full object-cover"
/>
<div
className="absolute right-3 bottom-3 left-3 overflow-hidden rounded-2xl px-4 py-3.5"
style={glossyBar}
>
<div className="flex h-8 items-center justify-between gap-3" dir="ltr">
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-white/45 text-white">
<ArrowUpLeft className="size-3.5" />
</span>
<h3 className="truncate text-[14px] font-bold text-white" dir="rtl">
{item.title}
</h3>
</div>
{item.text ? (
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-500 ease-out group-hover:grid-rows-[1fr]">
<div className="min-h-0 overflow-hidden">
<p className="pt-3 text-right text-[13px] leading-7 text-white/75">{item.text}</p>
</div>
</div>
) : null}
</div>
</SlotLink>
</article>
))}
</div>
</div>
</section>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
function circularOffset(index: number, active: number, length: number) {
let diff = index - active;
if (diff > length / 2) diff -= length;
if (diff < -length / 2) diff += length;
return diff;
}
const slideWidth = "min(1280px, calc(100vw - 5rem))";
export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
const [active, setActive] = useState(0);
const [paused, setPaused] = useState(false);
const ratio = parseAspectRatio(aspectRatio);
useEffect(() => {
setActive(0);
}, [slides.length]);
useEffect(() => {
if (slides.length < 2 || paused) return;
const timer = setInterval(() => {
setActive((current) => (current + 1) % slides.length);
}, 6000);
return () => clearInterval(timer);
}, [paused, slides.length]);
const go = (direction: number) => {
if (slides.length < 2) return;
setActive((current) => (current + direction + slides.length) % slides.length);
};
if (slides.length === 0) return null;
return (
<section className="relative overflow-x-clip bg-white pt-5 pb-8">
<div
className={ratio ? "relative min-h-[340px]" : "relative h-[min(62vh,640px)] min-h-[340px]"}
style={ratio ? { aspectRatio: ratio, maxHeight: 640 } : undefined}
dir="ltr"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{slides.map((item, i) => {
const offset = circularOffset(i, active, slides.length);
const isCenter = offset === 0;
const isPeek = Math.abs(offset) === 1;
return (
<article
key={item.id}
className="absolute top-0 h-full w-[min(1280px,calc(100vw-5rem))]"
style={{
left: `calc(50% - (${slideWidth}) / 2 + ${offset} * ((${slideWidth}) + 1rem))`,
zIndex: isCenter ? 3 : isPeek ? 2 : 1,
opacity: isCenter || isPeek ? 1 : 0,
pointerEvents: isCenter || isPeek ? "auto" : "none",
transition: "left 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease",
}}
>
{isCenter ? (
<SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl">
<Placeholder
src={item.image}
fallbackSrc={item.fallbackSrc}
alt={item.title ?? ""}
className="h-full w-full object-cover"
/>
<div
className="absolute inset-x-4 bottom-4 z-10 rounded-2xl px-5 py-3.5 sm:inset-x-5 sm:bottom-5 sm:px-7 sm:py-4"
style={{
background:
"linear-gradient(180deg, rgb(48 52 56 / 0.72) 0%, rgb(18 20 22 / 0.82) 100%)",
backdropFilter: "blur(22px) saturate(180%)",
WebkitBackdropFilter: "blur(22px) saturate(180%)",
border: "1px solid rgb(255 255 255 / 0.22)",
boxShadow:
"inset 0 1px 0 rgb(255 255 255 / 0.28), 0 10px 28px rgb(0 0 0 / 0.28)",
}}
>
<div dir="rtl" className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-5">
<h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]">
{item.title}
</h1>
<span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" />
<span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" />
{item.kicker ? (
<p className="font-latin hidden shrink-0 text-[11px] tracking-[0.2em] text-white/60 uppercase sm:block">
{item.kicker}
</p>
) : null}
</div>
<span className="inline-flex shrink-0 rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white sm:px-6 sm:py-3 sm:text-[14px]">
مشاهده قطعات
</span>
</div>
</div>
</SlotLink>
) : (
<button
type="button"
className="relative block h-full w-full overflow-hidden rounded-2xl"
onClick={() => setActive(i)}
aria-label={item.title}
>
<Placeholder src={item.image} fallbackSrc={item.fallbackSrc} alt="" className="h-full w-full object-cover" />
<span className="absolute inset-0 bg-white/35" />
</button>
)}
</article>
);
})}
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-[min(1280px,calc(100vw-5rem))] -translate-x-1/2">
<button
type="button"
onClick={() => go(1)}
className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm"
aria-label="اسلاید راست"
>
<ChevronRight className="size-5" />
</button>
<button
type="button"
onClick={() => go(-1)}
className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm"
aria-label="اسلاید چپ"
>
<ChevronLeft className="size-5" />
</button>
</div>
</div>
<div className="relative z-10 mt-5 flex justify-center gap-2">
{slides.map((item, i) => (
<button
key={item.id}
type="button"
onClick={() => setActive(i)}
className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
aria-label={`اسلاید ${i + 1}`}
/>
))}
</div>
</section>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Container } from "@/components/Container";
import { GlassBox } from "@/components/GlassBox";
import { Placeholder } from "@/components/Placeholder";
import { customerPortalUrl } from "@/lib/config";
import { sampleProducts } from "@/lib/data";
export function ProductStrip() {
return (
<section className="py-20 sm:py-24">
<Container>
<div className="mb-10 flex items-end justify-between gap-4">
<div>
<p className="section-kicker">Catalog</p>
<h2 className="mt-3 text-3xl font-extrabold">نمونه‌ قطعات</h2>
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
تصاویر فروشگاه روی پس‌زمینه سفید از بک‌اند خوانده می‌شوند. تا اتصال، همین گروه‌ها به‌عنوان نمونه آمده‌اند.
</p>
</div>
<a href={customerPortalUrl} className="hidden rounded-xl bg-ink px-5 py-3 text-[13px] text-white hover:bg-brand sm:inline-flex">
ورود به فروشگاه
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 lg:gap-5">
{sampleProducts.map((product) => (
<a key={product.id} href={customerPortalUrl} className="group relative block">
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl">
<Placeholder
src={product.image}
alt={product.title}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<GlassBox
frostSrc={product.image}
className="absolute inset-x-0 bottom-0 z-10 !rounded-none !rounded-b-2xl px-3 py-2.5 text-right"
>
<h3 className="truncate text-[12px] font-bold leading-5 sm:text-[13px]">{product.title}</h3>
<p className="mt-0.5 font-latin text-[9px] tracking-[0.2em] text-muted uppercase">Coming soon</p>
</GlassBox>
</div>
</a>
))}
</div>
</Container>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Wrench, Package, Store } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import type { SlotMediaItem } from "@/lib/static-images";
const icons = [Store, Package, Wrench];
export function Services({ items }: { items: SlotMediaItem[] }) {
return (
<section className="relative z-10 -mt-px bg-white">
<Container>
<div className="grid border-x border-line lg:grid-cols-3">
{items.map((service, index) => {
const Icon = icons[index] ?? Store;
return (
<SlotLink
key={service.id}
href={service.href}
className={`group flex items-start gap-5 px-6 py-8 sm:px-8 ${index < items.length - 1 ? "border-b border-line lg:border-b-0 lg:border-l" : ""}`}
>
{service.image ? (
<Placeholder
src={service.image}
fallbackSrc={service.fallbackSrc}
alt=""
className="mt-1 size-16 shrink-0 object-contain sm:size-[4.5rem]"
/>
) : (
<Icon className="mt-1 size-16 shrink-0 text-brand sm:size-[4.5rem]" strokeWidth={1.25} aria-hidden />
)}
<span>
<span className="font-latin block text-[10px] tracking-[0.22em] text-muted uppercase">
0{index + 1}
</span>
<h2 className="text-[18px] leading-7 font-bold group-hover:text-brand">{service.title}</h2>
{service.text ? <p className="text-[13px] leading-6 text-muted">{service.text}</p> : null}
</span>
</SlotLink>
);
})}
</div>
</Container>
</section>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Container } from "@/components/Container";
import { stats } from "@/lib/data";
export function Stats() {
return (
<section className="bg-paper">
<Container className="grid grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<div
key={stat.label}
className={`px-4 py-8 sm:px-6 ${index % 2 === 1 ? "border-r border-line" : ""} lg:border-r lg:last:border-0`}
>
<p className="text-3xl font-extrabold text-brand sm:text-4xl">{stat.value}</p>
<p className="mt-2 text-[13px] leading-6 text-muted">{stat.label}</p>
</div>
))}
</Container>
</section>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { glossyBar } from "@/lib/glossy";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
const fallbackCopy = {
kicker: "Contact us",
title: "اگر به دنبال قطعه ی خاصی هستید، با ما تماس بگیرید.",
href: "/contact",
};
export function WideBanner({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
const src = image?.image ?? "/images/banners/wide.jpg";
const href = image?.href || fallbackCopy.href;
const ratio = parseAspectRatio(aspectRatio);
return (
<section
className={`relative overflow-hidden ${ratio ? "min-h-[280px]" : "h-[280px] sm:h-[360px]"}`}
style={ratio ? { aspectRatio: ratio } : undefined}
>
<SlotLink href={href} className="absolute inset-0">
<Placeholder
src={src}
fallbackSrc={image?.fallbackSrc ?? "/images/banners/wide.jpg"}
alt=""
className="h-full w-full object-cover"
/>
<span className="absolute inset-0 bg-ink/20" />
</SlotLink>
<Container className="relative flex h-full items-center">
<div className="max-w-xl rounded-2xl px-8 py-7" style={glossyBar}>
<p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">
{image?.kicker || fallbackCopy.kicker}
</p>
<p className="mt-3 text-[22px] font-bold leading-9 text-white sm:text-[26px]">
{image?.title || fallbackCopy.title}
</p>
{image?.text ? <p className="mt-2 text-[14px] leading-7 text-white/75">{image.text}</p> : null}
<SlotLink
href={href}
className="mt-5 inline-flex rounded-xl bg-brand px-6 py-3 text-[14px] font-medium text-white hover:bg-brand-dark"
>
تماس با ما
</SlotLink>
</div>
</Container>
</section>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { Send } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { branches, navLinks, phones, site } from "@/lib/data";
export function Footer() {
return (
<footer className="bg-ink text-white">
<Container className="grid gap-12 py-16 sm:grid-cols-2 lg:grid-cols-12">
<div className="lg:col-span-4">
<div className="inline-block rounded-xl bg-white px-3 py-2">
<Placeholder src={site.logo} alt={site.name} className="h-14 w-auto object-contain" />
</div>
<p className="mt-5 max-w-sm text-[14px] leading-8 text-white/65">{site.description}</p>
<a
href={site.telegram}
target="_blank"
rel="noreferrer"
className="mt-5 inline-flex items-center gap-2 text-[13px] hover:text-brand"
>
<Send className="size-4" />
ما را دنبال کنید
</a>
</div>
<div className="lg:col-span-2">
<h3 className="mb-4 text-[13px] font-bold text-white/50">صفحات</h3>
<ul className="space-y-2.5 text-[14px] text-white/75">
{navLinks.map((item) => (
<li key={item.href}>
<Link href={item.href} className="hover:text-white">
{item.label}
</Link>
</li>
))}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="mb-4 text-[13px] font-bold text-white/50">مکان ما</h3>
<ul className="space-y-4 text-[14px] leading-7 text-white/75">
{branches.map((branch) => (
<li key={branch.id}>
<strong className="text-white">{branch.title}</strong>
<p className="mt-1">{branch.address}</p>
</li>
))}
</ul>
</div>
<div className="lg:col-span-3">
<h3 className="mb-4 text-[13px] font-bold text-white/50">تماس با ما</h3>
<ul className="space-y-2 font-latin text-[20px] tracking-wide">
{phones.slice(0, 4).map((phone) => (
<li key={phone.value}>
<a href={phone.href} className="hover:text-brand" dir="ltr">
{phone.value}
</a>
</li>
))}
</ul>
</div>
</Container>
<div className="border-t border-white/10 bg-black/30">
<Container className="flex flex-col gap-2 py-4 text-[12px] text-white/45 sm:flex-row sm:items-center sm:justify-between">
<p>© {new Date().getFullYear()} {site.name}. تمامی حقوق محفوظ است.</p>
<p className="font-latin tracking-[0.18em] uppercase">{site.nameEn}</p>
</Container>
</div>
</footer>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronDown, Menu, UserRound, X } from "lucide-react";
import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { HeaderSearch } from "@/components/layout/HeaderSearch";
import { customerPortalUrl } from "@/lib/config";
import { applianceGroups, categories, navLinks, site } from "@/lib/data";
export function Header() {
const [open, setOpen] = useState(false);
const [partsOpen, setPartsOpen] = useState(false);
const [appliancesOpen, setAppliancesOpen] = useState(false);
const [mobileParts, setMobileParts] = useState(false);
return (
<header className="sticky top-0 z-40 border-b border-line bg-white/95 backdrop-blur">
<Container className="flex h-[84px] items-center gap-4">
<button
type="button"
className="grid size-10 place-items-center lg:hidden"
onClick={() => setOpen(true)}
aria-label="منو"
>
<Menu className="size-5" />
</button>
<Link href="/" className="flex h-14 shrink-0 items-center">
<Placeholder src={site.logo} alt={site.name} className="h-14 w-auto object-contain" />
</Link>
<nav className="mx-2 hidden items-center lg:flex">
<Link href="/" className="px-3 py-2 text-[14px] hover:text-brand">
خانه
</Link>
<div
className="relative"
onMouseEnter={() => setAppliancesOpen(true)}
onMouseLeave={() => setAppliancesOpen(false)}
>
<button type="button" className="flex items-center gap-1 px-3 py-2 text-[14px] hover:text-brand">
لوازم برقی
<ChevronDown className="size-3.5" />
</button>
{appliancesOpen ? (
<div className="absolute right-0 top-full w-[560px] border border-line bg-white p-6 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
<div className="grid grid-cols-2 gap-8">
{applianceGroups.map((group) => (
<div key={group.title}>
<p className="mb-3 border-r-2 border-brand pr-2 text-[13px] font-bold">{group.title}</p>
<ul className="space-y-1.5 text-[13px] text-muted">
{group.items.map((item) => (
<li key={item}>
<Link href="/#parts" className="hover:text-brand">
{item}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
) : null}
</div>
<div
className="relative"
onMouseEnter={() => setPartsOpen(true)}
onMouseLeave={() => setPartsOpen(false)}
>
<Link href="/#parts" className="flex items-center gap-1 px-3 py-2 text-[14px] hover:text-brand">
قطعات
<ChevronDown className="size-3.5" />
</Link>
{partsOpen ? (
<div className="absolute right-0 top-full w-[720px] border border-line bg-white p-5 shadow-[0_20px_50px_rgba(0,0,0,0.08)]">
<div className="grid grid-cols-4 gap-3">
{categories.map((category) => (
<Link key={category.id} href="/#parts" className="group flex items-center gap-2 p-1.5 hover:bg-paper">
<Placeholder
src={category.image}
alt=""
className="size-11 shrink-0 object-cover"
/>
<span className="min-w-0">
<span className="block truncate text-[12px] font-medium leading-5 group-hover:text-brand">
{category.title.replace("قطعات ", "")}
</span>
<span className="font-latin block text-[9px] tracking-[0.16em] text-muted uppercase">
{category.english}
</span>
</span>
</Link>
))}
</div>
</div>
) : null}
</div>
{navLinks
.filter((link) => link.href !== "/" && link.href !== "/#parts")
.map((link) => (
<Link
key={link.href}
href={link.href}
className="px-3 py-2 text-[14px] hover:text-brand"
>
{link.label}
</Link>
))}
</nav>
<HeaderSearch className="ms-auto hidden max-w-xs flex-1 md:block" />
<a
href={customerPortalUrl}
className="flex items-center gap-2 px-2 py-2 text-[13px] hover:text-brand"
aria-label="ورود به حساب کاربری"
>
<UserRound className="size-5" />
<span className="hidden xl:inline">ورود / ثبت‌نام</span>
</a>
</Container>
{open ? (
<div className="fixed inset-0 z-50 bg-ink/50 lg:hidden" onClick={() => setOpen(false)}>
<div
className="absolute inset-y-0 right-0 flex w-[min(100%,340px)] flex-col bg-white"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-[84px] items-center justify-between border-b border-line px-4">
<Placeholder src={site.logo} alt={site.name} className="h-12 w-auto object-contain" />
<button type="button" onClick={() => setOpen(false)} aria-label="بستن">
<X className="size-5" />
</button>
</div>
<div className="flex flex-1 flex-col gap-1 overflow-y-auto p-4 text-[15px]">
<HeaderSearch className="mb-3" />
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className="py-2" onClick={() => setOpen(false)}>
{link.label}
</Link>
))}
<button
type="button"
className="flex items-center justify-between py-2"
onClick={() => setMobileParts((value) => !value)}
>
دسته‌بندی قطعات
<ChevronDown className={`size-4 transition ${mobileParts ? "rotate-180" : ""}`} />
</button>
{mobileParts
? categories.map((category) => (
<Link
key={category.id}
href="/#parts"
className="py-1.5 pe-2 text-[13px] text-muted"
onClick={() => setOpen(false)}
>
{category.title}
</Link>
))
: null}
<a href={customerPortalUrl} className="mt-4 rounded-xl bg-brand px-4 py-3 text-center text-white">
ورود / ثبت‌نام
</a>
</div>
</div>
</div>
) : null}
</header>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { FormEvent } from "react";
import { Search } from "lucide-react";
import { useRouter } from "next/navigation";
export function HeaderSearch({ className = "" }: { className?: string }) {
const router = useRouter();
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
router.push("/#parts");
};
return (
<form onSubmit={onSubmit} className={`relative ${className}`}>
<input
name="q"
placeholder="جستجوی قطعه..."
className="h-11 w-full rounded-xl bg-paper pe-4 ps-10 text-[13px] outline-none placeholder:text-muted/70 focus:bg-white focus:ring-1 focus:ring-brand"
/>
<Search className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted" />
</form>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { GlassBox } from "@/components/GlassBox";
import { Placeholder } from "@/components/Placeholder";
import { Container } from "@/components/Container";
export function PageHero({
kicker,
title,
image,
}: {
kicker: string;
title: string;
image: string;
}) {
return (
<section className="relative h-[42vh] min-h-[280px] overflow-hidden bg-ink">
<Placeholder src={image} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-black/10" />
<div className="absolute inset-x-0 bottom-0">
<Container className="px-4 sm:px-6">
<GlassBox frostSrc={image} className="relative mb-6 inline-block max-w-lg px-8 py-7">
<p className="section-kicker">{kicker}</p>
<h1 className="mt-2 text-3xl font-extrabold sm:text-5xl">{title}</h1>
</GlassBox>
</Container>
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Phone, Send } from "lucide-react";
import { Container } from "@/components/Container";
import { phones, site } from "@/lib/data";
export function TopBar() {
const primary = phones[0];
const landline = phones[2];
return (
<div className="bg-ink text-white">
<Container className="flex h-11 items-center justify-between gap-4 text-[12px]">
<p className="truncate">
<span className="text-brand">بورس قطعات لوازم خانگی</span>
<span className="mx-2 text-white/30">|</span>
<span className="text-white/70">قم · خرد و عمده · تعمیرگاه</span>
</p>
<div className="flex items-center gap-4">
<a href={primary.href} className="hidden items-center gap-1.5 font-latin hover:text-brand sm:flex" dir="ltr">
<Phone className="size-3.5" />
{primary.value}
</a>
<a href={landline.href} className="hidden font-latin hover:text-brand md:flex" dir="ltr">
{landline.value}
</a>
<a
href={site.telegram}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 hover:text-brand"
>
<Send className="size-3.5" />
تلگرام
</a>
</div>
</Container>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
]);
export default eslintConfig;
+16
View File
@@ -0,0 +1,16 @@
export const websiteDomain =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
process.env.WEBSITE_DOMAIN ??
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ??
"brb-store.com";
export const meshkeeApiBase = (
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
process.env.MESHKEE_API_BASE ??
process.env.NEXT_PUBLIC_MESHKEE_API_URL ??
"https://api.meshkee.com/api/v1"
).replace(/\/$/, "");
export const customerPortalUrl = (
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? `https://customer.${websiteDomain}`
).replace(/\/$/, "");
+463
View File
@@ -0,0 +1,463 @@
export const site = {
name: "فروشگاه برادران برقعی",
nameEn: "Burgheii Brothers Store",
shortName: "BRB",
tagline: "بورس قطعات لوازم خانگی",
slogan: "تجربه خریدی آسان ...",
description:
"فروشگاه برادران برقعی، انواع قطعات الکترونیکی لوازم برقی مصرفی در خانه را همچون قطعات ماشین لباس شویی، ماکروویو، جاروبرقی، کولر آبی، آبمیوه گیری و ... را برای استفاده شما در کنار هم فراهم کرده است.",
keywords: "قطعات الکترونیکی قم، قطعات لوازم خانگی قم،قطعه ماشین لباس شویی،بورس قطعات لوازم خانگی قم",
city: "قم",
telegram: "https://t.me/borgheeibrothers",
email: "alirezaborghei20@gmail.com",
emailLabel: "Alireza borghei20@gmail.com",
logo: "/images/brand/logo.png",
map: {
lat: 34.6564,
lng: 50.8696,
embed:
"https://maps.google.com/maps?q=34.6564,50.8696&hl=fa&z=16&output=embed",
},
};
export const about = {
title: "مجموعه ی فروشگاه و تعمیرگاه برادران برقعی",
paragraphs: [
"دارای فعالیت در زمینه های فروش لوازم خانگی، فروش قطعات لوازم خانگی و تعمیرات آنها شامل لوازم خانگی کوچک از قبیل (جارو برقی، چرخ گوشت، پنکه، آبمیوه گیری، آسیاب، اتوبخار، چایساز، همزن، مخلوط کن، ماکرویو و ...) می باشد.",
"این مجموعه با بیش از ۳۰ سال سابقه آماده خدمت رسانی در این عرصه ها به صورت خرد و عمده به شما عزیزان در ایران و سراسر جهان می باشد.",
"همچنین از تولیدکنندگان و تاجران عزیز در ایران و سراسر جهان که در این زمینه فعالیت دارند تقاضا دارد با معرفی خود به این مجموعه شروع یک همکاری صمیمانه را رقم بزنند.",
"مشتاقانه آماده همکاری با شما عزیزان هستیم...",
],
image: "/images/about/store.jpg",
};
export const phones = [
{ label: "تلفن همراه", value: "0912-309-3407", href: "tel:+989123093407" },
{ label: "تلفن همراه", value: "0912-651-7619", href: "tel:+989126517619" },
{ label: "تلفن", value: "025-36604454", href: "tel:+982536604454" },
{ label: "تلفن", value: "025-32404070", href: "tel:+982532404070" },
{ label: "تلفکس", value: "0253-6638866", href: "tel:+982536638866" },
];
export const branches = [
{
id: "keyvanfar",
title: "شعبه ۱",
address: "قم — خیابان سی متری کیوانفر، روبروی داروخانه الهادی، فروشگاه برادران برقعی",
image: "/images/lifestyle/lisa-anna-ZkWMfHPNWpw-unsplash.jpg",
},
{
id: "sadoughi",
title: "شعبه ۲",
address: "قم — بلوار صدوقی، خیابان امام حسین (ع)، نبش کوچه ۲۱",
image: "/images/lifestyle/theme-photos-Klby0nxseY8-unsplash.jpg",
},
];
export const navLinks = [
{ href: "/", label: "خانه" },
{ href: "/#parts", label: "قطعات" },
{ href: "/blog", label: "مقالات" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "ارتباط با ما" },
];
export const applianceGroups = [
{
title: "لوازم برقی خانگی",
items: [
"جارو برقی",
"اتو بخار و اتو پرس",
"پنکه و کولر",
"آبمیوه گیری و مخلوط کن",
"دستگاه اسپرسو ساز و آسیاب قهوه اسپرسو",
"خرد کن، غذاساز و آسیاب",
"چایی ساز و کتری برقی",
"ماکروویو و تستر",
"ساندویج ساز",
"همزن و گوشت کوب برقی",
"سماور برقی",
"سشوار",
"تلفن",
],
},
{
title: "لوازم برقی صنعتی",
items: [
"لوازم آشپزخانه صنعتی",
"لوازم نظافتی و بهداشتی صنعتی",
"دستگاه های واکس زن صنعتی",
],
},
];
export const slides = [
{
id: "slide-1",
image: "/images/slides/slide-1.jpg",
kicker: "Burgheii Appliances Store",
title: "تجربه خریدی آسان ...",
href: "/#parts",
},
{
id: "slide-2",
image: "/images/slides/slide-2.jpg",
kicker: "Burgheii Brothers Store",
title: "بورس قطعات لوازم خانگی",
href: "/#parts",
},
{
id: "slide-3",
image: "/images/about/store.jpg",
kicker: "Qom · 30 years",
title: "فروش، قطعه، تعمیر",
href: "/about",
},
];
export const categories = [
{
id: "vacuum",
title: "قطعات جارو برقی",
english: "Vacuum",
description: "گروه جاروبرقی: شامل جاروهای صنعتی و خانگی",
image: "/images/categories/vacuum.jpg",
href: "/#parts",
},
{
id: "blender",
title: "قطعات آبمیوه گیری، مخلوط کن، آسیاب",
english: "Juicer & Blender",
description: "شامل انواع قطعات لوازم فوق در انواع برندها",
image: "/images/categories/blender.jpg",
href: "/#parts",
},
{
id: "steam",
title: "قطعات بخارشویی",
english: "Steam Cleaner",
description: "گروه بخارشور: شامل انواع بخارشور در برندهای مختلف",
image: "/images/categories/steam.jpg",
href: "/#parts",
},
{
id: "meat-grinder",
title: "قطعات چرخ گوشت",
english: "Meat Grinder",
description: "خانگی و صنعتی ایستاده و رومیزی — انواع تیغ و شبکه",
image: "/images/categories/meat-grinder.jpg",
href: "/#parts",
},
{
id: "iron",
title: "قطعات اتو",
english: "Iron",
description: "گروه اتو: شامل انواع قطعات اتوهای پرس و اتوهای دستی",
image: "/images/categories/iron.jpg",
href: "/#parts",
},
{
id: "microwave",
title: "قطعات ماکروویو و تستر",
english: "Microwave",
description: "سولاردوم، ماکروویو و توستر در سایزها و برندهای مختلف",
image: "/images/categories/microwave.png",
href: "/#parts",
},
{
id: "kettle",
title: "قطعات چای ساز",
english: "Kettle",
description: "چای ساز، کتری برقی و قوری چای سازهای روهمی",
image: "/images/categories/kettle.jpg",
href: "/#parts",
},
{
id: "fan",
title: "قطعات پنکه و کولر آبی",
english: "Fan & Cooler",
description: "پنکه سقفی، رومیزی، پایه بلند و قطعات کولر آبی",
image: "/images/categories/fan.jpg",
href: "/#parts",
},
{
id: "food-processor",
title: "قطعات غذاساز، خردکن، گوشتکوب، همزن",
english: "Food Processor",
description: "انواع قطعات لوازم فوق در برندهای مختلف",
image: "/images/categories/food-processor.jpg",
href: "/#parts",
},
{
id: "battery",
title: "باطری",
english: "Battery",
description: "انواع باطری جاروهای شارژی در مدل‌های مختلف",
image: "/images/categories/battery.jpg",
href: "/#parts",
},
{
id: "bearing",
title: "بلبرینگ",
english: "Bearing",
description: "بلبرینگ جاروبرقی، لباسشویی و سایر سایزها",
image: "/images/categories/bearing.jpg",
href: "/#parts",
},
{
id: "collector",
title: "کلکتور",
english: "Collector",
description: "کلکتور جاروبرقی، آبمیوه گیری و مدل‌های دیگر",
image: "/images/categories/collector.jpg",
href: "/#parts",
},
];
export const showcaseCategories = [
{
id: "espresso",
title: "تعمیر اسپرسو ساز",
text: "تعمیر و تأمین قطعات دستگاه اسپرسو و آسیاب قهوه در شعبه قم.",
image: "/images/lifestyle/vlad-kutepov-RHCxMPJC5b8-unsplash.jpg",
href: "/contact",
},
{
id: "electrical",
title: "تعمیر لوازم برقی",
text: "تعمیر لوازم خانگی کوچک — جاروبرقی، مخلوط‌کن، چرخ گوشت و بیشتر.",
image: "/images/lifestyle/theme-photos-Klby0nxseY8-unsplash.jpg",
href: "/contact",
},
{
id: "microwave",
title: "تعمیر ماکروویو",
text: "قطعه و تعمیر ماکروویو، سولاردوم و توستر در انواع برندها.",
image: "/images/categories/microwave.png",
href: "/contact",
},
{
id: "vacuum",
title: "تعمیر جاروبرقی",
text: "کلکتور، بلبرینگ، تسمه و باطری جاروهای خانگی و صنعتی.",
image: "/images/categories/vacuum.jpg",
href: "/contact",
},
{
id: "grinder",
title: "تعمیر چرخ گوشت",
text: "تعویض تیغ، شبکه و قطعات چرخ گوشت خانگی و صنعتی.",
image: "/images/categories/meat-grinder.jpg",
href: "/contact",
},
{
id: "kettle",
title: "تعمیر چای ساز",
text: "المنت، ترموستات و قطعات چای‌ساز و کتری برقی.",
image: "/images/categories/kettle.jpg",
href: "/contact",
},
{
id: "blender",
title: "تعمیر مخلوط کن",
text: "قطعات آبمیوه‌گیری، مخلوط‌کن و آسیاب در برندهای مختلف.",
image: "/images/categories/blender.jpg",
href: "/contact",
},
{
id: "iron",
title: "تعمیر اتو",
text: "قطعات اتو بخار، اتو پرس و اتوهای دستی.",
image: "/images/categories/iron.jpg",
href: "/contact",
},
];
export const services = [
{
id: "sell",
title: "فروش لوازم خانگی",
text: "لوازم خانگی کوچک؛ جاروبرقی، چرخ گوشت، مخلوط کن، ماکروویو و بیشتر.",
image: "/images/slides/slide-1.jpg",
href: "/#parts",
},
{
id: "parts",
title: "بورس قطعات یدکی",
text: "تیغ، شبکه، کلکتور، بلبرینگ و قطعه اصلی برای تعمیرکار و همکار.",
image: "/images/categories/meat-grinder.jpg",
href: "/#parts",
},
{
id: "repair",
title: "تعمیرگاه تخصصی",
text: "تعمیر لوازم خانگی کوچک در دو شعبه قم؛ خرد و عمده.",
image: "/images/lifestyle/theme-photos-Klby0nxseY8-unsplash.jpg",
href: "/contact",
},
];
export const stats = [
{ value: "۳۰+", label: "سال سابقه در قم" },
{ value: "۲", label: "شعبه فعال" },
{ value: "۱۲", label: "گروه تخصصی قطعه" },
{ value: "خرد / عمده", label: "فروش به همکار و مصرف‌کننده" },
];
export const bannersBelowSlider = [
{
id: "banner-kitchen",
image: "/images/lifestyle/lisa-anna-ZkWMfHPNWpw-unsplash.jpg",
kicker: "Appliances",
title: "لوازم خانگی کوچک",
text: "جاروبرقی، مخلوط‌کن، چرخ گوشت، ماکروویو و سایر لوازم خانگی کوچک.",
href: "/#parts",
},
{
id: "banner-espresso",
image: "/images/lifestyle/vlad-kutepov-RHCxMPJC5b8-unsplash.jpg",
kicker: "Espresso",
title: "قطعات اسپرسو و آسیاب",
text: "قطعه و تعمیر دستگاه اسپرسو و آسیاب قهوه در شعبه قم.",
href: "/#parts",
},
];
export const twoBannerRow = [
{
id: "banner-repair",
image: "/images/lifestyle/theme-photos-Klby0nxseY8-unsplash.jpg",
kicker: "Workshop",
title: "تعمیرات تخصصی در محل شعبه",
text: "تعمیر لوازم خانگی کوچک در دو شعبه قم — خرد و عمده.",
href: "/about",
},
{
id: "banner-wholesale",
image: "/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg",
kicker: "Wholesale",
title: "فروش خرد و عمده به همکاران",
text: "تأمین قطعه برای تعمیرکار، همکار و مصرف‌کننده.",
href: "/contact",
},
];
export const threeBannerRow = [
{
id: "banner-kettle",
image: "/images/lifestyle/gleb-paniotov-N6Bm7zMCZUo-unsplash.jpg",
kicker: "Kettle",
title: "چای ساز و کتری برقی",
text: "المنت، ترموستات و قطعات چای‌ساز و کتری برقی.",
href: "/#parts",
},
{
id: "banner-cooling",
image: "/images/lifestyle/salar-HVXmK1wqntk-unsplash.jpg",
kicker: "Cooling",
title: "کولر آبی و تهویه",
text: "قطعات پنکه، کولر آبی و تهویه خانگی.",
href: "/#parts",
},
{
id: "banner-stove",
image: "/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg",
kicker: "Cooking",
title: "قطعات پخت‌وپز",
text: "قطعه لوازم پخت‌وپز کوچک برای تعمیرگاه و مصرف‌کننده.",
href: "/#parts",
},
];
/** Placeholder products with white backgrounds until Meshkee store items are wired. */
export const sampleProducts = [
{ id: "p-vacuum", title: "قطعات جاروبرقی", image: "/images/categories/vacuum.jpg" },
{ id: "p-blender", title: "قطعات مخلوط کن", image: "/images/categories/blender.jpg" },
{ id: "p-grinder", title: "تیغ و شبکه چرخ گوشت", image: "/images/categories/meat-grinder.jpg" },
{ id: "p-microwave", title: "قطعات ماکروویو", image: "/images/categories/microwave.png" },
{ id: "p-kettle", title: "قطعات چای ساز", image: "/images/categories/kettle.jpg" },
{ id: "p-iron", title: "قطعات اتو", image: "/images/categories/iron.jpg" },
{ id: "p-bearing", title: "بلبرینگ", image: "/images/categories/bearing.jpg" },
{ id: "p-collector", title: "کلکتور", image: "/images/categories/collector.jpg" },
];
export const blogPosts = [
{
slug: "meat-grinder-blades",
title: "زمان تعویض تیغ و شبکه چرخ گوشت",
excerpt: "کند شدن تیغ فقط کیفیت چرخ را پایین نمی‌آورد؛ موتور را هم تحت فشار می‌گذارد.",
date: "۱۴۰۴/۰۵/۱۲",
image: "/images/categories/meat-grinder.jpg",
body: [
"تیغ و شبکه چرخ گوشت از پرمصرف‌ترین قطعات تعمیرگاه هستند. وقتی گوشت به‌جای برش، له می‌شود یا موتور داغ می‌کند، معمولاً لبه تیغ گرد شده یا سوراخ شبکه گشاد شده است.",
"شبکه و تیغ را به‌صورت جفت عوض کنید تا سطح تماس یکنواخت بماند. در مدل‌های خانگی و صنعتی ایستاده، سایز و تعداد سوراخ فرق دارد؛ قطعه را با مدل دستگاه مطابقت دهید.",
"در شعبه‌های قم، تیغ و شبکه اصلی برای برندهای رایج موجود است — خرد و عمده.",
],
},
{
slug: "vacuum-maintenance",
title: "نگهداری جاروبرقی خانگی و صنعتی",
excerpt: "فیلتر گرفته و برس فرسوده، مکش را کم می‌کند و کلکتور را زودتر می‌سوزاند.",
date: "۱۴۰۴/۰۴/۲۸",
image: "/images/categories/vacuum.jpg",
body: [
"کم‌شدن مکش همیشه به موتور مربوط نیست. کیسه پر، فیلتر مسدود و برس غلتکی فرسوده سه علت اول مراجعه به تعمیرگاه هستند.",
"جاروهای صنعتی و خانگی قطعات مشترک زیادی دارند: کلکتور، بلبرینگ، تسمه و باطری مدل‌های شارژی. تعویض به‌موقع این‌ها از سوختن آرمیچر جلوگیری می‌کند.",
],
},
{
slug: "bearings-guide",
title: "بلبرینگ جاروبرقی و لباسشویی",
excerpt: "صدای زوزه یا لرزش غیرعادی معمولاً از بلبرینگ خشک یا خردشده است.",
date: "۱۴۰۴/۰۴/۰۹",
image: "/images/categories/bearing.jpg",
body: [
"بلبرینگ در جاروبرقی، لباسشویی و بعضی مخلوط‌کن‌ها کار می‌کند. سایز غلط یعنی لقی، داغی و در نهایت قفل شدن شفت.",
"شماره حک‌شده روی بلبرینگ را بخوانید یا قطعه معیوب را به فروشگاه بیاورید تا همان سایز تحویل شود.",
],
},
{
slug: "microwave-parts",
title: "قطعات ماکروویو: مگنترون تا سینی",
excerpt: "ماکروویو، سولاردوم و توستر قطعات جداگانه‌ای دارند؛ مدل دستگاه را دقیق اعلام کنید.",
date: "۱۴۰۴/۰۳/۲۱",
image: "/images/categories/microwave.png",
body: [
"گرم نشدن غذا الزاماً به معنی تعویض کل دستگاه نیست. مگنترون، دکمه‌ها، سینی شیشه‌ای و لولای در از قطعات قابل تهیه در بورس قطعات هستند.",
"برای سولاردوم و توستر هم قطعه جدا موجود است. مدل و برند را همراه داشته باشید.",
],
},
{
slug: "vacuum-collector",
title: "کلکتور موتور جاروبرقی چیست؟",
excerpt: "جرقه زیاد پشت موتور و بوی سوختگی، نشانه تمام شدن ذغال کلکتور است.",
date: "۱۴۰۴/۰۲/۱۸",
image: "/images/categories/collector.jpg",
body: [
"کلکتور مسیر جریان را به آرمیچر می‌رساند. وقتی ذغال تمام شود، جرقه می‌زند، قدرت کم می‌شود و در نهایت موتور از کار می‌افتد.",
"کلکتور جاروبرقی، آبمیوه‌گیری و چند مدل دیگر در فروشگاه موجود است. قطعه سوخته را برای تطبیق بیاورید.",
],
},
{
slug: "kettle-repair",
title: "تعمیر چای‌ساز و کتری برقی",
excerpt: "جوش نیاوردن معمولاً به المنت، ترموستات یا سوکت زیر کتری برمی‌گردد.",
date: "۱۴۰۴/۰۱/۳۰",
image: "/images/categories/kettle.jpg",
body: [
"چای‌ساز روهمی، کتری برقی و قوری هر کدام قطعه مخصوص خود را دارند. رسوب زیاد المنت را ضعیف می‌کند و ترموستات معیوب دستگاه را بی‌دلیل قطع می‌کند.",
"قبل از تعویض کل دستگاه، قطعه را در شعبه کیوانفر یا صدوقی بررسی کنید.",
],
},
];
export function getBlogPost(slug: string) {
return blogPosts.find((post) => post.slug === slug);
}
export const features = [
{ title: "۳۰ سال سابقه", text: "خدمت‌رسانی خرد و عمده در قم و سراسر کشور" },
{ title: "قطعات تخصصی", text: "جاروبرقی، چرخ گوشت، مخلوط کن، ماکروویو و بیشتر" },
{ title: "تعمیرگاه فعال", text: "تعمیر لوازم خانگی کوچک در کنار فروش قطعه" },
{ title: "دو شعبه در قم", text: "کیوانفر و بلوار صدوقی — امام حسین" },
];
+7
View File
@@ -0,0 +1,7 @@
export const glossyBar = {
background: "linear-gradient(180deg, rgb(48 52 56 / 0.78) 0%, rgb(18 20 22 / 0.88) 100%)",
backdropFilter: "blur(22px) saturate(180%)",
WebkitBackdropFilter: "blur(22px) saturate(180%)",
border: "1px solid rgb(255 255 255 / 0.22)",
boxShadow: "inset 0 1px 0 rgb(255 255 255 / 0.28), 0 10px 28px rgb(0 0 0 / 0.28)",
} as const;
+227
View File
@@ -0,0 +1,227 @@
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export type SlotKind = "single" | "list";
export type SlotImage = {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number | null;
};
export type StaticImageSlotCatalogItem = {
key: string;
label: string;
kind: SlotKind;
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
export type StaticImageSlot = StaticImageSlotCatalogItem & {
images: SlotImage[];
};
export type SlotMediaItem = {
id: string;
image: string;
href?: string;
title?: string;
kicker?: string;
text?: string;
fallbackSrc?: string;
};
/**
* Homepage slot catalog published at GET /meshkee/static-image-slots.
* Ratios match the live homepage at max-w-[1280px]. If a section’s CSS size
* or aspect ratio changes, update this catalog in the same change — Meshkee
* reads it as the static-image source of truth.
*/
export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
{
key: "slider",
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: null,
recommendedWidth: 1280,
},
{
key: "three-icon-image",
label: "Three service icons",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: 3,
recommendedWidth: 128,
},
{
key: "categories",
label: "Category carousel",
kind: "list",
pageKey: "home",
aspectRatio: "14:23",
itemCount: null,
recommendedWidth: 280,
},
{
key: "about-us-image",
label: "About us image",
kind: "single",
pageKey: "home",
aspectRatio: "4:3",
itemCount: 1,
recommendedWidth: 720,
},
{
key: "five-banner-section",
label: "Five banner section",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: 5,
recommendedWidth: 606,
},
{
key: "two-banner-row",
label: "Two banner row",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: 2,
recommendedWidth: 606,
},
{
key: "full-row-image",
label: "Full-row contact banner",
kind: "single",
pageKey: "home",
aspectRatio: "32:9",
itemCount: 1,
recommendedWidth: 1920,
},
{
key: "two-branch-image",
label: "Two branch images",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: 2,
recommendedWidth: 606,
},
];
export function catalogSlot(key: string) {
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
}
export function parseAspectRatio(ratio?: string | null) {
if (!ratio) return undefined;
const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
if (!match) return undefined;
return `${match[1]} / ${match[2]}`;
}
export function pickSlot(slots: StaticImageSlot[], key: string) {
return slots.find((slot) => slot.key === key);
}
function normalizeImages(images: SlotImage[] | undefined) {
return [...(images ?? [])]
.filter((image) => Boolean(image?.url))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
if (!slot) return [];
const images = normalizeImages(slot.images);
if (slot.kind === "single") return images.slice(0, 1);
return images;
}
function toMedia(image: SlotImage, fallback?: SlotMediaItem): SlotMediaItem {
return {
id: String(image.id),
image: image.url,
href: image.linkUrl?.trim() || fallback?.href,
title: image.titleFa?.trim() || fallback?.title,
kicker: image.titleEn?.trim() || fallback?.kicker,
text: image.subtext?.trim() || fallback?.text,
fallbackSrc: fallback?.image || fallback?.fallbackSrc,
};
}
export function overlaySlotImages(
slot: StaticImageSlot | undefined,
fallbacks: SlotMediaItem[],
key: string,
): { items: SlotMediaItem[]; aspectRatio?: string } {
const catalog = catalogSlot(key);
const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
const kind = slot?.kind ?? catalog?.kind ?? "list";
const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
if (kind === "single") {
const image = apiImages[0];
const fallback = fallbacks[0];
return {
aspectRatio,
items: image ? [toMedia(image, fallback)] : fallbacks.slice(0, 1),
};
}
if (apiImages.length === 0) {
return {
aspectRatio,
items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
};
}
if (itemCount) {
return {
aspectRatio,
items: Array.from({ length: itemCount }, (_, index) => {
const image = apiImages[index];
const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
return image ? toMedia(image, fallback) : fallback;
}).filter(Boolean),
};
}
return {
aspectRatio,
items: apiImages.map((image, index) => toMedia(image, fallbacks[index])),
};
}
function readItems(payload: unknown): StaticImageSlot[] {
if (Array.isArray(payload)) return payload as StaticImageSlot[];
if (payload && typeof payload === "object") {
const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
return record.items ?? record.slots ?? [];
}
return [];
}
export async function fetchStaticImageSlots(pageKey = "home"): Promise<StaticImageSlot[]> {
const url = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
try {
const response = await fetch(url, {
next: { revalidate: 60 },
});
if (!response.ok) return [];
return readItems(await response.json());
} catch {
return [];
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
agentRules: false,
};
export default nextConfig;
+6791
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "brb-store",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"lucide-react": "^0.539.0",
"next": "^16.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"eslint": "^9.34.0",
"eslint-config-next": "^16.3.1",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}