Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:51 +03:30
co-authored by Cursor
commit 42aff5aee9
162 changed files with 16828 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3002
NEXT_PUBLIC_ADMIN_DASHBOARD_URL=http://localhost:3002
NEXT_PUBLIC_BUSINESS_DASHBOARD_URL=http://business.localhost:3002
NEXT_PUBLIC_WEBSITE_URL=http://localhost:3001
NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL=http://customer.localhost:3002
NEXT_PUBLIC_ROOT_DOMAIN=localhost
# Meshkee — always remote (do not point at a local Meshkee)
NEXT_PUBLIC_MESHKEE_API_URL=https://api.meshkee.com/api/v1
# Meshkee (used by /api/meshkee/categories)
MESHKEE_API_URL=https://api.meshkee.com/api/v1
# Prefer cell + password (access tokens expire quickly). Optional static token:
# MESHKEE_API_TOKEN=
# MESHKEE_SUPERADMIN_CELL=
# MESHKEE_SUPERADMIN_PASSWORD=
NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN=iranbay.com
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+13
View File
@@ -0,0 +1,13 @@
# IranBay Website
Public Next.js site. From this folder:
```bash
cp .env.example .env.local
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
Account login is on the dashboard app (`NEXT_PUBLIC_DASHBOARD_URL`, default `http://localhost:3002`). Business registration stays at `/register`.
+369
View File
@@ -0,0 +1,369 @@
.page {
background: var(--color-white);
}
.container {
max-width: var(--container-width);
margin: 0 auto;
padding: 0 var(--page-pad);
}
.wide {
max-width: 1512px;
margin: 0 auto;
padding: 0 var(--page-pad);
}
.intro {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.15fr);
gap: 48px;
align-items: center;
padding: 56px 0 72px;
}
.visual {
position: relative;
width: min(100%, 380px);
aspect-ratio: 466 / 634;
margin-inline: auto 0;
}
.visualBack {
position: absolute;
inset: 0 18% 12% 0;
background: var(--color-card);
border-radius: 32px;
}
.visualCard {
position: absolute;
left: 14%;
top: 12%;
width: 86%;
height: 88%;
border-radius: 32px;
object-fit: cover;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.08);
}
.kicker {
font-size: clamp(22px, 2.2vw, 35px);
font-weight: 300;
color: #434343;
margin-bottom: 8px;
}
.title {
font-size: clamp(34px, 3.4vw, 55px);
font-weight: 700;
line-height: 1.1;
margin-bottom: 28px;
}
.title span {
display: block;
}
.copy {
font-size: 16px;
font-weight: 400;
line-height: 1.45;
color: #bababa;
margin-bottom: 18px;
max-width: 650px;
text-align: justify;
}
.cta {
margin-top: 18px;
height: 34px;
padding: 0 16px;
min-width: 207px;
font-size: 12px;
letter-spacing: 2.4px;
}
.clients {
padding: 24px 0 56px;
}
.clientBar {
margin-top: 36px;
background: #f8f8f8;
border-radius: 5px;
min-height: 160px;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: center;
gap: 24px;
padding: 24px 40px;
}
.clientLogo {
width: 100%;
max-height: 96px;
object-fit: contain;
margin: 0 auto;
}
.story {
display: grid;
grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.15fr);
gap: 40px;
padding: 40px 0 72px;
align-items: start;
}
.story .cta {
margin-top: 24px;
}
.comments {
padding: 24px 0 72px;
text-align: center;
}
.commentHead {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.commentHead .sectionHead {
margin: 0;
}
.arrows {
display: flex;
gap: 6px;
}
.arrowBtn {
width: 38px;
height: 36px;
background: var(--color-card);
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
}
.arrowBtn img {
width: 8px;
height: 14px;
object-fit: contain;
}
.arrowPrev {
transform: rotate(180deg);
}
.commentCount {
font-size: 13px;
letter-spacing: 1px;
color: var(--color-soft);
min-width: 48px;
text-align: right;
}
.commentTrack {
animation: commentIn 0.35s ease;
}
@keyframes commentIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.quote {
max-width: 900px;
margin: 28px auto 18px;
font-size: 18px;
line-height: 1.45;
color: #bababa;
}
.meta {
font-size: 18px;
color: #5f5f5f;
}
.dots {
margin-top: 28px;
display: flex;
justify-content: center;
gap: 8px;
}
.dot,
.dotActive {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d8d8d8;
padding: 0;
}
.dotActive {
background: var(--color-black);
}
.services {
padding: 24px 0 96px;
}
.serviceGrid {
margin-top: 48px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 28px;
align-items: end;
}
.serviceCard {
position: relative;
min-height: 190px;
display: grid;
grid-template-columns: auto 1fr;
align-items: end;
gap: 8px;
padding: 24px 16px 18px 0;
}
.serviceCard::before {
content: "";
position: absolute;
left: 52px;
right: 0;
bottom: 18px;
height: 135px;
background: #f8f8f8;
border-radius: 25px;
z-index: 0;
}
.serviceCard img {
position: relative;
z-index: 1;
display: block;
width: auto;
height: 160px;
max-width: 150px;
object-fit: contain;
margin-left: 0;
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.12));
}
.serviceCopy {
position: relative;
z-index: 1;
align-self: center;
padding: 8px 12px 8px 4px;
text-align: left;
}
.serviceCopy h3 {
font-size: clamp(20px, 1.8vw, 30px);
font-weight: 400;
letter-spacing: 1px;
text-transform: uppercase;
line-height: 1.1;
}
.serviceCopy p {
margin-top: 6px;
font-size: 11px;
font-weight: 300;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--color-black);
}
.support .serviceCopy h3 {
color: #575f50;
}
.register .serviceCopy h3 {
color: #b68968;
}
.responsive .serviceCopy h3 {
color: #474039;
}
.sectionHead {
text-align: center;
}
.sectionTitle {
font-size: clamp(24px, 2.2vw, 35px);
font-weight: 400;
letter-spacing: 14px;
text-transform: uppercase;
}
.sectionSub {
margin-top: 8px;
font-size: 12px;
font-weight: 300;
letter-spacing: 4.02px;
text-transform: uppercase;
color: var(--color-soft);
}
@media (max-width: 980px) {
.intro,
.story {
grid-template-columns: 1fr;
}
.visual {
margin: 0 auto;
}
.commentHead {
grid-template-columns: 1fr;
justify-items: center;
gap: 14px;
}
.commentCount {
text-align: center;
}
.serviceGrid {
grid-template-columns: 1fr;
max-width: 420px;
margin-left: auto;
margin-right: auto;
}
.clientBar {
grid-template-columns: repeat(3, 1fr);
min-height: auto;
}
}
@media (max-width: 720px) {
.clientBar {
grid-template-columns: repeat(2, 1fr);
padding: 20px;
}
.sectionTitle {
letter-spacing: 6px;
}
.serviceCopy {
padding-left: 4px;
}
}
+117
View File
@@ -0,0 +1,117 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import CommentCarousel from "@/components/CommentCarousel";
import { aboutCopy, clients, services } from "@/data/pages";
import styles from "./about.module.css";
export const metadata = {
title: "About Us | IranBay",
};
export default function AboutPage() {
return (
<div className={styles.page}>
<Header />
<main>
<section className={styles.container}>
<div className={styles.intro}>
<div className={styles.visual}>
<div className={styles.visualBack} />
<img
className={styles.visualCard}
src="/images/about/hero-card.png"
alt="IranBay"
/>
</div>
<div>
<p className={styles.kicker}>{aboutCopy.kicker}</p>
<h1 className={styles.title}>
{aboutCopy.title.map((line) => (
<span key={line}>{line}</span>
))}
</h1>
{aboutCopy.paragraphs.map((p) => (
<p className={styles.copy} key={p.slice(0, 32)}>
{p}
</p>
))}
<a className={`outline-btn ${styles.cta}`} href="/contact">
Contact us
</a>
</div>
</div>
</section>
<section className={styles.wide}>
<div className={styles.clients}>
<div className={styles.sectionHead}>
<h2 className={styles.sectionTitle}>All clinets</h2>
<p className={styles.sectionSub}>
some of our most crowded categories
</p>
</div>
<div className={styles.clientBar}>
{clients.map((client) => (
<img
key={client.name}
src={client.image}
alt={client.name}
className={styles.clientLogo}
/>
))}
</div>
</div>
</section>
<section className={styles.container}>
<div className={styles.story}>
<div>
<p className={styles.kicker}>{aboutCopy.kicker}</p>
<h2 className={styles.title}>
{aboutCopy.title.map((line) => (
<span key={`story-${line}`}>{line}</span>
))}
</h2>
</div>
<div>
<p className={styles.copy}>{aboutCopy.paragraphs[0]}</p>
<a className={`outline-btn ${styles.cta}`} href="/contact">
Contact us
</a>
</div>
</div>
</section>
<section className={styles.container}>
<CommentCarousel />
</section>
<section className={styles.wide}>
<div className={styles.services}>
<div className={styles.sectionHead}>
<h2 className={styles.sectionTitle}>Our services</h2>
<p className={styles.sectionSub}>
some of our most crowded categories
</p>
</div>
<div className={styles.serviceGrid}>
{services.map((item) => (
<article
className={`${styles.serviceCard} ${styles[item.tone]}`}
key={item.title}
>
<img src={item.image} alt="" />
<div className={styles.serviceCopy}>
<h3>{item.title}</h3>
<p>{item.subtitle}</p>
</div>
</article>
))}
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
const MESHKEE_API_URL =
process.env.MESHKEE_API_URL?.replace(/\/$/, "") ||
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
"https://api.meshkee.com/api/v1";
type CachedToken = { token: string; expiresAt: number };
let cachedToken: CachedToken | null = null;
function tokenExpiry(token: string) {
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1] ?? "", "base64url").toString(),
) as { exp?: number };
if (typeof payload.exp === "number") return payload.exp * 1000;
} catch {
/* ignore malformed tokens */
}
return 0;
}
function usableStaticToken() {
const token = process.env.MESHKEE_API_TOKEN?.trim();
if (!token) return null;
const expiresAt = tokenExpiry(token);
if (expiresAt && expiresAt < Date.now() + 15_000) return null;
return token;
}
async function serviceToken(): Promise<string | null> {
const now = Date.now();
if (cachedToken && cachedToken.expiresAt > now + 15_000) {
return cachedToken.token;
}
const staticToken = usableStaticToken();
if (staticToken) return staticToken;
const cellNumber = process.env.MESHKEE_SUPERADMIN_CELL?.trim();
const password = process.env.MESHKEE_SUPERADMIN_PASSWORD?.trim();
if (!cellNumber || !password) return null;
const response = await fetch(`${MESHKEE_API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cellNumber, password }),
cache: "no-store",
});
if (!response.ok) return null;
const payload = (await response.json().catch(() => null)) as {
accessToken?: string;
} | null;
const token = payload?.accessToken?.trim();
if (!token) return null;
cachedToken = {
token,
expiresAt: tokenExpiry(token) || now + 10 * 60 * 1000,
};
return token;
}
async function authHeader(request: NextRequest): Promise<string | null> {
const incoming = request.headers.get("authorization")?.trim();
if (incoming) return incoming;
const token = await serviceToken();
return token ? `Bearer ${token}` : null;
}
export async function GET(request: NextRequest) {
const auth = await authHeader(request);
if (!auth) {
return NextResponse.json(
{
message:
"Meshkee categories need auth. Set MESHKEE_API_TOKEN or sign in on the dashboard first.",
},
{ status: 503 },
);
}
const q = request.nextUrl.searchParams.get("q")?.trim() ?? "";
const params = new URLSearchParams();
if (q) params.set("q", q);
const response = await fetch(
`${MESHKEE_API_URL}/business-categories?${params}`,
{
headers: { Authorization: auth },
cache: "no-store",
},
);
const payload = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
payload ?? { message: `Meshkee error (${response.status})` },
{ status: response.status },
);
}
return NextResponse.json(payload);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
/**
* Install Meshkee session from the URL hash before the register page reads tokens.
* Used after customer-dashboard login with ?next=https://…/register
*/
const HANDOFF_BOOT = `
(function () {
try {
var raw = location.hash ? location.hash.replace(/^#/, "") : "";
if (!raw) return;
var p = new URLSearchParams(raw);
var access = p.get("access");
var refresh = p.get("refresh");
var next = p.get("next") || "/register";
if (!access || !refresh) return;
localStorage.setItem("marketplace_access_token", access);
localStorage.setItem("marketplace_refresh_token", refresh);
var root = "localhost";
try {
document.cookie =
"marketplace_access_token=" +
encodeURIComponent(access) +
"; Path=/; Max-Age=1209600; SameSite=Lax; Domain=" +
root;
document.cookie =
"marketplace_refresh_token=" +
encodeURIComponent(refresh) +
"; Path=/; Max-Age=1209600; SameSite=Lax; Domain=" +
root;
} catch (e) {}
var target = next.charAt(0) === "/" ? next : "/" + next;
location.replace(target);
} catch (e) {}
})();
`;
export default function AuthHandoffLayout({
children,
}: {
children: ReactNode;
}) {
return (
<>
<script dangerouslySetInnerHTML={{ __html: HANDOFF_BOOT }} />
{children}
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
export default function AuthHandoffPage() {
return (
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#f6f6f6",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
padding: 24,
textAlign: "center",
lineHeight: 1.6,
}}
>
Signing you in…
</div>
);
}
@@ -0,0 +1,196 @@
.page {
background: var(--color-white);
}
.shell {
max-width: 1920px;
margin: 0 auto;
padding: 0 var(--page-pad) 80px;
}
.layout {
margin-top: 36px;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.55fr);
gap: 42px;
align-items: start;
}
.article {
min-width: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 18px 28px;
margin-bottom: 12px;
font-size: 12px;
font-weight: 300;
color: var(--color-soft);
}
.meta span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.meta img {
width: 14px;
height: 14px;
opacity: 0.7;
object-fit: contain;
}
.title {
font-size: clamp(28px, 3vw, 40px);
font-weight: 300;
line-height: 1.2;
margin-bottom: 12px;
}
.intro {
font-size: 17px;
font-weight: 300;
line-height: 18px;
color: #9d9d9d;
text-align: justify;
margin-bottom: 28px;
}
.hero {
position: relative;
width: 100%;
aspect-ratio: 1190 / 525;
border: 1px solid #eee;
border-radius: 5px;
overflow: hidden;
margin-bottom: 36px;
background: #f6f6f6;
}
.heroImg {
object-fit: cover;
padding: 1.5%;
border-radius: 5px;
}
.sectionTitle {
font-size: clamp(28px, 3vw, 40px);
font-weight: 300;
margin-bottom: 18px;
}
.actions {
margin-top: 36px;
padding-top: 28px;
border-top: 1px solid #ececec;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 18px;
}
.related {
display: grid;
grid-template-columns: 154px minmax(0, 1fr);
gap: 18px;
padding: 18px;
border: 1px solid #eee;
border-radius: 5px;
transition: border-color 0.25s ease, transform 0.25s ease;
}
.related:hover {
border-color: #ccc;
transform: translateY(-2px);
}
.relatedMedia {
position: relative;
width: 154px;
height: 154px;
border-radius: 5px;
overflow: hidden;
background: #f6f6f6;
}
.relatedImg {
object-fit: cover;
}
.relatedBody {
display: flex;
flex-direction: column;
min-width: 0;
padding-top: 4px;
}
.relatedBody h3 {
font-size: 18px;
font-weight: 300;
margin-bottom: 10px;
}
.relatedBody p {
font-size: 14px;
font-weight: 300;
line-height: 1.45;
color: #9d9d9d;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.relatedMeta {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 12px;
font-weight: 300;
color: var(--color-soft);
}
.relatedMeta span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.relatedMeta img {
width: 14px;
height: 14px;
opacity: 0.55;
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.sidebar {
grid-template-columns: 1fr;
}
.related {
grid-template-columns: 110px minmax(0, 1fr);
}
.relatedMedia {
width: 110px;
height: 110px;
}
}
@@ -0,0 +1,133 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import DetailBanner from "@/components/DetailBanner";
import ArticleBody from "@/components/ArticleBody";
import ShareLikeActions from "@/components/ShareLikeActions";
import {
getAllBusinessSlugs,
getBusinessBlogPost,
getBusinessBySlug,
} from "@/data/business";
import styles from "./blog.module.css";
export function generateStaticParams() {
return getAllBusinessSlugs().flatMap((slug) => {
const business = getBusinessBySlug(slug);
return (business?.posts ?? []).map((post) => ({
slug,
postSlug: post.slug,
}));
});
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; postSlug: string }>;
}) {
const { slug, postSlug } = await params;
const data = getBusinessBlogPost(slug, postSlug);
if (!data) return { title: "Blog Details | IranBay" };
return {
title: `${data.post.title} | Blog Details | IranBay`,
description: data.post.intro,
};
}
export default async function BlogDetailPage({
params,
}: {
params: Promise<{ slug: string; postSlug: string }>;
}) {
const { slug, postSlug } = await params;
const data = getBusinessBlogPost(slug, postSlug);
if (!data) notFound();
const { business, post, related } = data;
return (
<div className={styles.page}>
<Header />
<main className={styles.shell}>
<DetailBanner title="Blog Details" breadcrumbs={business.breadcrumbs} />
<div className={styles.layout}>
<article className={styles.article}>
<div className={styles.meta}>
<span>
<img src="/images/icons/calendar.svg" alt="" />
{post.date}
</span>
<span>
<img src="/images/icons/user.svg" alt="" />
{post.author}
</span>
<span>
<img src="/images/icons/category.svg" alt="" />
{post.category}
</span>
</div>
<h2 className={styles.title}>{post.title}.</h2>
<p className={styles.intro}>{post.intro}</p>
<div className={styles.hero}>
<Image
src={post.hero}
alt=""
fill
className={styles.heroImg}
sizes="(max-width: 1100px) 100vw, 1232px"
priority
/>
</div>
<h3 className={styles.sectionTitle}>{post.title}.</h3>
<ArticleBody blocks={post.body} />
<div className={styles.actions}>
<ShareLikeActions />
</div>
</article>
<aside className={styles.sidebar} aria-label="Related posts">
{related.map((item) => (
<Link
key={item.slug}
href={`/businesses/${business.slug}/blog/${item.slug}`}
className={styles.related}
>
<div className={styles.relatedMedia}>
<Image
src={item.image}
alt=""
fill
className={styles.relatedImg}
sizes="154px"
/>
</div>
<div className={styles.relatedBody}>
<h3>{item.title}</h3>
<p>{item.excerpt}</p>
<div className={styles.relatedMeta}>
<span>
<img src="/images/icons/calendar.svg" alt="" />
{item.date}
</span>
<span>
<img src="/images/icons/user.svg" alt="" />
{item.author}
</span>
</div>
</div>
</Link>
))}
</aside>
</div>
</main>
<Footer />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
.page {
background: var(--color-white);
}
.shell {
max-width: 1920px;
margin: 0 auto;
padding: 0 var(--page-pad);
}
+52
View File
@@ -0,0 +1,52 @@
import { notFound } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import BusinessDetailView from "@/components/BusinessDetail";
import {
getAllBusinessSlugs,
getBusinessBySlug,
} from "@/data/business";
import styles from "./page.module.css";
export function generateStaticParams() {
return getAllBusinessSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const business = getBusinessBySlug(slug);
if (!business) {
return { title: "Business | IranBay" };
}
return {
title: `${business.name} | IranBay`,
description: business.description.slice(0, 140),
};
}
export default async function BusinessDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const business = getBusinessBySlug(slug);
if (!business) {
notFound();
}
return (
<div className={styles.page}>
<Header />
<main className={styles.shell}>
<BusinessDetailView business={business} />
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,92 @@
import { notFound } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import DetailBanner from "@/components/DetailBanner";
import ArticleBody from "@/components/ArticleBody";
import ShareLikeActions from "@/components/ShareLikeActions";
import PortfolioGallery from "@/components/PortfolioGallery";
import {
getAllBusinessSlugs,
getBusinessBySlug,
getBusinessPortfolio,
} from "@/data/business";
import styles from "./portfolio.module.css";
export function generateStaticParams() {
return getAllBusinessSlugs().flatMap((slug) => {
const business = getBusinessBySlug(slug);
return (business?.portfolios ?? []).map((item) => ({
slug,
portfolioSlug: item.slug,
}));
});
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; portfolioSlug: string }>;
}) {
const { slug, portfolioSlug } = await params;
const data = getBusinessPortfolio(slug, portfolioSlug);
if (!data) return { title: "Portfolios details | IranBay" };
return {
title: `${data.portfolio.title} | Portfolios details | IranBay`,
description: data.portfolio.description,
};
}
export default async function PortfolioDetailPage({
params,
}: {
params: Promise<{ slug: string; portfolioSlug: string }>;
}) {
const { slug, portfolioSlug } = await params;
const data = getBusinessPortfolio(slug, portfolioSlug);
if (!data) notFound();
const { business, portfolio } = data;
return (
<div className={styles.page}>
<Header />
<main className={styles.shell}>
<DetailBanner
title="Portfolios details"
breadcrumbs={business.breadcrumbs}
/>
<div className={styles.layout}>
<PortfolioGallery images={portfolio.images} />
<div className={styles.copy}>
<div className={styles.meta}>
<span>
<img src="/images/icons/calendar.svg" alt="" />
{portfolio.date}
</span>
<span>
<img src="/images/icons/user.svg" alt="" />
{portfolio.author}
</span>
<span>
<img src="/images/icons/category.svg" alt="" />
{portfolio.category}
</span>
</div>
<h2 className={styles.title}>{portfolio.title}.</h2>
<p className={styles.intro}>{portfolio.description}</p>
<hr className={styles.rule} />
<div className={styles.bodyWrap}>
<ArticleBody blocks={portfolio.body} />
</div>
<div className={styles.actions}>
<ShareLikeActions />
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,82 @@
.page {
background: var(--color-white);
}
.shell {
max-width: 1920px;
margin: 0 auto;
padding: 0 var(--page-pad) 80px;
}
.layout {
margin-top: 36px;
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 42px;
align-items: start;
}
.copy {
min-width: 0;
padding-top: 4px;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 18px 28px;
margin-bottom: 12px;
font-size: 12px;
font-weight: 300;
color: var(--color-soft);
}
.meta span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.meta img {
width: 14px;
height: 14px;
opacity: 0.7;
object-fit: contain;
}
.title {
font-size: clamp(28px, 3vw, 40px);
font-weight: 300;
line-height: 1.2;
margin-bottom: 10px;
}
.intro {
font-size: 17px;
font-weight: 300;
line-height: 18px;
color: #9d9d9d;
margin-bottom: 18px;
}
.rule {
border: 0;
border-top: 1px solid #ececec;
margin: 0 0 20px;
}
.bodyWrap {
min-height: 280px;
}
.actions {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid #ececec;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
+409
View File
@@ -0,0 +1,409 @@
.page {
background: var(--color-white);
}
.shell {
max-width: 1920px;
margin: 0 auto;
padding: 0 var(--page-pad);
}
.bannerWrap {
padding-top: 28px;
}
.banner {
position: relative;
isolation: isolate;
overflow: hidden;
background: #f6f6f6;
border-radius: 5px;
min-height: 136px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px 20px;
}
.banner::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.7;
background-image: var(--page-title-pattern);
}
.bannerTitle,
.crumbs {
position: relative;
z-index: 1;
}
.bannerTitle {
font-size: clamp(24px, 2.2vw, 35px);
font-weight: 400;
letter-spacing: 14px;
text-transform: uppercase;
}
.crumbs {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-soft);
}
.crumbs > span {
display: inline-flex;
align-items: center;
gap: 10px;
}
.crumbChevron {
width: 10px;
height: 10px;
opacity: 0.55;
}
.crumbActive {
color: var(--color-black);
}
.container {
width: 100%;
}
.filtersBar {
margin-top: 28px;
background: #f9f9f9;
border-radius: 5px;
padding: 18px 24px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 24px;
align-items: center;
}
.filtersLabel {
display: flex;
align-items: center;
gap: 12px;
min-width: 150px;
}
.filtersLabel strong {
display: block;
font-size: 18px;
font-weight: 500;
}
.filtersLabel span {
font-size: 12px;
color: var(--color-soft);
}
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
gap: 12px;
}
.filters input {
width: 100%;
border: 1px solid #e4e4e4;
border-radius: 4px;
padding: 12px 14px;
background: var(--color-white);
font: inherit;
font-size: 13px;
}
.filterBtn {
height: 44px;
padding: 0 22px;
font-size: 14px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-soft);
}
.filterBtn img {
width: 10px;
height: 14px;
transition: filter 0.3s ease, opacity 0.3s ease;
}
:global(.filter-chip).filterBtn:hover,
:global(.filter-chip).filterBtn:focus-visible {
color: var(--color-white);
}
:global(.filter-chip).filterBtn:hover img,
:global(.filter-chip).filterBtn:focus-visible img {
filter: invert(1);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 18px 0 28px;
}
.tag {
gap: 8px;
padding: 8px 12px;
font-size: 12px;
letter-spacing: 1px;
}
.tag button {
position: relative;
z-index: 2;
font-size: 14px;
line-height: 1;
color: var(--color-soft);
transition: color 0.3s ease;
}
:global(.filter-chip).tag:hover,
:global(.filter-chip).tag:focus-within {
color: var(--color-white);
}
:global(.filter-chip).tag:hover button,
:global(.filter-chip).tag:focus-within button {
color: var(--color-white);
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.pager {
margin: 36px auto 56px;
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.pager a,
.pager span {
min-width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 13px;
color: var(--color-soft);
}
.pagerActive {
background: var(--color-card);
color: var(--color-black) !important;
}
.featureSection {
max-width: var(--container-width);
margin: 0 auto;
width: 100%;
}
.feature {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
align-items: center;
padding: 48px 0 64px;
}
.featureTitle {
font-size: 36px;
font-weight: 400;
letter-spacing: 0;
text-transform: uppercase;
line-height: 1.1;
}
.featureLead {
margin-top: 6px;
font-size: 22px;
font-weight: 400;
color: var(--color-soft);
}
.featureBody {
margin-top: 16px;
font-size: 15px;
font-weight: 400;
line-height: 1.4;
color: var(--color-body);
max-width: none;
}
.featureList {
list-style: none;
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.featureList li {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 15px;
color: var(--color-body);
}
.featureList img {
width: 9px;
height: 11px;
margin-top: 6px;
flex-shrink: 0;
}
.featureCta {
margin-top: 22px;
height: 34px;
padding: 0 16px;
font-size: 13px;
letter-spacing: 2.4px;
}
.featureVisual {
position: relative;
background: var(--color-card);
border-radius: 10px;
min-height: 360px;
aspect-ratio: 1 / 1.05;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 28px;
}
.featureVisual img {
width: auto;
height: auto;
max-width: 72%;
max-height: 86%;
object-fit: contain;
}
.exportSection {
margin: 0 0 72px;
}
.exportCta {
background: var(--color-card);
border-radius: 5px;
height: min(201px, calc((min(100vw, 1920px) - 2 * var(--page-pad)) * 201 / 1800));
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 40px 0 22%;
position: relative;
overflow: hidden;
}
.plane {
position: absolute;
left: 1.5%;
top: 50%;
width: min(240px, 16%);
height: auto;
max-height: 88%;
object-fit: contain;
transform: translateY(-52%) rotate(38deg);
transform-origin: center;
pointer-events: none;
}
.exportTitle {
font-size: clamp(28px, 4vw, 48px);
font-weight: 300;
letter-spacing: 8px;
text-transform: uppercase;
line-height: 1.05;
}
.beginBtn {
height: 40px;
padding: 0 18px;
gap: 10px;
font-size: 13px;
letter-spacing: 3px;
}
.beginBtn img {
width: 9px;
height: 16px;
}
@media (max-width: 1200px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filtersBar {
grid-template-columns: 1fr;
}
.filters {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 980px) {
.feature {
grid-template-columns: 1fr;
}
.exportCta {
flex-direction: column;
justify-content: center;
text-align: center;
height: auto;
min-height: 160px;
padding: 28px 20px 24px;
}
.plane {
position: relative;
left: auto;
top: auto;
width: min(180px, 46%);
max-height: 120px;
transform: rotate(38deg);
margin-bottom: 8px;
}
}
@media (max-width: 720px) {
.grid,
.filters {
grid-template-columns: 1fr;
}
.bannerTitle {
letter-spacing: 6px;
}
}
+162
View File
@@ -0,0 +1,162 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import ProducerCard from "@/components/ProducerCard";
import { aboutBullets, featuredProducers } from "@/data/home";
import {
activeFilterTags,
breadcrumbs,
businessFilters,
} from "@/data/pages";
import styles from "./businesses.module.css";
export const metadata = {
title: "Businesses | IranBay",
};
export default function BusinessesPage() {
const cards = [
...featuredProducers,
...featuredProducers,
...featuredProducers,
...featuredProducers,
].slice(0, 16);
return (
<div className={styles.page}>
<Header />
<main className={styles.shell}>
<section className={styles.bannerWrap}>
<div className={styles.banner}>
<h1 className={styles.bannerTitle}>Let’s explore</h1>
<nav className={styles.crumbs} aria-label="Breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb}>
{index > 0 ? (
<img
className={styles.crumbChevron}
src="/images/icons/chevron.svg"
alt=""
/>
) : null}
<span
className={
index === breadcrumbs.length - 1 ? styles.crumbActive : undefined
}
>
{crumb}
</span>
</span>
))}
</nav>
</div>
</section>
<section className={styles.container}>
<div className={styles.filtersBar}>
<div className={styles.filtersLabel}>
<img src="/images/icons/search.svg" alt="" width={22} height={22} />
<div>
<strong>Filters</strong>
<span>A better focus</span>
</div>
</div>
<div className={styles.filters}>
{businessFilters.map((filter) => (
<input
key={filter.label}
aria-label={filter.label}
defaultValue={filter.value}
/>
))}
<button className={`filter-chip ${styles.filterBtn}`} type="button">
Filter
<img src="/images/icons/chevron.svg" alt="" />
</button>
</div>
</div>
<div className={styles.tags}>
{activeFilterTags.map((tag) => (
<span className={`filter-chip ${styles.tag}`} key={tag}>
{tag}
<button type="button" aria-label={`Remove ${tag}`}>
×
</button>
</span>
))}
</div>
<div className={styles.grid}>
{cards.map((producer, index) => (
<ProducerCard
key={`${producer.name}-${index}`}
producer={producer}
/>
))}
</div>
<nav className={styles.pager} aria-label="Pagination">
<a href="/businesses">First</a>
<a href="/businesses">1</a>
<span className={styles.pagerActive}>2</span>
<a href="/businesses">3</a>
<span>…</span>
<a href="/businesses">15</a>
<a href="/businesses">Last</a>
</nav>
</section>
<section className={styles.featureSection}>
<div className={styles.feature}>
<div>
<h2 className={styles.featureTitle}>Processed foods,</h2>
<p className={styles.featureLead}>
We are a gate to Iran&apos;s market place
</p>
<p className={styles.featureBody}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
</p>
<ul className={styles.featureList}>
{aboutBullets.map((item, index) => (
<li key={`${item}-${index}`}>
<img src="/images/icons/bullet.svg" alt="" />
<span>{item}</span>
</li>
))}
</ul>
<a className={`outline-btn ${styles.featureCta}`} href="/about">
know more about us
</a>
</div>
<div className={styles.featureVisual}>
<img src="/images/hero/ice-cream.png" alt="Processed foods" />
</div>
</div>
</section>
<section className={styles.exportSection}>
<div className={`tile-hover ${styles.exportCta}`}>
<img
className={styles.plane}
src="/images/banners/plane.png"
alt=""
/>
<h2 className={styles.exportTitle}>
let’s
<br />
export
</h2>
<a className={`outline-btn ${styles.beginBtn}`} href="/contact">
begin
<img src="/images/icons/chevron.svg" alt="" width={9} height={16} />
</a>
</div>
</section>
</main>
<Footer />
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
.page {
background: var(--color-white);
}
.bannerWrap {
max-width: 1920px;
margin: 0 auto;
padding: 28px var(--page-pad) 0;
}
.banner {
position: relative;
isolation: isolate;
overflow: hidden;
background: #f6f6f6;
border-radius: 5px;
min-height: 136px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 28px 20px;
}
.banner::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.7;
background-image: var(--page-title-pattern);
}
.bannerTitle,
.bannerSub {
position: relative;
z-index: 1;
}
.bannerTitle {
font-size: clamp(24px, 2.2vw, 35px);
font-weight: 400;
letter-spacing: 14px;
text-transform: uppercase;
}
.bannerSub {
margin-top: 8px;
font-size: 12px;
font-weight: 300;
letter-spacing: 4.02px;
text-transform: uppercase;
color: var(--color-soft);
}
.container {
max-width: 1552px;
margin: 0 auto;
padding: 0 var(--page-pad);
}
.panel {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(240px, 0.85fr);
gap: 64px;
padding: 56px 0 40px;
position: relative;
}
.panel::after {
content: "";
position: absolute;
top: 56px;
bottom: 40px;
left: 58%;
width: 2px;
background: var(--color-card);
}
.heading {
font-size: clamp(36px, 3.4vw, 55px);
font-weight: 700;
margin-bottom: 28px;
}
.sendLabel {
font-size: clamp(22px, 2vw, 35px);
font-weight: 400;
letter-spacing: 7px;
text-transform: uppercase;
margin-bottom: 10px;
}
.sendHint {
font-size: 12px;
font-weight: 300;
letter-spacing: 2.4px;
text-transform: uppercase;
color: #4b4b4b;
line-height: 1.5;
margin-bottom: 28px;
max-width: 520px;
}
.form {
display: grid;
gap: 18px;
max-width: 761px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.field {
position: relative;
display: block;
}
.field span {
position: absolute;
top: -8px;
left: 16px;
z-index: 1;
background: var(--color-white);
padding: 0 6px;
font-size: 12px;
font-weight: 300;
color: #aaa;
transition: color 0.25s ease;
}
.field input,
.field select,
.field textarea {
width: 100%;
border: 1px solid #eee;
border-radius: 5px;
background: var(--color-white);
padding: 14px 18px;
font: inherit;
font-size: 16px;
font-weight: 300;
color: var(--color-black);
outline: 0;
transition: border-color 0.25s ease;
}
.field:focus-within span {
color: var(--color-black);
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--color-black);
}
.field select {
appearance: none;
background-image: url("/images/icons/chevron.svg");
background-repeat: no-repeat;
background-position: right 18px center;
background-size: 8px 14px;
padding-right: 36px;
}
.plain span {
display: none;
}
.field textarea {
min-height: 231px;
resize: vertical;
}
.formActions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.formActions .outline-btn,
.formActions button {
height: 29px;
min-width: 207px;
font-size: 12px;
letter-spacing: 2.4px;
}
.aside {
display: flex;
flex-direction: column;
gap: 36px;
padding-top: 8px;
}
.asideBlock h3 {
font-size: clamp(24px, 2vw, 35px);
font-weight: 600;
margin-bottom: 12px;
}
.asideBlock p,
.asideBlock a {
font-size: clamp(16px, 1.4vw, 25px);
font-weight: 300;
color: #4b4b4b;
line-height: 1.35;
}
.asideBlock p {
font-size: 18px;
}
.phones {
display: flex;
flex-direction: column;
gap: 2px;
}
.socials {
display: flex;
gap: 12px;
align-items: center;
}
.socials img {
width: 26px;
height: 26px;
object-fit: contain;
}
.mapWrap {
padding: 12px 0 56px;
}
.mapWrap img {
width: 100%;
height: clamp(220px, 32vw, 360px);
object-fit: cover;
border-radius: 4px;
}
.faq {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.3fr);
gap: 48px;
padding: 12px 0 96px;
}
.faqKicker {
font-size: 20px;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 12px;
}
.faqTitle {
font-size: clamp(34px, 3.4vw, 55px);
font-weight: 700;
line-height: 1.1;
}
.faqList {
display: flex;
flex-direction: column;
gap: 8px;
}
.faqItem {
padding: 12px 0 18px;
}
.faqTrigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
text-align: left;
font-size: clamp(20px, 1.8vw, 30px);
font-weight: 500;
}
.faqOpen {
transform: rotate(-90deg);
}
.faqClosed {
transform: rotate(90deg);
}
.faqAnswer {
margin-top: 14px;
font-size: 18px;
line-height: 1.35;
color: #939393;
max-width: 703px;
}
@media (max-width: 1100px) {
.panel::after {
display: none;
}
.panel,
.faq {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
}
.bannerTitle {
letter-spacing: 6px;
}
}
+137
View File
@@ -0,0 +1,137 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import ContactFaq from "@/components/ContactFaq";
import { contactInfo } from "@/data/pages";
import styles from "./contact.module.css";
export const metadata = {
title: "Contact Us | IranBay",
};
export default function ContactPage() {
return (
<div className={styles.page}>
<Header />
<main>
<section className={styles.bannerWrap}>
<div className={styles.banner}>
<h1 className={styles.bannerTitle}>Contact us page</h1>
<p className={styles.bannerSub}>
some of our most crowded categories
</p>
</div>
</section>
<section className={styles.container}>
<div className={styles.panel}>
<div>
<h2 className={styles.heading}>Get In Touch</h2>
<p className={styles.sendLabel}>Send message</p>
<p className={styles.sendHint}>
In publishing and graphic design, Lorem ipsum is a placeholder
text commonly used to.
</p>
<form className={styles.form}>
<div className={styles.row}>
<label className={styles.field}>
<span>Name</span>
<input name="name" defaultValue="Arad Charta" />
</label>
<label className={styles.field}>
<span>Family</span>
<input name="family" defaultValue="Arad Charta" />
</label>
</div>
<div className={styles.row}>
<label className={styles.field}>
<span>Interested in</span>
<select name="interest" defaultValue="Developer">
<option>Developer</option>
<option>Producer</option>
<option>Store</option>
<option>Service</option>
</select>
</label>
<label className={`${styles.field} ${styles.plain}`}>
<input
name="phone"
placeholder="Phone Number"
aria-label="Phone Number"
/>
</label>
</div>
<label className={styles.field}>
<span>Message</span>
<textarea name="message" placeholder="Description" />
</label>
<div className={styles.formActions}>
<button className="outline-btn" type="submit">
Submit message
</button>
</div>
</form>
</div>
<aside className={styles.aside}>
<div className={styles.asideBlock}>
<h3>Email Us :</h3>
<a href={`mailto:${contactInfo.email}`}>{contactInfo.email}</a>
</div>
<div className={styles.asideBlock}>
<h3>Call Us :</h3>
<div className={styles.phones}>
{contactInfo.phones.map((phone) => (
<p key={phone}>{phone}</p>
))}
</div>
</div>
<div className={styles.asideBlock}>
<h3>Follow :</h3>
<div className={styles.socials}>
<a href="https://instagram.com" aria-label="Instagram">
<img src="/images/icons/instagram.png" alt="" />
</a>
<a href="https://t.me" aria-label="Telegram">
<img src="/images/icons/telegram.png" alt="" />
</a>
<a href="https://facebook.com" aria-label="Facebook">
<img src="/images/icons/facebook.png" alt="" />
</a>
</div>
</div>
<div className={styles.asideBlock}>
<h3>Visit Us :</h3>
{contactInfo.addressLines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
</aside>
</div>
</section>
<section className={styles.container}>
<div className={styles.mapWrap}>
<img src="/images/contact/map.png" alt="Office location map" />
</div>
</section>
<section className={styles.container}>
<div className={styles.faq}>
<div>
<p className={styles.faqKicker}>FAQ</p>
<h2 className={styles.faqTitle}>
Frequently
<br />
asked
<br />
questions.
</h2>
</div>
<ContactFaq />
</div>
</section>
</main>
<Footer />
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+274
View File
@@ -0,0 +1,274 @@
:root {
--font-montserrat: "Montserrat", sans-serif;
--color-black: #000;
--color-white: #fff;
--color-bg: #ffffff;
--color-card: #eeeeee;
--color-muted: #4a4a4a;
--color-soft: #8e8e8e;
--color-body: #aaaaaa;
--color-dark: #1e1e1e;
--color-explore: #f9f9f9;
--color-footer: #070707;
--color-footer-text: #bababa;
--color-footer-line: #232323;
--page-pad: 60px;
--max-width: 1920px;
--container-width: 1080px;
--page-title-pattern:
repeating-linear-gradient(
135deg,
rgba(0, 0, 0, 0.045) 0 1px,
transparent 1px 16px
),
repeating-linear-gradient(
45deg,
rgba(0, 0, 0, 0.03) 0 1px,
transparent 1px 16px
);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
height: 100%;
scroll-behavior: smooth;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
color: var(--color-black);
background: var(--color-bg);
font-family: var(--font-montserrat), "Montserrat", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img {
display: block;
max-width: 100%;
}
button,
a {
font-family: inherit;
color: inherit;
}
a {
text-decoration: none;
color: inherit;
}
button {
background: none;
border: none;
cursor: pointer;
}
.icon {
display: block;
object-fit: contain;
}
.outline-btn {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0.5px solid var(--color-black);
border-radius: 3px;
background: transparent;
color: var(--color-black);
font-weight: 300;
text-transform: uppercase;
letter-spacing: 0.2em;
white-space: nowrap;
transition: color 0.3s ease, border-color 0.3s ease;
}
.outline-btn:hover,
.outline-btn:focus-visible {
color: var(--color-white);
background: transparent;
}
.outline-btn:hover :is(span, strong, em, p, a, label, button),
.outline-btn:focus-visible :is(span, strong, em, p, a, label, button) {
color: var(--color-white);
}
.filter-chip {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 33px;
padding: 6px 22px;
border: 0;
border-radius: 5px;
background: #f6f6f6;
color: var(--color-black);
font: inherit;
font-size: 18px;
font-weight: 300;
white-space: nowrap;
cursor: pointer;
transition: color 0.3s ease, background 0.3s ease, border-color 0.3s ease;
}
.filter-chip:hover,
.filter-chip:focus-visible {
color: var(--color-white);
}
.filter-chip:hover :is(span, strong, em, p, a, label, button),
.filter-chip:focus-visible :is(span, strong, em, p, a, label, button) {
color: var(--color-white);
}
.filter-chip.is-active {
background: var(--color-black);
color: var(--color-white);
}
.filter-chip.is-active:hover,
.filter-chip.is-active:focus-visible {
color: var(--color-black);
}
.filter-chip.is-active:hover :is(span, strong, em, p, a, label, button),
.filter-chip.is-active:focus-visible :is(span, strong, em, p, a, label, button) {
color: var(--color-black);
}
.tile-hover,
.outline-btn,
.filter-chip {
position: relative;
isolation: isolate;
}
.tile-hover::before,
.outline-btn::before,
.filter-chip::before {
content: "";
position: absolute;
inset: 0;
background: var(--color-black);
clip-path: inset(100% 0 0 0);
transition: clip-path 0.45s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
border-radius: inherit;
}
/* Tiles: wipe sits above bare text; children are raised below. */
.tile-hover::before {
z-index: 0;
}
/*
Buttons/chips often use direct text nodes (no child wrappers).
Keep the wipe behind the label so white text stays visible.
*/
.outline-btn::before,
.filter-chip::before {
z-index: -1;
}
.filter-chip.is-active::before {
background: var(--color-white);
}
.tile-hover:hover::before,
.tile-hover:focus-within::before,
.outline-btn:hover::before,
.outline-btn:focus-visible::before,
.filter-chip:hover::before,
.filter-chip:focus-visible::before {
clip-path: inset(0 0 0 0);
}
.tile-hover > *:not(img),
.outline-btn > *,
.filter-chip > * {
position: relative;
z-index: 2;
}
.tile-hover:hover,
.tile-hover:focus-within,
.tile-hover:hover :is(h1, h2, h3, p, span, strong, a),
.tile-hover:focus-within :is(h1, h2, h3, p, span, strong, a) {
color: var(--color-white);
}
.tile-hover :is(h1, h2, h3, p, span, strong, a) {
transition: color 0.3s ease, border-color 0.3s ease;
}
.tile-hover:hover .outline-btn,
.tile-hover:hover .outline-btn:hover,
.tile-hover:focus-within .outline-btn {
color: var(--color-white);
border-color: var(--color-white);
background: transparent;
}
.tile-hover:hover .outline-btn::before,
.tile-hover:focus-within .outline-btn::before {
clip-path: inset(100% 0 0 0);
}
.outline-btn:hover img,
.outline-btn:focus-visible img,
.filter-chip:hover img,
.filter-chip:focus-visible img {
position: relative;
z-index: 2;
}
@media (prefers-reduced-motion: reduce) {
.tile-hover::before,
.outline-btn::before,
.filter-chip::before {
transition: none;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1100px) {
:root {
--page-pad: 32px;
}
}
@media (max-width: 720px) {
:root {
--page-pad: 20px;
}
}
+815
View File
@@ -0,0 +1,815 @@
.page {
background: var(--color-white);
}
.section {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--page-pad);
scroll-margin-top: 80px;
}
.container {
max-width: 1080px;
}
.container + .container,
.section + .container {
margin-top: 96px;
}
.carousel {
margin-top: 96px;
padding-left: 0;
padding-right: 0;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.95fr) minmax(220px, 300px) minmax(220px, 300px);
gap: 7px;
align-items: stretch;
}
.heroCard {
position: relative;
background: var(--color-card);
border-radius: 5px;
min-height: 526px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 28px 24px 28px;
}
.heroMain {
padding: 0 72px 48px 102px;
}
.heroCopy {
position: relative;
z-index: 2;
max-width: 430px;
}
.heroTitle {
font-size: 40px;
font-weight: 300;
letter-spacing: 8px;
text-transform: uppercase;
line-height: 45px;
margin-bottom: 28px;
}
.heroTitleSmall {
font-size: 25px;
letter-spacing: 5px;
line-height: 30px;
margin-bottom: 16px;
}
.heroBtn {
height: 37px;
padding: 0 20px;
font-size: 16px;
letter-spacing: 3.2px;
}
.heroBtnSmall {
height: 29px;
padding: 0 16px;
font-size: 12px;
letter-spacing: 2.4px;
}
.heroImage {
position: absolute;
pointer-events: none;
}
.heroFurniture {
width: min(513px, 52%);
height: auto;
right: 4%;
top: 0;
mix-blend-mode: multiply;
}
.heroCard:hover .heroFurniture,
.heroCard:focus-within .heroFurniture {
mix-blend-mode: normal;
}
.heroCard:hover .heroNav img,
.heroCard:focus-within .heroNav img {
filter: invert(1);
}
.heroIceCream {
width: 84%;
height: auto;
left: 8%;
top: 14%;
}
.heroRug {
width: 81%;
height: auto;
left: 10%;
top: 16%;
}
/* Beat globals `.tile-hover > *:not(img) { position: relative }` so arrows stay overlaid */
.heroCard .heroNav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 29px;
height: 29px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
}
.heroCard .heroNav img {
width: 11px;
height: 22px;
object-fit: contain;
}
.heroCard .heroPrev {
left: 16px;
transform: translateY(-50%) rotate(180deg);
}
.heroCard .heroNext {
right: 16px;
}
.join {
margin-top: 7px;
background: var(--color-card);
border-radius: 5px;
min-height: 136px;
display: flex;
align-items: center;
justify-content: center;
gap: 23px;
padding: 24px;
}
.joinIcon {
position: relative;
z-index: 2;
width: 62px;
height: 62px;
object-fit: contain;
}
.join:hover .joinIcon,
.join:focus-within .joinIcon {
filter: invert(1);
}
.joinCopy {
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
z-index: 1;
}
.joinTitle {
display: block;
font-size: 30px;
font-weight: 300;
letter-spacing: 6px;
text-transform: uppercase;
line-height: 30px;
}
.joinText {
display: block;
margin-top: 5px;
font-size: 14px;
font-weight: 300;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--color-muted);
line-height: 18px;
}
.sectionHead {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
max-width: var(--container-width);
width: 100%;
margin: 0 auto 28px;
padding: 0 var(--page-pad);
}
.sectionHeadCentered {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 0 36px;
}
.arrows {
display: flex;
gap: 6px;
}
.arrowBtn {
width: 38px;
height: 36px;
background: var(--color-card);
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.arrowBtn img {
width: 8px;
height: 14px;
object-fit: contain;
position: relative;
z-index: 2;
transition: filter 0.3s ease;
}
.arrowBtn:global(.tile-hover):hover img,
.arrowBtn:global(.tile-hover):focus-visible img {
filter: brightness(0) invert(1);
}
.arrowPrev {
transform: rotate(180deg);
}
.sectionTitles {
text-align: center;
}
.sectionTitle {
font-size: 20px;
font-weight: 400;
letter-spacing: 6px;
text-transform: uppercase;
white-space: nowrap;
}
.sectionSubtitle {
margin-top: 6px;
font-size: 11px;
font-weight: 300;
letter-spacing: 3.2px;
text-transform: uppercase;
}
.explore {
height: 36px;
min-width: 180px;
background: var(--color-explore);
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
font-size: 13px;
font-weight: 300;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--color-soft);
overflow: hidden;
}
.explore img {
width: 16px;
height: 16px;
position: relative;
z-index: 2;
transition: filter 0.3s ease;
}
.explore:global(.tile-hover):hover img,
.explore:global(.tile-hover):focus-visible img {
filter: brightness(0) invert(1);
}
.categoryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 28px 20px;
}
.categoryCard {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding-top: 38%;
overflow: visible;
}
.categoryStage {
width: 100%;
aspect-ratio: 430 / 187;
background: var(--color-card);
border-radius: 5px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 0 8px 10px;
overflow: visible;
}
.categoryImage {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 58%;
height: 120%;
bottom: 36%;
object-fit: contain;
pointer-events: none;
}
.industry {
width: 59%;
height: 135%;
bottom: 33%;
}
.food {
width: 50%;
height: 114%;
bottom: 45%;
}
.fruits {
width: 63%;
height: 144%;
bottom: 33%;
}
.clothes {
width: 58%;
height: 148%;
bottom: 32%;
}
.chair {
width: 43%;
height: 137%;
bottom: 45%;
}
.shoes {
width: 79%;
height: 96%;
bottom: 44%;
}
.washer {
width: 58%;
height: 133%;
bottom: 44%;
}
.services {
width: 69%;
height: 159%;
bottom: 26%;
}
.categoryTitle {
position: relative;
z-index: 1;
font-size: 12px;
font-weight: 400;
letter-spacing: 3px;
text-transform: uppercase;
line-height: 1.2;
}
.categoryCopy {
position: relative;
z-index: 1;
margin-top: 4px;
font-size: 10px;
font-weight: 300;
color: var(--color-muted);
}
.producerGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 36px;
align-items: start;
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--page-pad);
}
.about {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
align-items: center;
padding: 48px 0 8px;
overflow: visible;
}
.aboutKicker {
font-size: 36px;
font-weight: 400;
text-transform: uppercase;
line-height: 1.1;
}
.aboutKicker span {
font-size: 24px;
text-transform: none;
}
.aboutLead {
margin-top: 6px;
font-size: 22px;
font-weight: 400;
color: var(--color-soft);
}
.aboutBody {
margin-top: 16px;
font-size: 15px;
font-weight: 400;
color: var(--color-body);
line-height: 1.4;
}
.aboutList {
list-style: none;
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.aboutList li {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 15px;
color: var(--color-body);
}
.aboutList img {
width: 9px;
height: 11px;
margin-top: 6px;
flex-shrink: 0;
}
.aboutBtn {
margin-top: 22px;
height: 34px;
padding: 0 16px;
font-size: 13px;
letter-spacing: 2.4px;
}
.aboutVisual {
position: relative;
width: 100%;
aspect-ratio: 837 / 639;
overflow: visible;
}
.aboutFrame {
position: absolute;
left: 0;
bottom: 0;
width: 58.8%;
height: 86%;
background: var(--color-card);
border-radius: 10px;
}
.aboutShip {
position: absolute;
top: 0;
right: 0;
width: 94.5%;
height: 100%;
object-fit: cover;
object-position: 55% 35%;
}
.bannerBlock {
margin-top: 96px;
overflow: visible;
padding-bottom: clamp(40px, 7vw, 110px);
}
.bannerRow {
display: grid;
grid-template-columns: minmax(0, 2.92fr) repeat(3, minmax(0, 1fr));
gap: 7px;
align-items: end;
padding-top: 96px;
}
.bannerCard {
position: relative;
background: var(--color-card);
border-radius: 5px;
min-height: 360px;
padding: 24px 18px 24px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 14px;
overflow: visible;
}
.bannerWide {
min-height: 360px;
padding-left: 48px;
}
.bannerImage {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: min(280px, 56%);
bottom: 118px;
object-fit: contain;
pointer-events: none;
}
.tv {
width: min(320px, 48%);
bottom: 130px;
left: 42%;
}
.pistachio {
width: 130%;
max-width: 260px;
bottom: 108px;
transform: translateX(-50%) rotate(18deg);
}
.shampoo {
width: 58%;
height: auto;
bottom: 115px;
}
.saffron {
width: 54%;
bottom: 115px;
}
.bannerTitle {
position: relative;
z-index: 1;
font-size: 18px;
font-weight: 300;
letter-spacing: 3.5px;
text-transform: uppercase;
line-height: 1.2;
margin: 0;
}
.bannerWide .bannerTitle {
font-size: 32px;
letter-spacing: 6px;
line-height: 1.15;
}
.bannerBtn {
position: relative;
z-index: 1;
align-self: flex-start;
height: 29px;
padding: 0 14px;
font-size: 12px;
letter-spacing: 2.2px;
}
.bannerBtnWide {
height: 34px;
padding: 0 18px;
font-size: 14px;
letter-spacing: 2.4px;
}
.exportCta {
position: relative;
width: 100%;
margin-top: 7px;
background: var(--color-card);
border-radius: 5px;
height: min(201px, calc((min(100vw, var(--max-width)) - 2 * var(--page-pad)) * 201 / 1800));
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 40px 0 22%;
overflow: visible;
}
.plane {
position: absolute;
left: 0;
top: 50%;
width: min(220px, 13.5%);
height: auto;
max-width: none;
object-fit: contain;
transform: translate(-6%, -58%) rotate(42deg);
transform-origin: center;
pointer-events: none;
}
.exportTitle {
position: relative;
z-index: 2;
font-size: 32px;
font-weight: 300;
letter-spacing: 6px;
text-transform: uppercase;
line-height: 1.15;
}
.beginBtn {
position: relative;
z-index: 2;
height: 34px;
min-width: 320px;
padding: 0 16px;
font-size: 14px;
letter-spacing: 2.8px;
justify-content: space-between;
}
.beginBtn img {
width: 9px;
height: 16px;
object-fit: contain;
}
.exportCta:hover .beginBtn img,
.exportCta:focus-within .beginBtn img {
filter: invert(1);
}
@media (max-width: 1400px) {
.sectionTitle {
letter-spacing: 4px;
font-size: 18px;
}
.heroTitle {
font-size: 28px;
letter-spacing: 5px;
line-height: 1.15;
}
.aboutKicker {
font-size: 30px;
}
}
@media (max-width: 1100px) {
.hero,
.bannerRow {
grid-template-columns: 1fr;
}
.heroCard {
min-height: 420px;
}
.heroMain {
padding: 48px 24px 32px;
}
.heroFurniture,
.heroIceCream,
.heroRug {
width: min(280px, 46%);
}
.categoryGrid,
.producerGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.about {
grid-template-columns: 1fr;
margin-top: 0;
}
.aboutVisual {
min-height: unset;
max-width: 420px;
margin: 0 auto;
order: -1;
}
.sectionHead {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.exportCta {
flex-direction: column;
justify-content: center;
text-align: center;
padding: 24px 20px 24px;
height: auto;
min-height: 140px;
}
.plane {
left: 50%;
top: -8%;
width: min(180px, 42%);
transform: translate(-50%, -40%) rotate(42deg);
}
.beginBtn {
width: 100%;
min-width: 0;
}
}
@media (max-width: 720px) {
.heroCard {
min-height: 360px;
padding: 20px;
}
.heroMain {
padding: 32px 20px 24px;
}
.heroTitle,
.joinTitle,
.aboutKicker {
font-size: 24px;
letter-spacing: 3px;
line-height: 1.2;
}
.sectionTitle {
font-size: 15px;
letter-spacing: 2.5px;
white-space: normal;
}
.heroFurniture,
.heroIceCream,
.heroRug {
width: 42%;
opacity: 0.95;
}
.join {
flex-direction: column;
text-align: center;
}
.joinCopy {
align-items: center;
}
.categoryGrid,
.producerGrid {
grid-template-columns: 1fr;
}
.explore {
width: 100%;
min-width: 0;
}
.categoryCard {
padding-top: 28%;
max-width: 280px;
margin: 0 auto;
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import "./globals.css";
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
variable: "--font-montserrat",
display: "swap",
});
export const metadata: Metadata = {
title: "IranBay | A gate to iran market",
description: "IranBay homepage — a gate to Iran’s marketplace.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" dir="ltr" className={montserrat.variable}>
<body className={montserrat.className}>{children}</body>
</html>
);
}
+451
View File
@@ -0,0 +1,451 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import ProducerCard from "@/components/ProducerCard";
import {
aboutBullets,
featuredCategories,
featuredProducers,
type Producer,
} from "@/data/home";
import {
getWebsiteHomeMedia,
type WebsiteMediaItem,
} from "@/lib/website-media";
import {
carouselBusinessToProducer,
getWebsiteCarousels,
type WebsiteCarousel,
} from "@/lib/website-carousels";
import styles from "./home.module.css";
function SectionHeading({
title,
subtitle,
exploreHref,
}: {
title: string;
subtitle?: string;
exploreHref: string;
}) {
return (
<div className={styles.sectionHead}>
<div className={styles.arrows}>
<button
className={`tile-hover ${styles.arrowBtn}`}
type="button"
aria-label="Previous"
>
<img
className={styles.arrowPrev}
src="/images/icons/chevron.svg"
alt=""
width={8}
height={14}
/>
</button>
<button
className={`tile-hover ${styles.arrowBtn}`}
type="button"
aria-label="Next"
>
<img src="/images/icons/chevron.svg" alt="" width={8} height={14} />
</button>
</div>
<div className={styles.sectionTitles}>
<h2 className={styles.sectionTitle}>{title}</h2>
<p className={styles.sectionSubtitle}>
{subtitle || "some of our most crowded categories"}
</p>
</div>
<a className={`tile-hover ${styles.explore}`} href={exploreHref}>
Explore all
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
</a>
</div>
);
}
function BusinessCarouselSection({
carousel,
fallbackProducers,
}: {
carousel?: WebsiteCarousel;
fallbackProducers?: Producer[];
}) {
if (!carousel) return null;
const producers =
carousel.businesses.length > 0
? carousel.businesses.map(carouselBusinessToProducer)
: (fallbackProducers ?? []);
if (!producers.length) return null;
return (
<section
className={`${styles.section} ${styles.carousel}`}
id={carousel.slug || carousel.id}
>
<SectionHeading
title={carousel.title}
subtitle={carousel.subtext || undefined}
exploreHref="/businesses"
/>
<div className={styles.producerGrid}>
{producers.map((producer) => (
<ProducerCard
key={`${carousel.id}-${producer.slug}`}
producer={producer}
/>
))}
</div>
</section>
);
}
function TitleLines({ text }: { text: string }) {
const lines = text.includes("\n")
? text.split("\n")
: (() => {
const parts = text.trim().split(/\s+/);
if (parts.length <= 1) return [text];
return [parts[0], parts.slice(1).join(" ")];
})();
return (
<>
{lines.map((line, index) => (
<span key={`${line}-${index}`}>
{index > 0 ? <br /> : null}
{line}
</span>
))}
</>
);
}
function fallbackMedia() {
return {
slider: [
{
id: "slider-1",
imageUrl: "/images/hero/furniture.png",
linkUrl: "#producers",
title: "Iranian modern art",
cta: "browse furniture producers",
},
] satisfies WebsiteMediaItem[],
standingBanners: [
{
id: "standing-1",
imageUrl: "/images/hero/ice-cream.png",
linkUrl: "#producers",
title: "food industry",
cta: "browse producers",
},
{
id: "standing-2",
imageUrl: "/images/hero/rug.png",
linkUrl: "#producers",
title: "original & pure art",
cta: "browse producers",
},
] satisfies WebsiteMediaItem[],
categories: featuredCategories.map((category, index) => ({
id: `cat-${index + 1}`,
imageUrl: category.image,
linkUrl: "#producers",
title: category.title,
subtitle: category.description,
})),
menuImages: [],
aboutUs: {
id: "about-1",
imageUrl: "/images/banners/ship.png",
linkUrl: "/about",
} satisfies WebsiteMediaItem,
banners: [
{
id: "banner-1",
imageUrl: "/images/banners/tv.png",
linkUrl: "#producers",
title: "Iranian modern art",
cta: "browse furniture producers",
},
{
id: "banner-2",
imageUrl: "/images/banners/pistachio.png",
linkUrl: "#producers",
title: "food industry",
cta: "browse producers",
},
{
id: "banner-3",
imageUrl: "/images/banners/shampoo.png",
linkUrl: "#producers",
title: "food industry",
cta: "browse producers",
},
{
id: "banner-4",
imageUrl: "/images/banners/saffron.png",
linkUrl: "#producers",
title: "original & pure art",
cta: "browse producers",
},
{
id: "banner-5",
imageUrl: "/images/banners/plane.png",
linkUrl: "/register",
title: "let's export",
cta: "begin",
},
] satisfies WebsiteMediaItem[],
};
}
export default async function Home() {
const [mediaResult, carousels] = await Promise.all([
getWebsiteHomeMedia(),
getWebsiteCarousels(),
]);
const media = mediaResult ?? fallbackMedia();
const slide = media.slider[0];
const standing = media.standingBanners;
const collectionBanners = media.banners.slice(0, 4);
const exportBanner =
media.banners[4] ?? media.banners[media.banners.length - 1];
const [firstCarousel, secondCarousel, ...restCarousels] = carousels;
return (
<div className={styles.page} id="top">
<Header />
<main>
<section className={styles.section} aria-label="Featured collections">
<div className={styles.hero}>
<article className={`tile-hover ${styles.heroCard} ${styles.heroMain}`}>
<button
className={`${styles.heroNav} ${styles.heroPrev}`}
type="button"
aria-label="Previous slide"
>
<img src="/images/icons/chevron.svg" alt="" width={11} height={22} />
</button>
<div className={styles.heroCopy}>
<h1 className={styles.heroTitle}>
<TitleLines text={slide?.title || "Iranian modern art"} />
</h1>
<a
className={`outline-btn ${styles.heroBtn}`}
href={slide?.linkUrl || "#producers"}
>
{slide?.cta || "browse furniture producers"}
</a>
</div>
<img
className={`${styles.heroImage} ${styles.heroFurniture}`}
src={slide?.imageUrl || "/images/hero/furniture.png"}
alt={slide?.title || "Iranian modern furniture"}
/>
<button
className={`${styles.heroNav} ${styles.heroNext}`}
type="button"
aria-label="Next slide"
>
<img src="/images/icons/chevron.svg" alt="" width={11} height={22} />
</button>
</article>
{standing.slice(0, 2).map((banner, index) => (
<article key={banner.id} className={`tile-hover ${styles.heroCard}`}>
<img
className={`${styles.heroImage} ${
index === 0 ? styles.heroIceCream : styles.heroRug
}`}
src={banner.imageUrl}
alt={banner.title || ""}
/>
<h2 className={`${styles.heroTitle} ${styles.heroTitleSmall}`}>
<TitleLines text={banner.title || "Browse"} />
</h2>
<a
className={`outline-btn ${styles.heroBtnSmall}`}
href={banner.linkUrl || "#producers"}
>
{banner.cta || "browse producers"}
</a>
</article>
))}
</div>
</section>
<section className={styles.section} id="join">
<a href="/register" className={`tile-hover ${styles.join}`}>
<img
className={styles.joinIcon}
src="/images/icons/join-add.svg"
alt=""
width={62}
height={62}
/>
<span className={styles.joinCopy}>
<span className={styles.joinTitle}>Join our club</span>
<span className={styles.joinText}>
we will show your business to verity
<br />
of visitor around the world
</span>
</span>
</a>
</section>
<section className={`${styles.section} ${styles.container}`} id="categories">
<div className={styles.sectionHeadCentered}>
<h2 className={styles.sectionTitle}>Featured categories</h2>
<p className={styles.sectionSubtitle}>
some of our most crowded categories
</p>
</div>
<div className={styles.categoryGrid}>
{media.categories.map((category) => (
<a
key={category.id}
href={category.linkUrl || "#producers"}
className={styles.categoryCard}
>
<div className={`tile-hover ${styles.categoryStage}`}>
<img
className={styles.categoryImage}
src={category.imageUrl}
alt=""
/>
<h3 className={styles.categoryTitle}>
{category.title || "Category"}
</h3>
<p className={styles.categoryCopy}>
{category.subtitle || ""}
</p>
</div>
</a>
))}
</div>
</section>
<BusinessCarouselSection
carousel={firstCarousel}
fallbackProducers={featuredProducers}
/>
<section className={`${styles.section} ${styles.container}`} id="about">
<div className={styles.about}>
<div>
<h2 className={styles.aboutKicker}>
IranBay<span>,</span>
</h2>
<p className={styles.aboutLead}>
We are a gate to iran’s market place
</p>
<p className={styles.aboutBody}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
<ul className={styles.aboutList}>
{aboutBullets.map((item, index) => (
<li key={`${item}-${index}`}>
<img src="/images/icons/bullet.svg" alt="" width={11} height={11} />
<span>{item}</span>
</li>
))}
</ul>
<a
className={`outline-btn ${styles.aboutBtn}`}
href={media.aboutUs.linkUrl || "/about"}
>
know more about us
</a>
</div>
<div className={styles.aboutVisual}>
<div className={styles.aboutFrame} />
<img
className={styles.aboutShip}
src={media.aboutUs.imageUrl}
alt="Cargo ship entering the market"
/>
</div>
</div>
</section>
<BusinessCarouselSection
carousel={secondCarousel}
fallbackProducers={featuredProducers}
/>
<section className={`${styles.section} ${styles.bannerBlock}`} aria-label="Browse collections">
<div className={styles.bannerRow}>
{collectionBanners.map((banner, index) => (
<article
key={banner.id}
className={`tile-hover ${styles.bannerCard} ${
index === 0 ? styles.bannerWide : ""
}`}
>
<img
className={styles.bannerImage}
src={banner.imageUrl}
alt={banner.title || ""}
/>
<h2 className={styles.bannerTitle}>
<TitleLines text={banner.title || "Browse"} />
</h2>
<a
className={`outline-btn ${styles.bannerBtn} ${
index === 0 ? styles.bannerBtnWide : ""
}`}
href={banner.linkUrl || "#producers"}
>
{banner.cta || "browse producers"}
</a>
</article>
))}
</div>
{exportBanner ? (
<div className={`tile-hover ${styles.exportCta}`}>
<img
className={styles.plane}
src={exportBanner.imageUrl}
alt=""
/>
<h2 className={styles.exportTitle}>
<TitleLines text={exportBanner.title || "let's export"} />
</h2>
<a
className={`outline-btn ${styles.beginBtn}`}
href={exportBanner.linkUrl || "/register"}
>
{exportBanner.cta || "begin"}
<img src="/images/icons/chevron.svg" alt="" width={9} height={16} />
</a>
</div>
) : null}
</section>
{restCarousels.map((carousel, index) => (
<BusinessCarouselSection
key={carousel.id}
carousel={carousel}
fallbackProducers={index === 0 ? featuredProducers : undefined}
/>
))}
</main>
<Footer />
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
.page {
background: var(--color-white);
}
.shell {
max-width: 1920px;
margin: 0 auto;
padding: 0 var(--page-pad);
}
+24
View File
@@ -0,0 +1,24 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import BusinessRegistration from "@/components/BusinessRegistration";
import RequireAuth from "@/components/RequireAuth";
import styles from "./page.module.css";
export const metadata = {
title: "Business registration | IranBay",
description: "Register your business on IranBay in three steps.",
};
export default function RegisterPage() {
return (
<div className={styles.page}>
<Header />
<main className={styles.shell}>
<RequireAuth returnPath="/register">
<BusinessRegistration />
</RequireAuth>
</main>
<Footer />
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
.page {
background: var(--color-white);
min-height: 100vh;
}
.container {
max-width: var(--container-width);
margin: 0 auto;
padding: 0 var(--page-pad);
}
.sectionTitle {
font-size: 26px;
font-weight: 400;
letter-spacing: 10px;
text-transform: uppercase;
text-align: center;
}
.sectionSubtitle {
margin-top: 6px;
font-size: 11px;
font-weight: 300;
letter-spacing: 3.2px;
text-transform: uppercase;
text-align: center;
color: var(--color-soft);
}
.outlineBtn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 18px;
border: 0.5px solid var(--color-black);
border-radius: 3px;
font-size: 12px;
font-weight: 300;
letter-spacing: 2.4px;
text-transform: uppercase;
}
.pageBanner {
background: var(--color-card);
padding: 48px var(--page-pad);
text-align: center;
margin-bottom: 48px;
}
.pageBannerTitle {
font-size: 28px;
font-weight: 400;
letter-spacing: 10px;
text-transform: uppercase;
}
.pageBannerSub {
margin-top: 8px;
font-size: 11px;
font-weight: 300;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--color-soft);
}
+19
View File
@@ -0,0 +1,19 @@
.body {
display: flex;
flex-direction: column;
gap: 18px;
font-size: 17px;
font-weight: 300;
line-height: 26px;
color: #9d9d9d;
text-align: justify;
}
.body a {
color: inherit;
text-decoration: underline;
}
.quote {
margin: 0;
}
+36
View File
@@ -0,0 +1,36 @@
import type { BlogPost } from "@/data/details";
import styles from "./ArticleBody.module.css";
export default function ArticleBody({
blocks,
}: {
blocks: BlogPost["body"];
}) {
return (
<div className={styles.body}>
{blocks.map((block, index) => {
if (block.type === "quote") {
return (
<p key={index} className={styles.quote}>
{block.text}
</p>
);
}
if (block.type === "rich") {
return (
<p key={index}>
{block.text}
{block.linkHref ? (
<a href={block.linkHref} target="_blank" rel="noreferrer">
{block.linkText}
</a>
) : null}
{block.afterLink}
</p>
);
}
return <p key={index}>{block.text}</p>;
})}
</div>
);
}
+806
View File
@@ -0,0 +1,806 @@
.root {
width: 100%;
padding-bottom: 80px;
}
.bannerWrap {
padding-top: 28px;
}
.banner {
position: relative;
isolation: isolate;
overflow: hidden;
background: #f6f6f6;
border-radius: 5px;
min-height: 136px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px 20px;
}
.banner::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.7;
background-image: var(--page-title-pattern);
}
.bannerTitle,
.crumbs {
position: relative;
z-index: 1;
}
.bannerTitle {
font-size: clamp(24px, 2.2vw, 35px);
font-weight: 400;
letter-spacing: 14px;
text-transform: uppercase;
}
.crumbs {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-soft);
}
.crumbs > span {
display: inline-flex;
align-items: center;
gap: 10px;
}
.crumbChevron {
width: 10px;
height: 10px;
opacity: 0.55;
}
.crumbActive {
color: var(--color-black);
}
.hero {
position: relative;
margin-top: 28px;
height: clamp(220px, 28vw, 408px);
border-radius: 0;
overflow: hidden;
}
.heroImg {
object-fit: cover;
object-position: center 35%;
}
.profile {
margin-top: -40px;
position: relative;
z-index: 2;
padding: 0 clamp(12px, 4vw, 72px);
}
.profileBar {
background: #f6f6f6;
border-radius: 5px;
min-height: 121px;
padding: 18px 22px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.identity {
display: flex;
align-items: center;
gap: 18px;
min-width: 240px;
}
.logo {
width: 75px;
height: 75px;
object-fit: cover;
border-radius: 4px;
background: #fff;
}
.name {
font-size: clamp(16px, 1.4vw, 24px);
font-weight: 400;
letter-spacing: 4.8px;
text-transform: uppercase;
}
.tagline {
margin-top: 4px;
font-size: 16px;
font-weight: 300;
color: var(--color-muted);
}
.stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: clamp(20px, 3vw, 48px);
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.statWide {
display: flex;
align-items: center;
gap: 10px;
}
.statLabel {
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #969696;
line-height: 20px;
}
.statLabelStack {
font-size: 14px;
line-height: 14px;
text-transform: uppercase;
color: #969696;
}
.statValueSm {
font-size: clamp(20px, 2vw, 30px);
font-weight: 400;
}
.statValueLg {
font-size: clamp(32px, 3vw, 50px);
font-weight: 400;
line-height: 1;
}
.description {
margin-top: 24px;
max-width: 1568px;
font-size: clamp(13px, 1vw, 16px);
line-height: 1.55;
color: var(--color-body);
}
.tabs {
margin-top: 36px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(20px, 2.5vw, 40px);
border-bottom: 1px solid #ececec;
padding: 0 clamp(12px, 4vw, 40px);
}
.tab,
.tabActive {
position: relative;
padding: 0 6px 14px;
font-size: clamp(12px, 1vw, 15px);
font-weight: 300;
line-height: 1;
text-transform: uppercase;
color: var(--color-soft);
transition: color 0.2s ease;
}
.tabActive {
font-weight: 500;
color: var(--color-black);
}
.tabActive::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
background: var(--color-black);
}
.tab:hover {
color: var(--color-black);
}
.panel {
margin-top: 36px;
padding: 0;
}
.pills {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-bottom: 28px;
}
.filtersBar {
background: transparent;
border: 1px solid #eee;
border-radius: 5px;
padding: 18px 24px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 24px;
align-items: center;
}
.filtersLabel {
display: flex;
align-items: center;
gap: 12px;
}
.filtersLabel strong {
display: block;
font-size: 18px;
font-weight: 300;
letter-spacing: 6px;
text-transform: uppercase;
}
.filtersLabel span {
display: block;
font-size: 12px;
color: #9c9c9c;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
.filterField {
position: relative;
min-width: min(100%, 280px);
flex: 1 1 240px;
border: 1px solid #eee;
border-radius: 5px;
min-height: 44px;
padding: 14px 14px 8px;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.filterField span {
position: absolute;
top: -8px;
left: 12px;
background: #fff;
padding: 0 6px;
font-size: 12px;
font-weight: 300;
color: var(--color-body);
}
.filterField input {
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 16px;
font-weight: 300;
width: 100%;
}
.filterBtn {
min-height: 45px;
padding: 0 28px;
font-size: 20px;
letter-spacing: 4px;
text-transform: uppercase;
color: var(--color-soft);
}
.filterBtn img {
width: 12px;
height: 12px;
opacity: 0.55;
transform: rotate(90deg);
transition: filter 0.3s ease, opacity 0.3s ease;
}
:global(.filter-chip).filterBtn:hover,
:global(.filter-chip).filterBtn:focus-visible {
color: var(--color-white);
}
:global(.filter-chip).filterBtn:hover img,
:global(.filter-chip).filterBtn:focus-visible img {
opacity: 1;
filter: invert(1);
}
.activeTags {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.activeTag {
min-height: 41px;
padding: 0 14px;
font-size: 16px;
gap: 10px;
}
.activeTag em {
position: relative;
z-index: 2;
font-style: normal;
font-size: 12px;
text-transform: uppercase;
color: #999;
transition: color 0.3s ease;
}
.activeTag button {
position: relative;
z-index: 2;
font-size: 18px;
line-height: 1;
color: #999;
transition: color 0.3s ease;
}
:global(.filter-chip).activeTag:hover,
:global(.filter-chip).activeTag:focus-within {
color: var(--color-white);
}
:global(.filter-chip).activeTag:hover em,
:global(.filter-chip).activeTag:hover button,
:global(.filter-chip).activeTag:focus-within em,
:global(.filter-chip).activeTag:focus-within button {
color: var(--color-white);
}
.productGrid,
.blogGrid {
margin-top: 28px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 18px;
}
.productCard,
.blogCard {
border: 1px solid #eee;
border-radius: 5px;
overflow: hidden;
background: #fff;
display: flex;
flex-direction: column;
color: inherit;
text-decoration: none;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.productCard:hover,
.blogCard:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.06);
}
.productMedia,
.blogMedia {
position: relative;
aspect-ratio: 1;
margin: 12px 12px 0;
border-radius: 5px;
overflow: hidden;
background: #f7f7f7;
}
.productImg,
.blogImg {
object-fit: contain;
}
.blogImg {
object-fit: cover;
}
.productBody,
.blogBody {
padding: 14px 12px 18px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.productBody h3,
.blogBody h3 {
font-size: 18px;
font-weight: 300;
}
.stars {
display: flex;
align-items: center;
gap: 2px;
}
.starFilled {
color: #111;
font-size: 12px;
}
.starEmpty {
color: #d0d0d0;
font-size: 12px;
}
.rating {
margin-left: 6px;
font-size: 14px;
font-weight: 300;
color: #969696;
}
.productMeta {
margin-top: auto;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 23px;
padding: 2px 8px;
border-radius: 5px;
background: #f6f6f6;
}
.chip strong {
font-size: 18px;
font-weight: 400;
}
.chip em {
font-style: normal;
font-size: 9px;
line-height: 8px;
text-transform: uppercase;
color: #999;
}
.blogBody p {
font-size: 14px;
font-weight: 300;
line-height: 18px;
color: #9d9d9d;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.blogMeta {
margin-top: auto;
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
font-weight: 300;
color: var(--color-soft);
}
.blogMeta span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.blogMeta img {
opacity: 0.55;
}
.contact {
display: flex;
flex-direction: column;
gap: 28px;
}
.contactRows {
display: flex;
flex-direction: column;
gap: 24px;
}
.contactRow {
display: flex;
align-items: flex-start;
gap: 16px;
}
.contactIcon {
width: 46px;
height: 46px;
display: grid;
place-items: center;
font-size: 22px;
color: #666;
}
.contactLabel {
display: block;
font-size: 14px;
color: #888;
margin-bottom: 4px;
}
.contactRow p {
font-size: clamp(16px, 1.4vw, 24px);
font-weight: 400;
}
.socials {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.socialChip {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: min(100%, 280px);
min-height: 41px;
padding: 8px 16px;
border: 1px solid #eee;
border-radius: 5px;
font-size: 15px;
transition: border-color 0.2s ease;
}
.socialChip:hover {
border-color: #bbb;
}
.socialChip em {
font-style: normal;
font-size: 12px;
color: #888;
margin-right: 4px;
}
.map {
position: relative;
width: 100%;
height: clamp(280px, 40vw, 660px);
border-radius: 5px;
overflow: hidden;
background: #eee;
}
.mapImg {
object-fit: cover;
}
.gallery {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-auto-rows: 120px;
gap: 16px;
}
.galleryItem {
position: relative;
grid-row: span 3;
border: 1px solid #eee;
border-radius: 5px;
overflow: hidden;
margin: 0;
background: #f6f6f6;
}
.galleryTall {
grid-row: span 5;
}
.galleryMedium {
grid-row: span 4;
}
.galleryImg {
object-fit: cover;
transition: transform 0.35s ease;
}
.galleryItem:hover .galleryImg {
transform: scale(1.05);
}
.galleryZoom {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.28);
opacity: 0;
transition: opacity 0.25s ease;
}
.galleryItem:hover .galleryZoom {
opacity: 1;
}
.galleryZoom img {
filter: invert(1);
}
.portfolioGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.portfolioCard {
border: 1px solid #e4e4e4;
border-radius: 5px;
padding: 28px;
background: #fff;
color: inherit;
text-decoration: none;
display: block;
}
.portfolioMedia {
position: relative;
aspect-ratio: 1;
border-radius: 5px;
overflow: hidden;
}
.portfolioImg {
object-fit: cover;
}
.portfolioOverlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28px 24px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
transform: translateY(12px);
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
border-radius: 0 0 5px 5px;
}
.portfolioOverlayVisible {
opacity: 1;
transform: translateY(0);
}
.portfolioOverlay h3 {
font-size: 23px;
font-weight: 300;
margin-bottom: 8px;
}
.portfolioOverlay p {
font-size: 16px;
font-weight: 300;
line-height: 18px;
color: #9d9d9d;
}
@media (max-width: 1200px) {
.productGrid,
.blogGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.gallery {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.portfolioGrid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 900px) {
.profile {
margin-top: -24px;
padding: 0;
}
.profileBar {
flex-direction: column;
align-items: flex-start;
}
.filtersBar {
grid-template-columns: 1fr;
}
.filters {
justify-content: stretch;
}
.filterBtn {
width: 100%;
justify-content: center;
}
.tabs {
justify-content: flex-start;
gap: 24px;
overflow-x: auto;
flex-wrap: nowrap;
}
.productGrid,
.blogGrid,
.portfolioGrid {
grid-template-columns: 1fr 1fr;
}
.gallery {
grid-template-columns: 1fr 1fr;
grid-auto-rows: 140px;
}
}
@media (max-width: 560px) {
.bannerTitle {
letter-spacing: 6px;
}
.productGrid,
.blogGrid,
.portfolioGrid,
.gallery {
grid-template-columns: 1fr;
}
.portfolioCard {
padding: 16px;
}
.socialChip {
min-width: 100%;
}
}
+455
View File
@@ -0,0 +1,455 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import type { BusinessDetail, BusinessTabId } from "@/data/business";
import { businessTabs } from "@/data/business";
import styles from "./BusinessDetail.module.css";
function Stars({ rating }: { rating: string }) {
return (
<div className={styles.stars} aria-label={`${rating} out of 5`}>
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
className={i < 4 ? styles.starFilled : styles.starEmpty}
aria-hidden
>
★
</span>
))}
<span className={styles.rating}>{rating}</span>
</div>
);
}
function CategoryPills({
items,
active,
onChange,
}: {
items: string[];
active: string;
onChange: (value: string) => void;
}) {
return (
<div className={styles.pills}>
{items.map((item) => (
<button
key={item}
type="button"
className={`filter-chip${item === active ? " is-active" : ""}`}
aria-pressed={item === active}
onClick={() => onChange(item)}
>
{item}
</button>
))}
</div>
);
}
function FiltersBar({
fields,
tags,
}: {
fields: { label: string; value: string }[];
tags: { label: string; value: string }[];
}) {
return (
<>
<div className={styles.filtersBar}>
<div className={styles.filtersLabel}>
<img src="/images/icons/search.svg" alt="" width={22} height={22} />
<div>
<strong>Filters</strong>
<span>A better focus</span>
</div>
</div>
<div className={styles.filters}>
{fields.map((field) => (
<label key={field.label} className={styles.filterField}>
<span>{field.label}</span>
<input defaultValue={field.value} readOnly />
</label>
))}
<button className={`filter-chip ${styles.filterBtn}`} type="button">
Filter
<img src="/images/icons/chevron.svg" alt="" />
</button>
</div>
</div>
{tags.length ? (
<div className={styles.activeTags}>
{tags.map((tag) => (
<span className={`filter-chip ${styles.activeTag}`} key={tag.label}>
<em>{tag.label}:</em> {tag.value}
<button type="button" aria-label={`Remove ${tag.label}`}>
×
</button>
</span>
))}
</div>
) : null}
</>
);
}
export default function BusinessDetailView({
business,
}: {
business: BusinessDetail;
}) {
const [tab, setTab] = useState<BusinessTabId>("products");
const [productCategory, setProductCategory] = useState("All");
const [blogCategory, setBlogCategory] = useState("All");
const [portfolioCategory, setPortfolioCategory] = useState("All");
const [hoveredPortfolio, setHoveredPortfolio] = useState<string | null>(
business.portfolios[2]?.id ?? null,
);
const products =
productCategory === "All"
? business.products
: business.products.filter((p) => p.category === productCategory);
return (
<div className={styles.root}>
<section className={styles.bannerWrap}>
<div className={styles.banner}>
<h1 className={styles.bannerTitle}>{business.name}</h1>
<nav className={styles.crumbs} aria-label="Breadcrumb">
{business.breadcrumbs.map((crumb, index) => (
<span key={`${crumb}-${index}`}>
{index > 0 ? (
<img
className={styles.crumbChevron}
src="/images/icons/chevron.svg"
alt=""
/>
) : null}
<span
className={
index === business.breadcrumbs.length - 1
? styles.crumbActive
: undefined
}
>
{crumb}
</span>
</span>
))}
</nav>
</div>
</section>
<section className={styles.hero}>
<Image
src={business.hero}
alt=""
fill
priority
className={styles.heroImg}
sizes="100vw"
/>
</section>
<section className={styles.profile}>
<div className={styles.profileBar}>
<div className={styles.identity}>
<img
className={styles.logo}
src={business.logo}
alt=""
width={75}
height={75}
/>
<div>
<h2 className={styles.name}>{business.name}</h2>
<p className={styles.tagline}>{business.tagline}</p>
</div>
</div>
<div className={styles.stats}>
<div className={styles.stat}>
<span className={styles.statLabel}>since</span>
<strong className={styles.statValueSm}>{business.since}</strong>
</div>
<div className={styles.stat}>
<span className={styles.statLabel}>province</span>
<strong className={styles.statValueSm}>{business.province}</strong>
</div>
<div className={styles.statWide}>
<strong className={styles.statValueLg}>
{business.activeProducts}
</strong>
<span className={styles.statLabelStack}>
active
<br />
product
</span>
</div>
<div className={styles.stat}>
<span className={styles.statLabel}>Telephone</span>
<strong className={styles.statValueSm}>{business.telephone}</strong>
</div>
</div>
</div>
<p className={styles.description}>{business.description}</p>
</section>
<nav className={styles.tabs} aria-label="Business sections">
{businessTabs.map((item) => (
<button
key={item.id}
type="button"
className={tab === item.id ? styles.tabActive : styles.tab}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
))}
</nav>
<section className={styles.panel} aria-live="polite">
{tab === "products" ? (
<>
<CategoryPills
items={business.productCategories}
active={productCategory}
onChange={setProductCategory}
/>
<FiltersBar
fields={[{ label: "Product name", value: "gold" }]}
tags={[{ label: "Name", value: "gold" }]}
/>
<div className={styles.productGrid}>
{products.map((product) => (
<article key={product.id} className={styles.productCard}>
<div className={styles.productMedia}>
<Image
src={product.image}
alt=""
fill
className={styles.productImg}
sizes="(max-width: 720px) 50vw, 292px"
/>
</div>
<div className={styles.productBody}>
<h3>{product.title}</h3>
<Stars rating={product.rating} />
<div className={styles.productMeta}>
<span className={styles.chip}>
<strong>{product.minOrder}</strong>
<em>
pcs, min
<br />
order
</em>
</span>
<span className={styles.chip}>
<strong>{product.shipsIn}</strong>
<em>
days
<br />
to ship
</em>
</span>
</div>
</div>
</article>
))}
</div>
</>
) : null}
{tab === "contact" ? (
<div className={styles.contact}>
<div className={styles.contactRows}>
<div className={styles.contactRow}>
<span className={styles.contactIcon} aria-hidden>
⌖
</span>
<div>
<span className={styles.contactLabel}>Address</span>
<p>{business.contact.address}</p>
</div>
</div>
<div className={styles.contactRow}>
<span className={styles.contactIcon} aria-hidden>
☎
</span>
<div>
<span className={styles.contactLabel}>land line</span>
<p>{business.contact.landlines.join(" , ")}</p>
</div>
</div>
</div>
<div className={styles.socials}>
{business.contact.socials.map((social) => (
<a
key={social.network}
href="#"
className={styles.socialChip}
>
<img src={social.icon} alt="" width={18} height={18} />
<span>
<em>{social.network}</em> {social.handle}
</span>
</a>
))}
</div>
<div className={styles.map}>
<Image
src={business.contact.map}
alt="Business location map"
fill
className={styles.mapImg}
sizes="100vw"
/>
</div>
</div>
) : null}
{tab === "blog" ? (
<>
<CategoryPills
items={business.blogCategories}
active={blogCategory}
onChange={setBlogCategory}
/>
<FiltersBar
fields={[
{ label: "Article Name", value: "gold Scarlet necklace" },
{ label: "Date", value: "1402.05.12" },
]}
tags={[
{ label: "Name", value: "gold Scarlet necklace" },
{ label: "date", value: "1402.05.12" },
]}
/>
<div className={styles.blogGrid}>
{business.posts.map((post) => (
<Link
key={post.id}
href={`/businesses/${business.slug}/blog/${post.slug}`}
className={styles.blogCard}
>
<div className={styles.blogMedia}>
<Image
src={post.image}
alt=""
fill
className={styles.blogImg}
sizes="(max-width: 720px) 50vw, 292px"
/>
</div>
<div className={styles.blogBody}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
<div className={styles.blogMeta}>
<span>
<img
src="/images/icons/home-outline.svg"
alt=""
width={14}
height={14}
/>
{post.date}
</span>
<span>
<img
src="/images/icons/user.svg"
alt=""
width={14}
height={14}
/>
{post.author}
</span>
</div>
</div>
</Link>
))}
</div>
</>
) : null}
{tab === "gallery" ? (
<div className={styles.gallery}>
{business.gallery.map((item) => (
<figure
key={item.id}
className={[
styles.galleryItem,
item.tall ? styles.galleryTall : "",
item.medium ? styles.galleryMedium : "",
]
.filter(Boolean)
.join(" ")}
>
<Image
src={item.image}
alt=""
fill
className={styles.galleryImg}
sizes="(max-width: 900px) 50vw, 20vw"
/>
<span className={styles.galleryZoom} aria-hidden>
<img src="/images/icons/search.svg" alt="" width={22} height={22} />
</span>
</figure>
))}
</div>
) : null}
{tab === "portfolios" ? (
<>
<CategoryPills
items={business.portfolioCategories}
active={portfolioCategory}
onChange={setPortfolioCategory}
/>
<div className={styles.portfolioGrid}>
{business.portfolios.map((item) => {
const showOverlay =
hoveredPortfolio === item.id ||
(hoveredPortfolio === null &&
item.id === business.portfolios[2]?.id);
return (
<Link
key={item.id}
href={`/businesses/${business.slug}/portfolios/${item.slug}`}
className={styles.portfolioCard}
onMouseEnter={() => setHoveredPortfolio(item.id)}
onMouseLeave={() => setHoveredPortfolio(null)}
>
<div className={styles.portfolioMedia}>
<Image
src={item.image}
alt=""
fill
className={styles.portfolioImg}
sizes="(max-width: 900px) 100vw, 33vw"
/>
<div
className={[
styles.portfolioOverlay,
showOverlay ? styles.portfolioOverlayVisible : "",
]
.filter(Boolean)
.join(" ")}
>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</div>
</Link>
);
})}
</div>
</>
) : null}
</section>
</div>
);
}
+644
View File
@@ -0,0 +1,644 @@
.root {
width: 100%;
padding-bottom: 80px;
}
.container {
width: 100%;
max-width: var(--container-width);
margin-inline: auto;
overflow: visible;
}
.steps {
margin-top: 36px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid #ececec;
}
.step,
.stepActive {
position: relative;
padding: 0 12px 16px;
font-size: clamp(12px, 1.1vw, 15px);
font-weight: 300;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-soft);
text-align: center;
transition: color 0.2s ease;
}
.stepActive {
font-weight: 500;
color: var(--color-black);
}
.stepActive::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
background: var(--color-black);
}
.step:hover {
color: var(--color-black);
}
.layout {
margin-top: 28px;
display: grid;
grid-template-columns: minmax(120px, 158px) minmax(0, 1fr);
gap: 28px;
align-items: start;
overflow: visible;
}
.side {
position: relative;
min-height: 520px;
overflow: visible;
}
.sideRail {
position: absolute;
inset: 0 auto 0 0;
width: 100%;
max-width: 158px;
min-height: 644px;
background: #f6f6f6;
border-radius: 6px;
}
.sideImage {
position: absolute;
left: -64px;
top: 140px;
z-index: 1;
width: 240px;
max-width: none;
height: auto;
object-fit: contain;
pointer-events: none;
}
.sideImageLaptop {
width: 288px; /* +20% */
}
.sideImagePainting {
width: 400px;
left: -120px;
top: 100px;
}
.sideImageNotebook {
width: 380px;
left: -110px;
top: 90px;
}
.panel {
min-width: 0;
display: flex;
flex-direction: column;
}
.stepPanel {
display: flex;
flex-direction: column;
gap: 22px;
}
.typeRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 18px;
align-items: center;
}
.typeIntro {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 90px;
}
.typeIntro strong {
font-size: 18px;
font-weight: 300;
letter-spacing: 0.54px;
text-transform: uppercase;
}
.typeIntro span {
font-size: 12px;
color: #9c9c9c;
}
.typeCards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.typeCard,
.typeCardActive {
position: relative;
isolation: isolate;
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 8px 14px 8px 10px;
border: 0;
border-radius: 5px;
background: #f6f6f6;
text-align: left;
overflow: hidden;
transition: color 0.3s ease;
}
.typeCard::before,
.typeCardActive::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: var(--color-black);
clip-path: inset(100% 0 0 0);
transition: clip-path 0.45s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
border-radius: inherit;
}
.typeCard:hover::before,
.typeCardActive::before {
clip-path: inset(0 0 0 0);
}
.typeCard:hover,
.typeCardActive {
color: var(--color-white);
background: #f6f6f6;
}
.typeCardActive {
background: #111;
color: var(--color-white);
}
.typeCard img,
.typeCardActive img,
.typeCard span,
.typeCardActive span {
position: relative;
z-index: 1;
}
.typeIcon {
width: 36px;
height: 36px;
flex-shrink: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.typeIcon img {
width: 100%;
height: 100%;
object-fit: contain;
transition: filter 0.3s ease, transform 0.3s ease;
}
/* Optical scale — source PNGs have uneven padding / visual weight */
.typeIconStore {
transform: scale(0.88);
}
.typeIconCorporates {
transform: scale(1.45);
/* Asset is white-on-black; invert so it reads as black outline on grey cards */
filter: invert(1);
}
.typeIconIndustrial {
transform: scale(1.75);
}
.typeCard:hover .typeIconStore,
.typeCard:hover .typeIconIndustrial,
.typeCardActive .typeIconStore,
.typeCardActive .typeIconIndustrial {
filter: invert(1) brightness(2);
}
.typeCard:hover .typeIconCorporates,
.typeCardActive .typeIconCorporates {
filter: none;
}
.typeCard span,
.typeCardActive span {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.typeCard em,
.typeCardActive em {
font-style: normal;
font-size: 16px;
font-weight: 300;
text-transform: capitalize;
}
.typeCard small,
.typeCardActive small {
font-size: 10px;
color: #989898;
text-transform: capitalize;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.typeCard:hover small,
.typeCardActive small {
color: #cfcfcf;
}
.row,
.row4,
.rowLocation {
display: grid;
gap: 16px;
}
.row {
grid-template-columns: 1fr 1fr;
}
.row4 {
grid-template-columns: 1.4fr 1fr 0.7fr 1.4fr;
}
.rowLocation {
grid-template-columns: 0.9fr 1.2fr 0.8fr;
}
.field {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 46px;
padding: 14px 14px 10px;
border: 1px solid #eee;
border-radius: 5px;
background: #fff;
transition: border-color 0.25s ease;
}
.field span {
position: absolute;
top: -8px;
left: 12px;
background: #fff;
padding: 0 6px;
font-size: 12px;
font-weight: 300;
color: var(--color-body);
transition: color 0.25s ease;
}
.field:focus-within {
border-color: var(--color-black);
}
.field:focus-within span {
color: var(--color-black);
}
.field input,
.field select,
.field textarea {
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 16px;
font-weight: 300;
width: 100%;
color: var(--color-black);
resize: vertical;
}
.field input::placeholder {
color: #c8c8c8;
}
.selectField {
padding-right: 34px;
}
.selectField select {
appearance: none;
cursor: pointer;
}
.selectChevron {
position: absolute;
right: 14px;
bottom: 16px;
width: 10px;
height: 12px;
opacity: 0.5;
transform: rotate(90deg);
pointer-events: none;
transition: opacity 0.25s ease;
}
.selectField:focus-within .selectChevron {
opacity: 1;
}
.textareaField {
min-height: 110px;
padding-top: 18px;
}
.rule {
border: 0;
border-top: 1px solid #ececec;
margin: 4px 0;
}
.uploadGrid {
display: grid;
grid-template-columns: minmax(220px, 0.4fr) minmax(0, 1fr);
gap: 18px;
}
.uploadCard,
.uploadCardWide {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
cursor: pointer;
}
.uploadHeader {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 44px;
padding: 8px 16px;
border-radius: 5px;
background: #f6f6f6;
}
.uploadHeader img {
width: 28px;
height: 28px;
object-fit: contain;
}
.uploadHeader span {
display: flex;
flex-direction: column;
gap: 2px;
}
.uploadHeader em {
font-style: normal;
font-size: 16px;
font-weight: 300;
}
.uploadHeader small {
font-size: 10px;
color: #686868;
text-transform: capitalize;
}
.uploadBox {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid #c1c1c1;
border-radius: 5px;
font-size: clamp(28px, 3vw, 48px);
font-weight: 700;
color: #e2e2e2;
text-transform: uppercase;
letter-spacing: 0.04em;
text-align: center;
padding: 20px;
}
.fileInput {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.summarySection {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
}
.summarySection:first-child,
.summarySection:nth-of-type(4) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summaryItem {
position: relative;
padding-left: 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.summaryItem::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 1px;
background: #d7d7d7;
}
.summaryItem span,
.summaryBlock span {
font-size: 14px;
font-weight: 300;
letter-spacing: 0.42px;
text-transform: uppercase;
color: #9c9c9c;
}
.summaryItem strong {
font-size: 18px;
font-weight: 300;
text-transform: uppercase;
}
.summaryBlock {
position: relative;
padding-left: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.summaryBlock::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 1px;
background: #d7d7d7;
}
.summaryBlock p {
font-size: 18px;
font-weight: 300;
line-height: 1.45;
text-align: justify;
}
.footerBar {
margin-top: 28px;
padding-top: 22px;
border-top: 1px solid #ececec;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.backBtn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 300;
letter-spacing: 2.4px;
text-transform: uppercase;
color: #9e9e9e;
}
.backBtn img {
width: 8px;
height: 10px;
opacity: 0.45;
transform: scaleX(-1) rotate(0deg);
}
.continueBtn {
min-width: min(100%, 240px);
min-height: 29px;
padding: 0 18px;
font-size: 12px;
letter-spacing: 2.4px;
}
.confirmHint {
margin: 8px 0 0;
font-size: 12px;
font-weight: 300;
color: #989898;
line-height: 1.45;
}
.submitError {
margin: 12px 0 0;
font-size: 13px;
font-weight: 300;
color: #b42318;
}
.submitOk {
margin: 12px 0 0;
font-size: 13px;
font-weight: 300;
color: #027a48;
}
@media (max-width: 1100px) {
.typeRow {
grid-template-columns: 1fr;
}
.row4,
.rowLocation,
.summarySection,
.summarySection:first-child,
.summarySection:nth-of-type(4) {
grid-template-columns: 1fr 1fr;
}
.uploadGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.side {
display: none;
}
.typeCards {
grid-template-columns: 1fr;
}
.steps {
overflow-x: auto;
}
.step,
.stepActive {
white-space: nowrap;
}
}
@media (max-width: 640px) {
.row,
.row4,
.rowLocation,
.summarySection,
.summarySection:first-child,
.summarySection:nth-of-type(4) {
grid-template-columns: 1fr;
}
.footerBar {
flex-direction: column-reverse;
align-items: stretch;
}
.continueBtn,
.backBtn {
width: 100%;
justify-content: center;
}
}
+541
View File
@@ -0,0 +1,541 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import CategoryMultiSelect from "@/components/CategoryMultiSelect";
import DetailBanner from "@/components/DetailBanner";
import {
categoryLabel,
searchMeshkeeCategories,
type MeshkeeCategory,
} from "@/lib/meshkee-categories";
import { registerShop } from "@/lib/marketplace";
import styles from "./BusinessRegistration.module.css";
const steps = [
{ id: 1, label: "Business information" },
{ id: 2, label: "insert photo" },
{ id: 3, label: "Confirm information" },
] as const;
const businessTypes = [
{
id: "store",
title: "Store",
subtitle: "In publishing and graphic design",
icon: "/images/register/type-store.png",
iconClass: "typeIconStore",
},
{
id: "corporates",
title: "Corporates",
subtitle: "In publishing and graphic design",
icon: "/images/register/type-corporates.png",
iconClass: "typeIconCorporates",
},
{
id: "industrial",
title: "industrial",
subtitle: "In publishing and graphic design",
icon: "/images/register/type-industrial.png",
iconClass: "typeIconIndustrial",
},
] as const;
const sideImages = [
{
src: "/images/register/side-step1.png",
className: "sideImageLaptop",
},
{
src: "/images/register/side-step2.png",
className: "sideImagePainting",
},
{
src: "/images/register/side-step3.png",
className: "sideImageNotebook",
},
] as const;
type FormState = {
businessType: string;
categoryIds: string[];
storeName: string;
telephone: string;
since: string;
gmail: string;
description: string;
country: string;
province: string;
postalCode: string;
address: string;
logoName: string;
bannerName: string;
};
const initialForm: FormState = {
businessType: "corporates",
categoryIds: [],
storeName: "Sani Khani",
telephone: "025 - 3698 52 69",
since: "2018",
gmail: "@Gmail.com",
description: "In publishing and graphic design, Lorem ipsum is a placeholder.",
country: "Iran",
province: "Qom",
postalCode: "03785692",
address: "In publishing and graphic design, Lorem ipsum is a placeholder.",
logoName: "",
bannerName: "",
};
function Field({
label,
value,
onChange,
placeholder,
className,
type = "text",
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
type?: string;
}) {
return (
<label className={`${styles.field} ${className ?? ""}`}>
<span>{label}</span>
<input
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</label>
);
}
function SelectField({
label,
value,
onChange,
options,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: string[];
className?: string;
}) {
return (
<label className={`${styles.field} ${styles.selectField} ${className ?? ""}`}>
<span>{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<img src="/images/icons/chevron.svg" alt="" className={styles.selectChevron} />
</label>
);
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div className={styles.summaryItem}>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
export default function BusinessRegistration() {
const [step, setStep] = useState(1);
const [form, setForm] = useState<FormState>(initialForm);
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(true);
const [categoriesError, setCategoriesError] = useState("");
const [ownerPassword, setOwnerPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
const [submitOk, setSubmitOk] = useState("");
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
useEffect(() => {
const controller = new AbortController();
async function loadCategories() {
setCategoriesLoading(true);
setCategoriesError("");
try {
const items = await searchMeshkeeCategories("", controller.signal);
if (controller.signal.aborted) return;
setCategories(items);
} catch (err) {
if (controller.signal.aborted) return;
setCategories([]);
setCategoriesError(
err instanceof Error ? err.message : "Failed to load categories",
);
} finally {
if (!controller.signal.aborted) setCategoriesLoading(false);
}
}
void loadCategories();
return () => controller.abort();
}, []);
const goNext = () => setStep((prev) => Math.min(3, prev + 1));
const goBack = () => setStep((prev) => Math.max(1, prev - 1));
const businessTypeLabel =
businessTypes.find((item) => item.id === form.businessType)?.title ??
form.businessType;
const selectedCategoryLabels = useMemo(() => {
const byId = new Map(categories.map((item) => [item.id, item]));
return form.categoryIds
.map((id) => byId.get(id))
.filter((item): item is MeshkeeCategory => Boolean(item))
.map((item) => categoryLabel(item));
}, [categories, form.categoryIds]);
return (
<div className={styles.root}>
<DetailBanner
title="Business registration"
breadcrumbs={["Home", "Business Register"]}
/>
<div className={styles.container}>
<nav className={styles.steps} aria-label="Registration steps">
{steps.map((item) => (
<button
key={item.id}
type="button"
className={step === item.id ? styles.stepActive : styles.step}
onClick={() => setStep(item.id)}
>
{item.label}
</button>
))}
</nav>
<div className={styles.layout}>
<aside className={styles.side} aria-hidden>
<div className={styles.sideRail} />
<img
className={`${styles.sideImage} ${styles[sideImages[step - 1].className]}`}
src={sideImages[step - 1].src}
alt=""
/>
</aside>
<div className={styles.panel}>
{step === 1 ? (
<div className={styles.stepPanel}>
<div className={styles.typeRow}>
<div className={styles.typeIntro}>
<strong>Category</strong>
<span>Do Select</span>
</div>
<div className={styles.typeCards}>
{businessTypes.map((item) => (
<button
key={item.id}
type="button"
className={
form.businessType === item.id
? styles.typeCardActive
: styles.typeCard
}
onClick={() => update("businessType", item.id)}
>
<span className={styles.typeIcon}>
<img
className={styles[item.iconClass]}
src={item.icon}
alt=""
/>
</span>
<span>
<em>{item.title}</em>
<small>{item.subtitle}</small>
</span>
</button>
))}
</div>
</div>
<CategoryMultiSelect
label="Category"
categories={categories}
value={form.categoryIds}
onChange={(ids) => update("categoryIds", ids)}
loading={categoriesLoading}
error={categoriesError}
/>
<hr className={styles.rule} />
<div className={styles.row4}>
<Field
label="Store name"
value={form.storeName}
onChange={(value) => update("storeName", value)}
/>
<Field
label="Telephone"
value={form.telephone}
onChange={(value) => update("telephone", value)}
/>
<Field
label="Since"
value={form.since}
onChange={(value) => update("since", value)}
/>
<Field
label="Gmail"
value={form.gmail}
onChange={(value) => update("gmail", value)}
placeholder="@Gmail.com"
/>
</div>
<label className={`${styles.field} ${styles.textareaField}`}>
<span>Description</span>
<textarea
value={form.description}
onChange={(event) => update("description", event.target.value)}
rows={4}
/>
</label>
<hr className={styles.rule} />
<div className={styles.rowLocation}>
<SelectField
label="country"
value={form.country}
onChange={(value) => update("country", value)}
options={["Iran", "UAE", "Turkey"]}
/>
<SelectField
label="Province"
value={form.province}
onChange={(value) => update("province", value)}
options={["Qom", "Tehran", "Isfahan", "Shiraz"]}
/>
<Field
label="Postal code"
value={form.postalCode}
onChange={(value) => update("postalCode", value)}
/>
</div>
<label className={`${styles.field} ${styles.textareaField}`}>
<span>Address</span>
<textarea
value={form.address}
onChange={(event) => update("address", event.target.value)}
rows={2}
/>
</label>
</div>
) : null}
{step === 2 ? (
<div className={styles.stepPanel}>
<div className={styles.uploadGrid}>
<label className={styles.uploadCard}>
<span className={styles.uploadHeader}>
<img src="/images/register/upload.png" alt="" />
<span>
<em>Enter Logo</em>
<small>Max image size: 200kb</small>
</span>
</span>
<span className={styles.uploadBox}>
{form.logoName ? form.logoName : "111 x 80"}
</span>
<input
type="file"
accept="image/*"
className={styles.fileInput}
onChange={(event) =>
update("logoName", event.target.files?.[0]?.name ?? "")
}
/>
</label>
<label className={styles.uploadCardWide}>
<span className={styles.uploadHeader}>
<img src="/images/register/upload.png" alt="" />
<span>
<em>Enter Banner</em>
<small>Max image size: 2mb</small>
</span>
</span>
<span className={styles.uploadBox}>
{form.bannerName ? form.bannerName : "1386 x 189"}
</span>
<input
type="file"
accept="image/*"
className={styles.fileInput}
onChange={(event) =>
update("bannerName", event.target.files?.[0]?.name ?? "")
}
/>
</label>
</div>
</div>
) : null}
{step === 3 ? (
<div className={styles.stepPanel}>
<div className={styles.summarySection}>
<SummaryItem label="Business type" value={businessTypeLabel} />
<SummaryItem
label="Category"
value={
selectedCategoryLabels.length
? selectedCategoryLabels.join(", ")
: "—"
}
/>
</div>
<hr className={styles.rule} />
<div className={styles.summarySection}>
<SummaryItem label="Store Name" value={form.storeName} />
<SummaryItem label="Telephone" value={form.telephone} />
<SummaryItem label="Since" value={form.since} />
<SummaryItem label="Gmail" value={form.gmail} />
</div>
<hr className={styles.rule} />
<div className={styles.summaryBlock}>
<span>Discraption</span>
<p>{form.description}</p>
</div>
<hr className={styles.rule} />
<div className={styles.summarySection}>
<SummaryItem label="Caountry" value={form.country} />
<SummaryItem label="province" value={form.province} />
<SummaryItem label="postal code" value={form.postalCode} />
</div>
<hr className={styles.rule} />
<div className={styles.summaryBlock}>
<span>Address</span>
<p>{form.address}</p>
</div>
<hr className={styles.rule} />
<label className={styles.field}>
<span>Account password</span>
<input
type="password"
value={ownerPassword}
onChange={(event) => setOwnerPassword(event.target.value)}
minLength={8}
required
autoComplete="current-password"
placeholder="Confirm your login password"
/>
</label>
<p className={styles.confirmHint}>
Confirm your IranBay account password to create your Meshkee
shop (separate from the iranbay.com auth tenant).
</p>
{submitError ? (
<p className={styles.submitError} role="alert">
{submitError}
</p>
) : null}
{submitOk ? (
<p className={styles.submitOk} role="status">
{submitOk}
</p>
) : null}
</div>
) : null}
<div className={styles.footerBar}>
{step > 1 ? (
<button
type="button"
className={styles.backBtn}
onClick={goBack}
disabled={submitting}
>
<img src="/images/icons/chevron.svg" alt="" />
step backward
</button>
) : (
<span />
)}
{step < 3 ? (
<button
type="button"
className={`outline-btn ${styles.continueBtn}`}
onClick={goNext}
>
Continue registration
</button>
) : (
<button
type="button"
className={`outline-btn ${styles.continueBtn}`}
disabled={submitting || Boolean(submitOk)}
onClick={async () => {
setSubmitError("");
setSubmitOk("");
if (ownerPassword.length < 8) {
setSubmitError("Enter your account password (min 8 chars).");
return;
}
if (!form.storeName.trim()) {
setSubmitError("Store name is required.");
return;
}
setSubmitting(true);
try {
const result = await registerShop({
name: form.storeName.trim(),
about: form.description.trim() || undefined,
meshkeeCategoryIds: form.categoryIds.length
? form.categoryIds
: undefined,
ownerPassword,
});
setSubmitOk(
result.message ||
`Submitted. Meshkee shop #${result.meshkeeBusinessId}`,
);
} catch (err) {
setSubmitError(
err instanceof Error
? err.message
: "Could not register business",
);
} finally {
setSubmitting(false);
}
}}
>
{submitting ? "Submitting…" : "Confirm information"}
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
.root {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.field {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 46px;
padding: 14px 34px 10px 14px;
border: 1px solid #eee;
border-radius: 5px;
background: #fff;
transition: border-color 0.25s ease;
}
.field:focus-within {
border-color: var(--color-black);
}
.label {
position: absolute;
top: -8px;
left: 12px;
background: #fff;
padding: 0 6px;
font-size: 12px;
font-weight: 300;
color: var(--color-body);
transition: color 0.25s ease;
}
.field:focus-within .label {
color: var(--color-black);
}
.control {
display: flex;
align-items: center;
width: 100%;
}
.control input {
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 16px;
font-weight: 300;
width: 100%;
color: var(--color-black);
}
.control input::placeholder {
color: #c8c8c8;
}
.caret {
position: absolute;
right: 14px;
bottom: 16px;
width: 10px;
height: 12px;
opacity: 0.5;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.caret img {
width: 10px;
height: 12px;
transform: rotate(90deg);
transition: transform 0.15s ease;
}
.caretOpen img {
transform: rotate(-90deg);
}
.dropdown {
position: absolute;
z-index: 30;
top: calc(100% + 6px);
inset-inline: 0;
max-height: 280px;
overflow: auto;
border: 1px solid #eee;
border-radius: 5px;
background: #fff;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
}
.list {
list-style: none;
margin: 0;
padding: 6px 0;
}
.node {
margin: 0;
}
.row {
display: flex;
align-items: center;
gap: 2px;
min-height: 36px;
}
.expand,
.expandSpacer {
flex: 0 0 22px;
width: 22px;
height: 22px;
}
.expand {
display: inline-grid;
place-items: center;
border: 1px solid #e8e8e8;
border-radius: 3px;
background: #fff;
color: #989898;
font-size: 14px;
font-weight: 300;
line-height: 1;
cursor: pointer;
}
.expand:hover,
.expand:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.option {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 4px;
border: none;
background: transparent;
text-align: start;
font: inherit;
font-size: 14px;
font-weight: 300;
color: var(--color-muted);
cursor: pointer;
border-radius: 4px;
}
.option:hover,
.option:focus-visible {
background: #f7f7f7;
color: var(--color-black);
}
.optionActive {
color: var(--color-black);
}
.check {
flex: 0 0 14px;
width: 14px;
font-size: 11px;
line-height: 1;
color: var(--color-black);
}
.optionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty,
.status {
margin: 0;
padding: 16px 14px;
font-size: 13px;
font-weight: 300;
color: var(--color-body);
}
.error {
margin: 0;
font-size: 12px;
font-weight: 300;
color: #b42318;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--color-black);
border-radius: 4px;
padding: 5px 10px;
font-size: 12px;
font-weight: 300;
color: var(--color-white);
background: var(--color-black);
cursor: pointer;
}
.chip:hover,
.chip:focus-visible {
opacity: 0.88;
}
.chip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
categoryLabel,
type MeshkeeCategory,
} from "@/lib/meshkee-categories";
import styles from "./CategoryMultiSelect.module.css";
type TreeNode = MeshkeeCategory & { children: TreeNode[] };
function buildTree(categories: MeshkeeCategory[]): TreeNode[] {
const byId = new Map<string, TreeNode>();
for (const category of categories) {
byId.set(category.id, { ...category, children: [] });
}
const roots: TreeNode[] = [];
for (const node of byId.values()) {
if (node.parentId && byId.has(node.parentId)) {
byId.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(roots);
return roots;
}
function matchesQuery(category: MeshkeeCategory, q: string) {
if (!q) return true;
const hay = [category.name, category.nameFa ?? "", category.slug, ...category.tags]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
if (!q) return nodes;
const result: TreeNode[] = [];
for (const node of nodes) {
const children = filterTree(node.children, q);
if (matchesQuery(node, q) || children.length) {
result.push({ ...node, children });
}
}
return result;
}
function collectExpandIds(nodes: TreeNode[], into: Set<string>) {
for (const node of nodes) {
if (node.children.length) {
into.add(node.id);
collectExpandIds(node.children, into);
}
}
}
type Props = {
categories: MeshkeeCategory[];
value: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
loading?: boolean;
error?: string;
label?: string;
};
export default function CategoryMultiSelect({
categories,
value,
onChange,
disabled = false,
loading = false,
error = "",
label = "Category",
}: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const tree = useMemo(() => buildTree(categories), [categories]);
const q = query.trim().toLowerCase();
const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]);
const byId = useMemo(() => {
const map = new Map<string, MeshkeeCategory>();
for (const category of categories) map.set(category.id, category);
return map;
}, [categories]);
const selected = value
.map((id) => byId.get(id))
.filter((item): item is MeshkeeCategory => Boolean(item));
useEffect(() => {
if (!q) return;
const next = new Set<string>();
collectExpandIds(visibleTree, next);
setExpanded(next);
}, [q, visibleTree]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function toggle(id: string) {
const next = value.includes(id)
? value.filter((item) => item !== id)
: [...value, id];
onChange(next);
}
function toggleExpand(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function renderNodes(nodes: TreeNode[], depth: number) {
return nodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = q ? true : expanded.has(node.id);
const active = value.includes(node.id);
return (
<li key={node.id} className={styles.node}>
<div
className={styles.row}
style={{ paddingInlineStart: `${8 + depth * 16}px` }}
>
{hasChildren ? (
<button
type="button"
className={styles.expand}
aria-label={isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
onClick={() => toggleExpand(node.id)}
>
<span aria-hidden>{isExpanded ? "−" : "+"}</span>
</button>
) : (
<span className={styles.expandSpacer} aria-hidden />
)}
<button
type="button"
className={`${styles.option} ${active ? styles.optionActive : ""}`}
disabled={disabled}
onClick={() => toggle(node.id)}
>
<span className={styles.check} aria-hidden>
{active ? "✓" : ""}
</span>
<span className={styles.optionLabel}>{categoryLabel(node)}</span>
</button>
</div>
{hasChildren && isExpanded ? (
<ul className={styles.list}>{renderNodes(node.children, depth + 1)}</ul>
) : null}
</li>
);
});
}
const placeholder = loading
? "Loading categories…"
: selected.length
? `${selected.length} selected — search to add more`
: "Search categories…";
return (
<div className={styles.root} ref={rootRef}>
<div className={styles.field}>
<span className={styles.label}>{label}</span>
<div className={styles.control}>
<input
value={query}
disabled={disabled || loading}
placeholder={placeholder}
aria-expanded={open}
aria-haspopup="listbox"
onFocus={() => setOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onClick={() => setOpen(true)}
/>
<button
type="button"
className={`${styles.caret} ${open ? styles.caretOpen : ""}`}
aria-label={open ? "Close categories" : "Open categories"}
disabled={disabled || loading}
onClick={() => setOpen((prev) => !prev)}
>
<img src="/images/icons/chevron.svg" alt="" />
</button>
</div>
{open ? (
<div className={styles.dropdown} role="listbox" aria-multiselectable>
{loading ? (
<p className={styles.status}>Loading categories…</p>
) : visibleTree.length ? (
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
) : (
<p className={styles.empty}>
{error || "No categories found"}
</p>
)}
</div>
) : null}
</div>
{error && !open ? <p className={styles.error}>{error}</p> : null}
{selected.length ? (
<div className={styles.chips}>
{selected.map((category) => (
<button
key={category.id}
type="button"
className={styles.chip}
disabled={disabled}
title="Remove category"
onClick={() => toggle(category.id)}
>
{categoryLabel(category)}
<span aria-hidden>×</span>
</button>
))}
</div>
) : null}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { testimonials } from "@/data/pages";
import styles from "@/app/about/about.module.css";
export default function CommentCarousel() {
const [index, setIndex] = useState(0);
const total = testimonials.length;
const item = testimonials[index];
const prev = () => setIndex((i) => (i - 1 + total) % total);
const next = () => setIndex((i) => (i + 1) % total);
return (
<div className={styles.comments}>
<div className={styles.commentHead}>
<div className={styles.arrows}>
<button
className={styles.arrowBtn}
type="button"
aria-label="Previous review"
onClick={prev}
>
<img
className={styles.arrowPrev}
src="/images/icons/chevron.svg"
alt=""
width={8}
height={14}
/>
</button>
<button
className={styles.arrowBtn}
type="button"
aria-label="Next review"
onClick={next}
>
<img src="/images/icons/chevron.svg" alt="" width={8} height={14} />
</button>
</div>
<div className={styles.sectionHead}>
<h2 className={styles.sectionTitle}>People comment</h2>
<p className={styles.sectionSub}>
some of our most crowded categories
</p>
</div>
<span className={styles.commentCount} aria-live="polite">
{index + 1} / {total}
</span>
</div>
<div className={styles.commentTrack} key={item.meta}>
<p className={styles.quote}>{item.body}</p>
<p className={styles.meta}>{item.meta}</p>
</div>
<div className={styles.dots} role="tablist" aria-label="Reviews">
{testimonials.map((review, i) => (
<button
key={review.meta}
type="button"
role="tab"
aria-selected={i === index}
aria-label={`Show review ${i + 1}`}
className={i === index ? styles.dotActive : styles.dot}
onClick={() => setIndex(i)}
/>
))}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { faqs } from "@/data/pages";
import styles from "@/app/contact/contact.module.css";
export default function ContactFaq() {
const [open, setOpen] = useState(0);
return (
<div className={styles.faqList}>
{faqs.map((item, index) => {
const active = open === index;
return (
<div className={styles.faqItem} key={`${item.question}-${index}`}>
<button
type="button"
className={styles.faqTrigger}
aria-expanded={active}
onClick={() => setOpen(active ? -1 : index)}
>
<span>{item.question}</span>
<img
className={active ? styles.faqOpen : styles.faqClosed}
src="/images/icons/chevron.svg"
alt=""
width={10}
height={18}
/>
</button>
{active ? <p className={styles.faqAnswer}>{item.answer}</p> : null}
</div>
);
})}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
.bannerWrap {
padding-top: 28px;
}
.banner {
position: relative;
isolation: isolate;
overflow: hidden;
background: #f6f6f6;
border-radius: 5px;
min-height: 136px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px 20px;
}
.banner::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.7;
background-image: var(--page-title-pattern);
}
.bannerTitle,
.crumbs {
position: relative;
z-index: 1;
}
.bannerTitle {
font-size: clamp(24px, 2.2vw, 35px);
font-weight: 400;
letter-spacing: 10.5px;
text-transform: uppercase;
}
.crumbs {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: center;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-soft);
}
.crumbs > span {
display: inline-flex;
align-items: center;
gap: 10px;
}
.crumbChevron {
width: 10px;
height: 10px;
opacity: 0.55;
}
.crumbActive {
color: var(--color-black);
}
+37
View File
@@ -0,0 +1,37 @@
import styles from "./DetailBanner.module.css";
export default function DetailBanner({
title,
breadcrumbs,
}: {
title: string;
breadcrumbs: string[];
}) {
return (
<section className={styles.bannerWrap}>
<div className={styles.banner}>
<h1 className={styles.bannerTitle}>{title}</h1>
<nav className={styles.crumbs} aria-label="Breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={`${crumb}-${index}`}>
{index > 0 ? (
<img
className={styles.crumbChevron}
src="/images/icons/chevron.svg"
alt=""
/>
) : null}
<span
className={
index === breadcrumbs.length - 1 ? styles.crumbActive : undefined
}
>
{crumb}
</span>
</span>
))}
</nav>
</div>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
.footer {
background: var(--color-footer);
color: var(--color-white);
margin-top: 96px;
}
.inner {
max-width: var(--container-width);
margin: 0 auto;
padding: 48px var(--page-pad) 32px;
}
.grid {
display: grid;
grid-template-columns: 1.1fr 1fr 1.1fr 0.8fr;
gap: 28px 32px;
}
.heading {
font-size: 20px;
font-weight: 600;
margin-bottom: 10px;
}
.copy {
font-size: 14px;
font-weight: 400;
line-height: 1.35;
color: var(--color-footer-text);
max-width: 280px;
}
.phones {
display: flex;
flex-direction: column;
gap: 0;
}
.socials {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.socials img {
width: 25px;
height: 25px;
object-fit: contain;
}
.bottom {
margin-top: 32px;
padding-top: 18px;
border-top: 1px solid var(--color-footer-line);
display: flex;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
align-items: center;
}
.copyright {
font-size: 13px;
font-weight: 400;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
color: var(--color-soft);
font-size: 13px;
}
.links span {
color: var(--color-soft);
}
@media (max-width: 1100px) {
.inner {
padding: 56px var(--page-pad) 32px;
}
.grid {
grid-template-columns: 1fr 1fr;
}
.heading,
.copy,
.copyright,
.links {
font-size: 13px;
}
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
.bottom {
flex-direction: column;
}
}
+61
View File
@@ -0,0 +1,61 @@
import styles from "./Footer.module.css";
export default function Footer() {
return (
<footer className={styles.footer} id="contact">
<div className={styles.inner}>
<div className={styles.grid}>
<div>
<h2 className={styles.heading}>logo Here</h2>
<p className={styles.copy}>
In publishing and graphic design, Lorem ipsum is a placeholder.
</p>
</div>
<div>
<h2 className={styles.heading}>Phone Number :</h2>
<div className={`${styles.copy} ${styles.phones}`}>
<span>+98 937 622 0110</span>
<span>+98 912 123 4567</span>
</div>
</div>
<div>
<h2 className={styles.heading}>Location :</h2>
<p className={styles.copy}>
1 Sheikh Mohammed bin Rashid Blvd - Downtown Dubai - Dubai -
United Arab Emirates
</p>
</div>
<div>
<h2 className={styles.heading}>Social Media :</h2>
<div className={styles.socials}>
<a href="https://instagram.com" aria-label="Instagram">
<img src="/images/icons/instagram.png" alt="" width={25} height={25} />
</a>
<a href="https://t.me" aria-label="Telegram">
<img src="/images/icons/telegram.png" alt="" width={26} height={26} />
</a>
<a href="https://facebook.com" aria-label="Facebook">
<img src="/images/icons/facebook.png" alt="" width={20} height={20} />
</a>
</div>
</div>
</div>
<div className={styles.bottom}>
<p className={styles.copyright}>
Copy Rights By 2023 IranBay . All Rights Reserved site by Bradly
Lancaster.
</p>
<nav className={styles.links} aria-label="Footer">
<a href="/businesses">Categories</a>
<span>|</span>
<a href="/#vision">Vision</a>
<span>|</span>
<a href="/about">About us</a>
<span>|</span>
<a href="/contact">Contact us</a>
</nav>
</div>
</div>
</footer>
);
}
+434
View File
@@ -0,0 +1,434 @@
.header {
position: sticky;
top: 0;
z-index: 50;
background: var(--color-white);
overflow: visible;
}
.inner {
max-width: var(--container-width);
margin: 0 auto;
padding: 14px var(--page-pad);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.brandText {
display: flex;
flex-direction: column;
gap: 1px;
}
.brandName {
font-size: 15px;
font-weight: 400;
letter-spacing: 4px;
text-transform: uppercase;
line-height: 1.15;
}
.brandTag {
font-size: 8px;
font-weight: 300;
letter-spacing: 1.6px;
text-transform: uppercase;
}
.nav {
display: flex;
align-items: center;
gap: 22px;
flex: 1;
justify-content: center;
min-width: 0;
}
.navLink,
.menuTrigger {
font-size: 12px;
font-weight: 300;
letter-spacing: 1.6px;
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--color-black);
white-space: nowrap;
}
.homeIcon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.homeIcon img {
width: 16px;
height: 16px;
}
.dropdown {
position: relative;
}
.menuTrigger {
background: none;
position: relative;
}
.menuTriggerOpen::after {
content: "";
position: absolute;
left: 0;
right: 16px;
bottom: -14px;
height: 1px;
background: var(--color-black);
}
.chevron {
width: 7px;
height: 12px;
object-fit: contain;
transform: rotate(90deg);
}
.headerMega {
overflow: visible;
}
.megaHost {
position: static;
}
.actions {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.actionsDivider {
width: 1px;
height: 18px;
background: #d8d8d8;
margin: 0 2px;
}
.lang {
position: relative;
display: flex;
align-items: center;
height: 18px;
}
.langTrigger {
display: inline-flex;
align-items: center;
gap: 6px;
height: 18px;
padding: 0;
font-size: 14px;
font-weight: 300;
line-height: 18px;
letter-spacing: 2.4px;
text-transform: uppercase;
color: var(--color-black);
background: none;
border: 0;
}
.langChevron {
width: 8px;
height: 12px;
object-fit: contain;
transform: rotate(90deg);
opacity: 0.7;
transition: transform 0.2s ease;
}
.langChevronOpen {
transform: rotate(-90deg);
}
.langMenu {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 60;
min-width: 72px;
margin: 0;
padding: 6px 0;
list-style: none;
background: var(--color-white);
border: 1px solid #eee;
border-radius: 5px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.langOption,
.langOptionActive {
width: 100%;
padding: 8px 14px;
font-size: 12px;
font-weight: 300;
letter-spacing: 2.4px;
text-transform: uppercase;
text-align: left;
color: var(--color-soft);
background: none;
border: 0;
}
.langOption:hover,
.langOptionActive {
color: var(--color-black);
background: #f6f6f6;
}
.iconBtn {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.iconBtn img {
width: 18px;
height: 18px;
object-fit: contain;
}
.iconBtn img.userIcon {
width: 16px;
height: 16px;
}
.account {
position: relative;
display: flex;
align-items: center;
gap: 10px;
}
.accountName {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 140px;
padding: 0;
border: 0;
background: none;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-black);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.accountChevron {
width: 7px;
height: 10px;
object-fit: contain;
transform: rotate(90deg);
opacity: 0.65;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.accountChevronOpen {
transform: rotate(-90deg);
}
.accountTrigger {
width: 28px;
height: 28px;
border-radius: 5px;
background: #f1f1f1;
}
.accountMenu {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 60;
min-width: 196px;
padding: 8px 0;
background: var(--color-white);
border: 1px solid #eee;
border-radius: 5px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.accountItem {
display: block;
width: 100%;
padding: 10px 16px;
border: 0;
background: none;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: left;
color: var(--color-soft);
cursor: pointer;
}
.accountItem:hover,
.accountItem:focus-visible {
color: var(--color-black);
background: #f6f6f6;
}
.accountItemDanger {
color: #8e8e8e;
}
.mobileAccount {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 8px;
border-top: 1px solid var(--color-card);
}
.mobileAccountName {
margin: 0;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-black);
}
.burger {
display: none;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
}
.burger img {
width: 22px;
height: 22px;
}
.mobileNav {
display: none;
max-width: var(--container-width);
margin: 0 auto;
}
@media (max-width: 980px) {
.nav,
.actions {
display: none;
}
.burger {
display: flex;
}
.inner {
padding: 12px var(--page-pad);
}
.logo {
width: 36px;
height: 36px;
}
.mobileNav {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px var(--page-pad) 24px;
border-top: 1px solid var(--color-card);
}
.mobileNav a,
.mobileNav button {
font-size: 14px;
font-weight: 300;
letter-spacing: 2.4px;
text-transform: uppercase;
text-align: left;
}
.mobileActions {
display: flex;
gap: 20px;
padding-top: 8px;
}
.mobileLang {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
}
.mobileLang > span {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--color-soft);
}
.mobileLangOptions {
display: flex;
gap: 8px;
}
.mobileLangOptions .langOption,
.mobileLangOptions .langOptionActive {
width: auto;
min-width: 48px;
border-radius: 5px;
text-align: center;
}
.mobileMega {
display: grid;
gap: 16px;
}
.mobileMega p {
font-size: 12px;
letter-spacing: 2px;
margin-bottom: 6px;
}
.mobileMega a {
display: block;
padding: 4px 0;
font-size: 13px;
}
}
+517
View File
@@ -0,0 +1,517 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { megaMenuTabs, navLinks, type MegaTab } from "@/data/home";
import {
fetchCurrentUser,
firstNameFor,
getAdminDashboardUrl,
getBusinessDashboardUrl,
getCustomerDashboardUrl,
type WebsiteAuthUser,
} from "@/lib/auth-user";
import { searchMeshkeeCategories } from "@/lib/meshkee-categories";
import { buildMegaMenuTabs } from "@/lib/mega-menu";
import { clearTokens, getCustomerLoginUrl } from "@/lib/session";
import MegaMenu from "./MegaMenu";
import styles from "./Header.module.css";
const languages = [
{ code: "en", label: "en", dir: "ltr" as const },
{ code: "ar", label: "ar", dir: "rtl" as const },
{ code: "fa", label: "fa", dir: "rtl" as const },
];
function pickDefaultTabId(tabs: MegaTab[]) {
return (
tabs.find((tab) => tab.id === "stores")?.id ??
tabs[0]?.id ??
megaMenuTabs[0].id
);
}
export default function Header() {
const [open, setOpen] = useState(false);
const [megaOpen, setMegaOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const [language, setLanguage] = useState(languages[0]);
const [tabs, setTabs] = useState<MegaTab[]>(megaMenuTabs);
const [menuImages, setMenuImages] = useState<string[]>([]);
const [activeTabId, setActiveTabId] = useState(pickDefaultTabId(megaMenuTabs));
const [user, setUser] = useState<WebsiteAuthUser | null>(null);
const [authReady, setAuthReady] = useState(false);
const closeTimer = useRef<number | undefined>(undefined);
const headerRef = useRef<HTMLElement>(null);
const langRef = useRef<HTMLDivElement>(null);
const accountRef = useRef<HTMLDivElement>(null);
const activeTab =
tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? megaMenuTabs[0];
const customerUrl = getCustomerDashboardUrl();
const adminUrl = getAdminDashboardUrl();
const businessUrl = getBusinessDashboardUrl();
const firstName = firstNameFor(user);
const isSuperAdmin = user?.dashboard === "super_admin";
const ownsBusiness = Boolean(
user?.businesses.some((item) => item.isOwner) ||
user?.dashboard === "business",
);
const showMega = () => {
window.clearTimeout(closeTimer.current);
setMegaOpen(true);
};
const hideMega = () => {
closeTimer.current = window.setTimeout(() => setMegaOpen(false), 160);
};
const closeMega = () => {
window.clearTimeout(closeTimer.current);
setMegaOpen(false);
};
const selectLanguage = (code: string) => {
const next = languages.find((item) => item.code === code) ?? languages[0];
setLanguage(next);
setLangOpen(false);
document.documentElement.lang = next.code;
document.documentElement.dir = next.dir;
};
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
const categories = await searchMeshkeeCategories("", controller.signal);
if (controller.signal.aborted) return;
const nextTabs = buildMegaMenuTabs(categories);
if (!nextTabs.length) return;
setTabs(nextTabs);
setActiveTabId((current) =>
nextTabs.some((tab) => tab.id === current)
? current
: pickDefaultTabId(nextTabs),
);
} catch {
/* keep static fallback */
}
})();
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
const api =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
void (async () => {
try {
const response = await fetch(`${api}/website/home-media`, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok || controller.signal.aborted) return;
const data = (await response.json()) as {
menuImages?: { imageUrl?: string }[];
};
if (controller.signal.aborted) return;
setMenuImages(
(data.menuImages ?? []).map((item) => item.imageUrl?.trim() ?? ""),
);
} catch {
/* keep built-in menu photos */
}
})();
return () => controller.abort();
}, []);
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
const next = await fetchCurrentUser(controller.signal);
if (!controller.signal.aborted) setUser(next);
} catch {
if (!controller.signal.aborted) setUser(null);
} finally {
if (!controller.signal.aborted) setAuthReady(true);
}
})();
return () => controller.abort();
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMega();
setLangOpen(false);
setAccountOpen(false);
}
};
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!headerRef.current?.contains(target)) {
setMegaOpen(false);
}
if (!langRef.current?.contains(target)) {
setLangOpen(false);
}
if (!accountRef.current?.contains(target)) {
setAccountOpen(false);
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointerDown);
};
}, []);
function logout() {
clearTokens();
setUser(null);
setAccountOpen(false);
}
return (
<header
ref={headerRef}
className={`${styles.header} ${megaOpen ? styles.headerMega : ""}`}
onMouseLeave={hideMega}
>
<div className={styles.inner}>
<a href="/" className={styles.brand} onMouseEnter={closeMega}>
<img
className={styles.logo}
src="/images/icons/logo.png"
alt="IranBay"
width={40}
height={40}
/>
<span className={styles.brandText}>
<span className={styles.brandName}>IranBay</span>
<span className={styles.brandTag}>A gate to iran market</span>
</span>
</a>
<nav className={styles.nav} aria-label="Primary">
{navLinks.map((link) =>
"hasMenu" in link && link.hasMenu ? (
<div className={styles.dropdown} key={link.label}>
<button
className={`${styles.menuTrigger} ${megaOpen ? styles.menuTriggerOpen : ""}`}
type="button"
aria-expanded={megaOpen}
aria-controls="categories-mega"
onMouseEnter={showMega}
onFocus={showMega}
onClick={showMega}
>
{link.label}
<img
className={styles.chevron}
src="/images/icons/chevron.svg"
alt=""
width={7}
height={12}
/>
</button>
</div>
) : (
<a
key={link.label}
href={link.href}
className={styles.navLink}
onMouseEnter={closeMega}
onFocus={closeMega}
>
{link.label}
</a>
),
)}
</nav>
<div className={styles.actions} onMouseEnter={closeMega}>
<a href="/register" className={styles.iconBtn} aria-label="Join">
<img src="/images/icons/add.svg" alt="" width={30} height={30} />
</a>
<button className={styles.iconBtn} type="button" aria-label="Search">
<img src="/images/icons/search.svg" alt="" width={30} height={30} />
</button>
{authReady && user ? (
<div className={styles.account} ref={accountRef}>
<button
type="button"
className={styles.accountName}
aria-haspopup="menu"
aria-expanded={accountOpen}
onClick={() => setAccountOpen((value) => !value)}
>
{firstName}
<img
className={`${styles.accountChevron} ${accountOpen ? styles.accountChevronOpen : ""}`}
src="/images/icons/chevron.svg"
alt=""
width={7}
height={12}
/>
</button>
{accountOpen ? (
<div className={styles.accountMenu} role="menu">
<a
role="menuitem"
href={customerUrl}
className={styles.accountItem}
onClick={() => setAccountOpen(false)}
>
Customer dashboard
</a>
<a
role="menuitem"
href={`${customerUrl}/profile`}
className={styles.accountItem}
onClick={() => setAccountOpen(false)}
>
My profile
</a>
<a
role="menuitem"
href={`${customerUrl}/forgot-password`}
className={styles.accountItem}
onClick={() => setAccountOpen(false)}
>
Change password
</a>
{ownsBusiness ? (
<a
role="menuitem"
href={businessUrl}
className={styles.accountItem}
onClick={() => setAccountOpen(false)}
>
Business dashboard
</a>
) : null}
{isSuperAdmin ? (
<a
role="menuitem"
href={adminUrl}
className={styles.accountItem}
onClick={() => setAccountOpen(false)}
>
Super admin
</a>
) : null}
<button
type="button"
role="menuitem"
className={`${styles.accountItem} ${styles.accountItemDanger}`}
onClick={logout}
>
Log out
</button>
</div>
) : null}
</div>
) : (
<a
href={customerUrl}
className={styles.iconBtn}
aria-label="Customer dashboard"
title="Customer dashboard"
>
<img
className={styles.userIcon}
src="/images/icons/user.svg"
alt=""
width={16}
height={16}
/>
</a>
)}
<span className={styles.actionsDivider} aria-hidden />
<div className={styles.lang} ref={langRef}>
<button
className={styles.langTrigger}
type="button"
aria-haspopup="listbox"
aria-expanded={langOpen}
aria-label="Select language"
onClick={() => setLangOpen((value) => !value)}
>
{language.label}
<img
className={`${styles.langChevron} ${langOpen ? styles.langChevronOpen : ""}`}
src="/images/icons/chevron.svg"
alt=""
width={7}
height={12}
/>
</button>
{langOpen ? (
<ul className={styles.langMenu} role="listbox" aria-label="Languages">
{languages.map((item) => (
<li
key={item.code}
role="option"
aria-selected={item.code === language.code}
>
<button
type="button"
className={
item.code === language.code
? styles.langOptionActive
: styles.langOption
}
onClick={() => selectLanguage(item.code)}
>
{item.label}
</button>
</li>
))}
</ul>
) : null}
</div>
</div>
<button
className={styles.burger}
type="button"
aria-label="Open menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
</button>
</div>
{megaOpen ? (
<div className={styles.megaHost} onMouseEnter={showMega}>
<MegaMenu
tabs={tabs}
tab={activeTab}
menuImages={menuImages}
onTabHover={setActiveTabId}
/>
</div>
) : null}
{open ? (
<nav className={styles.mobileNav} aria-label="Mobile">
<a href="/" onClick={() => setOpen(false)}>
Home
</a>
{navLinks.map((link) => (
<a key={link.label} href={link.href} onClick={() => setOpen(false)}>
{link.label}
</a>
))}
<div className={styles.mobileMega}>
{tabs.map((tab) => (
<div key={tab.id}>
<p>{tab.label}</p>
{tab.groups.map((group) => (
<div key={`${tab.id}-${group.title}`}>
<p>{group.title}</p>
{group.links.map((item) => (
<a
href={item.href}
key={`${tab.id}-${group.title}-${item.label}-${item.href}`}
onClick={() => setOpen(false)}
>
{item.label}
</a>
))}
</div>
))}
</div>
))}
</div>
<div className={styles.mobileLang}>
<span>Language</span>
<div className={styles.mobileLangOptions}>
{languages.map((item) => (
<button
key={item.code}
type="button"
className={
item.code === language.code
? styles.langOptionActive
: styles.langOption
}
onClick={() => {
selectLanguage(item.code);
setOpen(false);
}}
>
{item.label}
</button>
))}
</div>
</div>
<div className={styles.mobileAccount}>
{user ? (
<>
<p className={styles.mobileAccountName}>{firstName}</p>
<a href={customerUrl} onClick={() => setOpen(false)}>
Customer dashboard
</a>
<a
href={`${customerUrl}/profile`}
onClick={() => setOpen(false)}
>
My profile
</a>
<a
href={`${customerUrl}/forgot-password`}
onClick={() => setOpen(false)}
>
Change password
</a>
{ownsBusiness ? (
<a href={businessUrl} onClick={() => setOpen(false)}>
Business dashboard
</a>
) : null}
{isSuperAdmin ? (
<a href={adminUrl} onClick={() => setOpen(false)}>
Super admin
</a>
) : null}
<button
type="button"
onClick={() => {
logout();
setOpen(false);
}}
>
Log out
</button>
</>
) : (
<a
href={getCustomerLoginUrl("/")}
onClick={() => setOpen(false)}
>
Sign in
</a>
)}
</div>
<div className={styles.mobileActions}>
<a href="/register" className={styles.iconBtn} aria-label="Join">
<img src="/images/icons/add.svg" alt="" width={30} height={30} />
</a>
<button className={styles.iconBtn} type="button" aria-label="Search">
<img src="/images/icons/search.svg" alt="" width={30} height={30} />
</button>
</div>
</nav>
) : null}
</header>
);
}
+153
View File
@@ -0,0 +1,153 @@
.panel {
position: absolute;
top: calc(100% - 1px);
left: 50%;
transform: translateX(-50%);
width: min(var(--container-width), calc(100vw - 2 * var(--page-pad)));
background: var(--color-white);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.1);
z-index: 60;
}
.inner {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
min-height: 360px;
}
.tabs {
display: flex;
flex-direction: column;
padding: 22px 0;
border-right: 1px solid var(--color-card);
}
.tab {
display: grid;
grid-template-columns: 18px 1fr 10px;
align-items: center;
gap: 12px;
width: 100%;
padding: 13px 18px 13px 22px;
font-size: 12px;
font-weight: 300;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--color-body);
text-align: left;
}
.tab img {
width: 15px;
height: 15px;
object-fit: contain;
opacity: 0.55;
}
.tabActive {
background: var(--color-card);
color: var(--color-black);
}
.tabActive img {
opacity: 1;
}
.tabChevron {
width: 7px;
height: 12px;
justify-self: end;
opacity: 1;
}
.tabChevronSlot {
width: 7px;
height: 12px;
}
.body {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(240px, 0.8fr);
gap: 8px;
padding: 32px 24px 28px 40px;
}
.groups {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px 40px;
align-content: start;
}
.group h3 {
font-size: 13px;
font-weight: 500;
letter-spacing: 1.8px;
text-transform: uppercase;
margin-bottom: 10px;
}
.group a {
display: block;
font-size: 12px;
font-weight: 300;
letter-spacing: 1.5px;
text-transform: uppercase;
padding: 4px 0;
color: var(--color-muted);
}
.group a:hover {
color: var(--color-black);
}
.featured {
position: relative;
min-height: 300px;
overflow: hidden;
}
.featuredTitle {
position: relative;
z-index: 1;
max-width: 170px;
font-size: 26px;
font-weight: 300;
letter-spacing: 3.5px;
text-transform: uppercase;
line-height: 1.2;
}
.featuredTitle span {
display: block;
}
.featuredImage {
position: absolute;
right: 0;
bottom: 0;
height: 94%;
width: auto;
max-width: 72%;
object-fit: contain;
pointer-events: none;
}
.dress {
right: -2%;
height: 108%;
}
.drum,
.notebook,
.shoes {
right: 4%;
height: 82%;
bottom: 8%;
}
@media (max-width: 980px) {
.panel {
display: none;
}
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import type { MegaTab } from "@/data/home";
import styles from "./MegaMenu.module.css";
const imageClassMap: Record<string, string> = {
dress: styles.dress,
drum: styles.drum,
notebook: styles.notebook,
shoes: styles.shoes,
};
export default function MegaMenu({
tabs,
tab,
menuImages = [],
onTabHover,
}: {
tabs: MegaTab[];
tab: MegaTab;
menuImages?: string[];
onTabHover: (id: string) => void;
}) {
const tabIndex = Math.max(
0,
tabs.findIndex((item) => item.id === tab.id),
);
const uploaded =
menuImages[tabIndex]?.trim() ||
(tabIndex !== 3 ? menuImages[3]?.trim() : "") ||
"";
const imageSrc = uploaded || tab.featured.image;
const imageClass =
!uploaded && tab.featured.imageClass
? imageClassMap[tab.featured.imageClass]
: undefined;
return (
<div className={styles.panel} id="categories-mega">
<div className={styles.inner}>
<div className={styles.tabs} role="tablist" aria-label="Category types">
{tabs.map((item) => {
const active = item.id === tab.id;
return (
<button
key={item.id}
type="button"
role="tab"
id={`mega-tab-${item.id}`}
aria-selected={active}
aria-controls="mega-tab-panel"
className={`${styles.tab} ${active ? styles.tabActive : ""}`}
onMouseEnter={() => onTabHover(item.id)}
onFocus={() => onTabHover(item.id)}
onClick={() => onTabHover(item.id)}
>
<img src={item.icon} alt="" width={15} height={15} />
<span>{item.label}</span>
{active ? (
<img
className={styles.tabChevron}
src="/images/icons/chevron.svg"
alt=""
width={7}
height={12}
/>
) : (
<span className={styles.tabChevronSlot} />
)}
</button>
);
})}
</div>
<div
className={styles.body}
role="tabpanel"
id="mega-tab-panel"
aria-labelledby={`mega-tab-${tab.id}`}
>
<div className={styles.groups}>
{tab.groups.map((group, index) => (
<div className={styles.group} key={`${group.title}-${index}`}>
<h3>{group.title}</h3>
{group.links.map((link, linkIndex) => (
<a href={link.href} key={`${link.label}-${linkIndex}`}>
{link.label}
</a>
))}
</div>
))}
</div>
<div className={styles.featured}>
<img
className={`${styles.featuredImage} ${imageClass ?? ""}`}
src={imageSrc}
alt=""
/>
</div>
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
.gallery {
display: flex;
flex-direction: column;
gap: 16px;
}
.main {
position: relative;
width: 100%;
aspect-ratio: 1;
border: 1px solid #e4e4e4;
border-radius: 5px;
overflow: hidden;
background: #fff;
padding: 4.2%;
}
.mainInner {
position: relative;
width: 100%;
height: 100%;
border-radius: 5px;
overflow: hidden;
}
.mainImg {
object-fit: cover;
}
.thumbs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 144px));
gap: 12px;
}
.thumb,
.thumbActive {
position: relative;
aspect-ratio: 1;
border: 1px solid #e4e4e4;
border-radius: 5px;
overflow: hidden;
background: #fff;
padding: 10px;
}
.thumbActive {
border-color: #111;
}
.thumbInner {
position: relative;
display: block;
width: 100%;
height: 100%;
border-radius: 5px;
overflow: hidden;
}
.thumbImg {
object-fit: cover;
transition: transform 0.3s ease;
}
.thumb:hover .thumbImg {
transform: scale(1.04);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import styles from "./PortfolioGallery.module.css";
export default function PortfolioGallery({ images }: { images: string[] }) {
const [active, setActive] = useState(0);
const thumbs = images.slice(0, 3);
return (
<div className={styles.gallery}>
<div className={styles.main}>
<div className={styles.mainInner}>
<Image
src={images[active] ?? images[0]}
alt=""
fill
className={styles.mainImg}
sizes="(max-width: 900px) 100vw, 830px"
priority
/>
</div>
</div>
<div className={styles.thumbs}>
{thumbs.map((src, index) => (
<button
key={`${src}-${index}`}
type="button"
className={active === index ? styles.thumbActive : styles.thumb}
onClick={() => setActive(index)}
aria-label={`Show image ${index + 1}`}
>
<span className={styles.thumbInner}>
<Image
src={src}
alt=""
fill
className={styles.thumbImg}
sizes="144px"
/>
</span>
</button>
))}
</div>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
.card {
display: flex;
flex-direction: column;
min-width: 0;
height: fit-content;
align-self: start;
}
.link {
display: flex;
flex-direction: column;
color: inherit;
text-decoration: none;
min-width: 0;
}
.coverWrap {
position: relative;
height: 150px;
overflow: hidden;
border-radius: 5px 5px 0 0;
}
.cover {
object-fit: cover;
}
.coverWrap > img.cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.body {
position: relative;
z-index: 1;
border: 1px solid var(--color-card);
border-top: 0;
border-radius: 0 0 5px 5px;
padding: 0 16px 14px;
background: var(--color-white);
overflow: visible;
}
/* Hovering the cover (or anywhere on the card) also runs the body wipe. */
.link:hover .body:global(.tile-hover)::before,
.link:focus-within .body:global(.tile-hover)::before {
clip-path: inset(0 0 0 0);
}
.link:hover .body:global(.tile-hover) :is(h1, h2, h3, p, span, strong, a),
.link:focus-within .body:global(.tile-hover) :is(h1, h2, h3, p, span, strong, a) {
color: var(--color-white);
}
.info {
position: relative;
z-index: 2;
margin-top: -36px;
background: var(--color-card);
border-radius: 5px;
padding: 12px 14px 10px;
}
.info > * {
position: relative;
z-index: 1;
}
/* Keep the gray identity panel readable while the body wipe fills below. */
.link:hover .body:global(.tile-hover) .info .name,
.link:focus-within .body:global(.tile-hover) .info .name {
color: var(--color-black);
}
.link:hover .body:global(.tile-hover) .info .tagline,
.link:focus-within .body:global(.tile-hover) .info .tagline {
color: var(--color-muted);
}
.link:hover .body:global(.tile-hover) .info .meta,
.link:focus-within .body:global(.tile-hover) .info .meta,
.link:hover .body:global(.tile-hover) .info .products,
.link:focus-within .body:global(.tile-hover) .info .products,
.link:hover .body:global(.tile-hover) .info .since,
.link:focus-within .body:global(.tile-hover) .info .since,
.link:hover .body:global(.tile-hover) .info .products strong,
.link:focus-within .body:global(.tile-hover) .info .products strong,
.link:hover .body:global(.tile-hover) .info .since strong,
.link:focus-within .body:global(.tile-hover) .info .since strong {
color: var(--color-dark);
}
.link:hover .body:global(.tile-hover) .info .divider,
.link:focus-within .body:global(.tile-hover) .info .divider {
background: #cfcfcf;
}
.identity {
display: flex;
align-items: center;
gap: 10px;
}
.logo {
width: 36px;
height: 30px;
object-fit: contain;
flex-shrink: 0;
}
.logo.wide {
width: 62px;
height: 30px;
}
.logo.round {
width: 42px;
height: 42px;
object-fit: cover;
margin-top: -12px;
}
.logo.square {
width: 42px;
height: 42px;
object-fit: cover;
margin-top: -6px;
}
.name {
font-size: 14px;
font-weight: 400;
letter-spacing: 2.4px;
text-transform: uppercase;
line-height: 1.2;
}
.tagline {
font-size: 11px;
font-weight: 300;
color: var(--color-muted);
margin-top: 2px;
transition: color 0.3s ease;
}
.meta {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
color: var(--color-dark);
transition: color 0.3s ease;
}
.products strong,
.since strong {
font-weight: 500;
}
.products {
font-size: 10px;
font-weight: 300;
}
.products strong {
font-size: 12px;
}
.since {
font-size: 10px;
font-weight: 300;
}
.since strong {
font-size: 13px;
}
.divider {
width: 1px;
height: 14px;
background: #cfcfcf;
}
.description {
position: relative;
z-index: 1;
margin-top: 12px;
font-size: 13px;
font-weight: 300;
line-height: 18px;
color: var(--color-muted);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s ease;
}
.rule {
position: relative;
z-index: 1;
height: 1px;
background: var(--color-card);
margin: 10px 0 8px;
transition: background 0.3s ease;
}
.link:hover .body:global(.tile-hover) .rule,
.link:focus-within .body:global(.tile-hover) .rule {
background: rgba(255, 255, 255, 0.2);
}
.footerRow {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tag {
flex: 1;
min-width: 0;
font-size: 11px;
font-weight: 300;
color: var(--color-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s ease;
}
.rowArrow {
width: 8px;
height: 14px;
object-fit: contain;
flex-shrink: 0;
opacity: 0.55;
transition: opacity 0.3s ease, filter 0.3s ease;
}
.link:hover .body:global(.tile-hover) .rowArrow,
.link:focus-within .body:global(.tile-hover) .rowArrow {
opacity: 1;
filter: brightness(0) invert(1);
}
@media (prefers-reduced-motion: reduce) {
.rule,
.tag,
.rowArrow,
.description {
transition: none;
}
}
+77
View File
@@ -0,0 +1,77 @@
import Image from "next/image";
import Link from "next/link";
import type { Producer } from "@/data/home";
import styles from "./ProducerCard.module.css";
function isRemoteSrc(src: string) {
return src.startsWith("http://") || src.startsWith("https://");
}
export default function ProducerCard({ producer }: { producer: Producer }) {
return (
<article className={styles.card}>
<Link href={`/businesses/${producer.slug}`} className={styles.link}>
<div className={styles.coverWrap}>
{isRemoteSrc(producer.cover) ? (
<img
src={producer.cover}
alt=""
className={styles.cover}
/>
) : (
<Image
src={producer.cover}
alt=""
fill
className={styles.cover}
sizes="(max-width: 720px) 100vw, (max-width: 1100px) 50vw, 430px"
/>
)}
</div>
<div className={`tile-hover ${styles.body}`}>
<div className={styles.info}>
<div className={styles.identity}>
<img
className={`${styles.logo} ${producer.logoClass ? styles[producer.logoClass] : ""}`}
src={producer.logo}
alt=""
width={40}
height={33}
/>
<div>
<h3 className={styles.name}>{producer.name}</h3>
{producer.tagline ? (
<p className={styles.tagline}>{producer.tagline}</p>
) : null}
</div>
</div>
<div className={styles.meta}>
<p className={styles.products}>
<strong>{producer.products} </strong>
Products
</p>
<span className={styles.divider} />
<p className={styles.since}>
Since <strong>{producer.since}</strong>
</p>
</div>
</div>
{producer.description ? (
<p className={styles.description}>{producer.description}</p>
) : null}
<div className={styles.rule} />
<div className={styles.footerRow}>
<span className={styles.tag}>{producer.category}</span>
<img
className={styles.rowArrow}
src="/images/icons/chevron.svg"
alt=""
width={8}
height={14}
/>
</div>
</div>
</Link>
</article>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import {
clearTokens,
getAccessToken,
getCustomerLoginUrl,
} from "@/lib/session";
const MESHKEE_API_URL =
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
"https://api.meshkee.com/api/v1";
async function verifySession(accessToken: string, signal: AbortSignal) {
const response = await fetch(`${MESHKEE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
signal,
cache: "no-store",
});
return response.ok;
}
export default function RequireAuth({
children,
returnPath = "/register",
}: {
children: ReactNode;
returnPath?: string;
}) {
const [ready, setReady] = useState(false);
useEffect(() => {
const controller = new AbortController();
async function ensureAuth() {
const token = getAccessToken();
if (!token) {
window.location.replace(getCustomerLoginUrl(returnPath));
return;
}
try {
const ok = await verifySession(token, controller.signal);
if (controller.signal.aborted) return;
if (!ok) {
clearTokens();
window.location.replace(getCustomerLoginUrl(returnPath));
return;
}
setReady(true);
} catch {
if (controller.signal.aborted) return;
clearTokens();
window.location.replace(getCustomerLoginUrl(returnPath));
}
}
void ensureAuth();
return () => controller.abort();
}, [returnPath]);
if (!ready) {
return (
<div
style={{
minHeight: "50vh",
display: "grid",
placeItems: "center",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
}}
>
Checking account…
</div>
);
}
return children;
}
+26
View File
@@ -0,0 +1,26 @@
.actions {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.action {
min-width: 135px;
min-height: 35px;
padding: 0 18px;
font-size: 16px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.action img {
width: 18px;
height: 18px;
object-fit: contain;
transition: filter 0.3s ease;
}
:global(.filter-chip).action:hover img,
:global(.filter-chip).action:focus-visible img {
filter: invert(1);
}
+16
View File
@@ -0,0 +1,16 @@
import styles from "./ShareLikeActions.module.css";
export default function ShareLikeActions() {
return (
<div className={styles.actions}>
<button className={`filter-chip ${styles.action}`} type="button">
<img src="/images/icons/share.svg" alt="" width={18} height={18} />
share
</button>
<button className={`filter-chip ${styles.action}`} type="button">
<img src="/images/icons/heart.svg" alt="" width={18} height={18} />
LIKE
</button>
</div>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { featuredProducers } from "@/data/home";
import { defaultBlogPosts, defaultPortfolios } from "@/data/details";
export type BusinessTabId =
| "products"
| "contact"
| "blog"
| "gallery"
| "portfolios";
export type BusinessDetail = {
slug: string;
name: string;
tagline: string;
breadcrumbs: string[];
hero: string;
logo: string;
since: string;
province: string;
activeProducts: string;
telephone: string;
description: string;
productCategories: string[];
products: {
id: string;
title: string;
image: string;
rating: string;
minOrder: string;
shipsIn: string;
category: string;
}[];
contact: {
address: string;
landlines: string[];
socials: { network: string; handle: string; icon: string }[];
map: string;
};
blogCategories: string[];
posts: {
id: string;
slug: string;
title: string;
excerpt: string;
image: string;
date: string;
author: string;
}[];
gallery: { id: string; image: string; tall?: boolean; medium?: boolean }[];
portfolioCategories: string[];
portfolios: {
id: string;
slug: string;
title: string;
description: string;
image: string;
}[];
};
export const businessTabs: { id: BusinessTabId; label: string }[] = [
{ id: "products", label: "Products" },
{ id: "contact", label: "Contact info" },
{ id: "blog", label: "blog" },
{ id: "gallery", label: "gallery" },
{ id: "portfolios", label: "Portfolios" },
];
const productImage = "/images/business/product-necklace.png";
const productAlt = "/images/business/product-alt.png";
const productRing = "/images/business/product-ring.png";
const galleryImage = "/images/business/gallery.jpg";
const blogImage = "/images/business/blog.jpg";
export const businesses: BusinessDetail[] = [
{
slug: "ayoubi-gallery",
name: "ayoubi gallery",
tagline: "we produce bread materials",
breadcrumbs: ["producers", "industry", "Food", "processed"],
hero: "/images/business/hero.jpg",
logo: "/images/business/logo.png",
since: "2018",
province: "Tehran",
activeProducts: "121",
telephone: "+9821-31634",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
productCategories: ["All", "Neckleses", "Earrings", "Bracelet"],
products: Array.from({ length: 6 }, (_, i) => ({
id: `p-${i + 1}`,
title: "Gold scarlet necklace",
image: [productImage, productAlt, productRing][i % 3],
rating: "4.3",
minOrder: "3",
shipsIn: "7",
category: ["Neckleses", "Earrings", "Bracelet"][i % 3],
})),
contact: {
address: "Tehran, Jordan St., beside Sina Bank, number 27",
landlines: ["+98 212243248", "+98 212243249"],
socials: [
{
network: "instagram",
handle: "ayoubi.jewelery",
icon: "/images/icons/instagram.png",
},
{
network: "Telegram",
handle: "ayoubi.jewelery",
icon: "/images/icons/telegram.png",
},
{
network: "Facebook",
handle: "ayoubi.jewelery",
icon: "/images/icons/facebook.png",
},
{
network: "Whatsapp",
handle: "ayoubi.jewelery",
icon: "/images/icons/telegram.png",
},
],
map: "/images/business/map.png",
},
blogCategories: ["All", "Neckleses", "Earrings", "Bracelet"],
posts: Array.from({ length: 6 }, (_, i) => ({
id: `b-${i + 1}`,
slug: i === 0 ? "gold-scarlet-necklace" : `gold-scarlet-necklace-${i + 1}`,
title: "Gold scarlet necklace",
excerpt:
"we will show your business to verity of visitor around the world.we will show your business to verity of visitor around the world.we will show your business to verity of visitor around the world.",
image: "/images/blog/thumb.jpg",
date: "September 11",
author: "Mohammadi",
})),
gallery: [
{ id: "g1", image: galleryImage, tall: true },
{ id: "g2", image: productAlt },
{ id: "g3", image: blogImage, medium: true },
{ id: "g4", image: productImage },
{ id: "g5", image: "/images/producers/cover-jewelry.png", tall: true },
{ id: "g6", image: productRing, tall: true },
{ id: "g7", image: galleryImage },
],
portfolioCategories: ["All", "Ring", "Earrings", "Bracelet"],
portfolios: [
{
id: "pf1",
slug: "gold-scarlet-necklace",
title: "Gold scarlet necklace",
description: "we will show your business to verity of visitor ",
image: "/images/portfolio/main.jpg",
},
{
id: "pf2",
slug: "gold-scarlet-necklace-2",
title: "Gold scarlet necklace",
description: "we will show your business to verity of visitor ",
image: "/images/business/portfolio-1.jpg",
},
{
id: "pf3",
slug: "gold-scarlet-necklace-3",
title: "Gold scarlet necklace",
description: "we will show your business to verity of visitor ",
image: "/images/business/portfolio-3.jpg",
},
],
},
];
function fromProducer(slug: string): BusinessDetail | undefined {
const producer = featuredProducers.find((p) => p.slug === slug);
if (!producer) return undefined;
const base = businesses[0];
return {
...base,
slug: producer.slug,
name: producer.name,
tagline: producer.tagline,
logo: producer.logo,
activeProducts: producer.products,
since: producer.since,
};
}
export function getBusinessBySlug(slug: string) {
return businesses.find((b) => b.slug === slug) ?? fromProducer(slug);
}
export function getAllBusinessSlugs() {
const detailSlugs = businesses.map((b) => b.slug);
const producerSlugs = featuredProducers.map((p) => p.slug);
return [...new Set([...detailSlugs, ...producerSlugs])];
}
export function getBusinessBlogPost(businessSlug: string, postSlug: string) {
const business = getBusinessBySlug(businessSlug);
if (!business) return null;
const list = defaultBlogPosts.map((post, i) => ({
...post,
image: business.posts[i]?.image ?? post.image,
slug: business.posts[i]?.slug ?? post.slug,
}));
const post = list.find((p) => p.slug === postSlug);
if (!post) return null;
const related = list.filter((p) => p.slug !== post.slug).slice(0, 4);
return { business, post: { ...post, hero: "/images/blog/hero.jpg" }, related };
}
export function getBusinessPortfolio(businessSlug: string, portfolioSlug: string) {
const business = getBusinessBySlug(businessSlug);
if (!business) return null;
const list = defaultPortfolios.map((item, i) => ({
...item,
slug: business.portfolios[i]?.slug ?? item.slug,
image: business.portfolios[i]?.image ?? item.image,
images:
i === 0
? item.images
: [
business.portfolios[i]?.image ?? item.image,
...item.images.slice(1),
],
}));
const portfolio = list.find((p) => p.slug === portfolioSlug);
if (!portfolio) return null;
return { business, portfolio };
}
+116
View File
@@ -0,0 +1,116 @@
export type BlogPost = {
slug: string;
title: string;
excerpt: string;
image: string;
date: string;
author: string;
category: string;
intro: string;
body: { type: "p" | "quote" | "rich"; text: string; linkText?: string; linkHref?: string; afterLink?: string }[];
};
export type PortfolioItem = {
slug: string;
title: string;
description: string;
image: string;
images: string[];
date: string;
author: string;
category: string;
body: { type: "p" | "quote" | "rich"; text: string; linkText?: string; linkHref?: string; afterLink?: string }[];
};
export const blogDetailBody: BlogPost["body"] = [
{
type: "p",
text: "Lorem ipsum, or lipsum as it is sometimes known, is dummy text used in laying out print, graphic or web designs. The passage is attributed to an unknown typesetter in the 15th century who is thought to have scrambled parts of Cicero's De Finibus Bonorum et Malorum for use in a type specimen book. It usually begins with:",
},
{
type: "quote",
text: "“Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.”",
},
{
type: "rich",
text: "The purpose of lorem ipsum is to create a natural looking block of text (sentence, paragraph, page, etc.) that doesn't distract from the layout. A practice not without ",
linkText: "controversy",
linkHref: "https://loremipsum.io/#controversy",
afterLink:
", laying out pages with meaningless filler text can be very useful when the focus is meant to be on design, not content.",
},
{
type: "rich",
text: "The passage experienced a surge in popularity during the 1960s when Letraset used it on their dry-transfer sheets, and again during the 90s as desktop publishers bundled the text with their software. Today it's seen all around the web; on templates, websites, and stock designs. Use our ",
linkText: "generator",
linkHref: "https://loremipsum.io/#generator",
afterLink: " to get your own, or read on for the authoritative history of lorem ipsum.",
},
];
export const portfolioDetailBody: PortfolioItem["body"] = blogDetailBody;
export const defaultBlogPosts: BlogPost[] = Array.from({ length: 6 }, (_, i) => ({
slug: i === 0 ? "gold-scarlet-necklace" : `gold-scarlet-necklace-${i + 1}`,
title: "Gold scarlet necklace",
excerpt:
"we will show your business to verity of visitor around the world.we will show your business to verity of visitor around the world.we will show your business to verity of visitor around the world.",
image: "/images/blog/thumb.jpg",
date: "September 11",
author: "Mohammadi",
category: "Bracelet",
intro:
"we will show your business to verity of visitor around the world.we will show your business to verity of visitor around the world.we will show your business to verity of visitor around the world.",
body: blogDetailBody,
}));
export const defaultPortfolios: PortfolioItem[] = [
{
slug: "gold-scarlet-necklace",
title: "Gold scarlet necklace",
description: "we will show your business to verity of visitor around the world.we",
image: "/images/portfolio/main.jpg",
images: [
"/images/portfolio/main.jpg",
"/images/portfolio/thumb-1.jpg",
"/images/portfolio/thumb-2.jpg",
"/images/portfolio/thumb-3.jpg",
],
date: "September 11",
author: "Mohammadi",
category: "Bracelet",
body: portfolioDetailBody,
},
{
slug: "gold-scarlet-necklace-2",
title: "Gold scarlet necklace",
description: "we will show your business to verity of visitor ",
image: "/images/business/portfolio-1.jpg",
images: [
"/images/business/portfolio-1.jpg",
"/images/portfolio/thumb-1.jpg",
"/images/portfolio/thumb-2.jpg",
"/images/portfolio/thumb-3.jpg",
],
date: "September 11",
author: "Mohammadi",
category: "Bracelet",
body: portfolioDetailBody,
},
{
slug: "gold-scarlet-necklace-3",
title: "Gold scarlet necklace",
description: "we will show your business to verity of visitor ",
image: "/images/business/portfolio-3.jpg",
images: [
"/images/business/portfolio-3.jpg",
"/images/portfolio/thumb-1.jpg",
"/images/portfolio/thumb-2.jpg",
"/images/portfolio/thumb-3.jpg",
],
date: "September 11",
author: "Mohammadi",
category: "Bracelet",
body: portfolioDetailBody,
},
];
+292
View File
@@ -0,0 +1,292 @@
export const navLinks = [
{ href: "/businesses", label: "Categories", hasMenu: true },
{ href: "/#vision", label: "vision" },
{ href: "/about", label: "about us" },
{ href: "/contact", label: "contact us" },
] as const;
export type MegaLink = { label: string; href: string };
export type MegaGroup = {
title: string;
links: MegaLink[];
};
export type MegaTab = {
id: string;
label: string;
icon: string;
groups: MegaGroup[];
featured: {
title: string;
image: string;
imageClass?: string;
};
};
const placeholders = (count: number): MegaLink[] =>
Array.from({ length: count }, () => ({
label: "Subcategory 1",
href: "/businesses",
}));
export const megaMenuTabs: MegaTab[] = [
{
id: "industry",
label: "Industry",
icon: "/images/icons/gear.svg",
groups: [
{
title: "Materials",
links: [
{ label: "Metals", href: "/businesses" },
{ label: "Plastics", href: "/businesses" },
{ label: "Chemicals", href: "/businesses" },
],
},
{
title: "Category",
links: placeholders(5),
},
{
title: "Category",
links: placeholders(6),
},
{
title: "Category",
links: placeholders(5),
},
],
featured: {
title: "Iranian industry",
image: "/images/categories/industry.png",
imageClass: "drum",
},
},
{
id: "stores",
label: "Stores",
icon: "/images/icons/cart.svg",
groups: [
{
title: "Wearables",
links: [
{ label: "Clothes", href: "/businesses" },
{ label: "Shoes", href: "/businesses" },
{ label: "Accessories", href: "/businesses" },
],
},
{
title: "Category",
links: placeholders(5),
},
{
title: "Category",
links: placeholders(6),
},
{
title: "Category",
links: placeholders(5),
},
],
featured: {
title: "Persian unique style",
image: "/images/categories/clothes.png",
imageClass: "dress",
},
},
{
id: "wholesellers",
label: "Whole sellers",
icon: "/images/icons/gear.svg",
groups: [
{
title: "Apparel",
links: [
{ label: "Clothes", href: "/businesses" },
{ label: "Shoes", href: "/businesses" },
{ label: "Textiles", href: "/businesses" },
],
},
{
title: "Category",
links: placeholders(5),
},
{
title: "Category",
links: placeholders(6),
},
{
title: "Category",
links: placeholders(5),
},
],
featured: {
title: "Wholesale market",
image: "/images/categories/shoes.png",
imageClass: "shoes",
},
},
{
id: "services",
label: "Services",
icon: "/images/icons/home-outline.svg",
groups: [
{
title: "Business",
links: [
{ label: "Logistics", href: "#services" },
{ label: "Consulting", href: "#services" },
{ label: "Support", href: "#services" },
],
},
{
title: "Category",
links: placeholders(5),
},
{
title: "Category",
links: placeholders(6),
},
{
title: "Category",
links: placeholders(5),
},
],
featured: {
title: "Professional services",
image: "/images/categories/services.png",
imageClass: "notebook",
},
},
];
export const featuredCategories = [
{
title: "industry",
description: "Some description about the title",
image: "/images/categories/industry.png",
imageClass: "industry",
},
{
title: "Food",
description: "Some description about the title",
image: "/images/categories/food.png",
imageClass: "food",
},
{
title: "Fruits",
description: "Some description about the title",
image: "/images/categories/fruits.png",
imageClass: "fruits",
},
{
title: "Clothes",
description: "Some description about the title",
image: "/images/categories/clothes.png",
imageClass: "clothes",
},
{
title: "Home Furniture",
description: "Some description about the title",
image: "/images/categories/chair.png",
imageClass: "chair",
},
{
title: "Shoes",
description: "Some description about the title",
image: "/images/categories/shoes.png",
imageClass: "shoes",
},
{
title: "Home appliances",
description: "Some description about the title",
image: "/images/categories/washer.png",
imageClass: "washer",
},
{
title: "Services",
description: "Some description about the title",
image: "/images/categories/services.png",
imageClass: "services",
},
];
export type Producer = {
slug: string;
name: string;
tagline: string;
products: string;
since: string;
description: string;
typeIcon: "cart" | "gear";
typeLabel: string;
category: string;
cover: string;
logo: string;
logoClass?: string;
};
export const featuredProducers: Producer[] = [
{
slug: "sani-khani",
name: "sani khani",
tagline: "Lorem Ipsum",
products: "921",
since: "2021",
description: "we will show your business to verity of visitor around the world.",
typeIcon: "cart",
typeLabel: "Store",
category: "Home Appliances",
cover: "/images/producers/cover-laundry.png",
logo: "/images/producers/logo-sani.png",
logoClass: "wide",
},
{
slug: "arad-charta",
name: "Arad charta",
tagline: "we produce bread materials",
products: "3",
since: "2021",
description: "we will show your business to verity of visitor around the world.",
typeIcon: "gear",
typeLabel: "Industry",
category: "Food",
cover: "/images/producers/cover-bakery.png",
logo: "/images/producers/logo-arad.png",
},
{
slug: "simode",
name: "Simode",
tagline: "Clothes whole seller",
products: "121",
since: "2021",
description: "we will show your business to verity of visitor around the world.",
typeIcon: "gear",
typeLabel: "Whole Seller",
category: "Clothes",
cover: "/images/producers/cover-clothes.png",
logo: "/images/producers/logo-simode.png",
logoClass: "round",
},
{
slug: "ayoubi-gallery",
name: "ayoubi gallery",
tagline: "An elegant jewellery store",
products: "121",
since: "2021",
description: "we will show your business to verity of visitor around the world.",
typeIcon: "gear",
typeLabel: "Store",
category: "Jewellery",
cover: "/images/producers/cover-jewelry.png",
logo: "/images/producers/logo-ayoubi.png",
logoClass: "square",
},
];
export const aboutBullets = [
"Excepteur sint occaecat cupidatat non proident",
"veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo con",
"Excepteur sint occaecat cupidatat non proident",
];
+97
View File
@@ -0,0 +1,97 @@
export const aboutCopy = {
kicker: "About Us",
title: ["IranBay", "A Get To Iran", "Market."],
paragraphs: [
"n publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used as a placeholder before final copy is available",
"n publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used as a placeholder before final copy is available",
],
};
export const clients = [
{ name: "Apple", image: "/images/about/clients/apple.png" },
{ name: "Audi", image: "/images/about/clients/audi.png" },
{ name: "Tesla", image: "/images/about/clients/tesla.png" },
{ name: "Mercedes", image: "/images/about/clients/mercedes.png" },
{ name: "BMW", image: "/images/about/clients/bmw.png" },
];
export const testimonials = [
{
body: "n publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used as a placeholder before final copy is available",
meta: "Alimohammadi | 21:31 | Sepember | 2023.20.16",
},
{
body: "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
meta: "Sara Ahmadi | 14:05 | October | 2024.02.11",
},
{
body: "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.",
meta: "Reza Karimi | 09:42 | January | 2025.01.08",
},
];
/** @deprecated use testimonials */
export const testimonial = testimonials[0];
export const services = [
{
title: "Support",
subtitle: "24 hour support",
image: "/images/about/support.png",
tone: "support",
},
{
title: "Register",
subtitle: "Quick store registration",
image: "/images/about/register.png",
tone: "register",
},
{
title: "Responsive",
subtitle: "Responsive in all sizes",
image: "/images/about/responsive.png",
tone: "responsive",
},
] as const;
export const contactInfo = {
email: "Alimohammadi@gmail.com",
phones: ["+98 937 622 0110", "+98 912 123 4567"],
addressLines: [
"Sheikh Mohammed bin Rashid Blvd",
"Downtown Dubai - Dubai - United Arab Emirates",
],
};
export const faqs = [
{
question: "In publishing and graphic design?",
answer:
"Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content.",
},
{
question: "In publishing and graphic?",
answer:
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
},
{
question: "In publishing ?",
answer:
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
},
{
question: "In publishing and graphic design ?",
answer:
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
},
];
export const businessFilters = [
{ label: "Looking for...", value: "Arad Charta" },
{ label: "Province", value: "Tehran" },
{ label: "City", value: "Factory" },
];
export const activeFilterTags = ["Arad Charta", "Tehran"];
export const breadcrumbs = ["producers", "industry", "Food", "processed"];
+18
View File
@@ -0,0 +1,18 @@
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,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+88
View File
@@ -0,0 +1,88 @@
import {
clearTokens,
getAccessToken,
} from "@/lib/session";
export type WebsiteAuthUser = {
id: string;
cellNumber: string;
email: string | null;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
roles: string[];
dashboard: "super_admin" | "business" | "customer";
businesses: {
id: string;
name: string;
slug: string;
isOwner: boolean;
}[];
};
const MESHKEE_API_URL =
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ||
"https://api.meshkee.com/api/v1";
export function getCustomerDashboardUrl() {
return (
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://customer.localhost:3002"
);
}
export function getAdminDashboardUrl() {
return (
process.env.NEXT_PUBLIC_ADMIN_DASHBOARD_URL?.replace(/\/$/, "") ||
process.env.NEXT_PUBLIC_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://localhost:3002"
);
}
export function getBusinessDashboardUrl() {
return (
process.env.NEXT_PUBLIC_BUSINESS_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://business.localhost:3002"
);
}
export function displayNameFor(user: WebsiteAuthUser | null | undefined) {
if (!user) return "";
const first = (user.firstNameEn || user.firstName || "").trim();
const last = (user.lastNameEn || user.lastName || "").trim();
const full = [first, last].filter(Boolean).join(" ");
if (full) return full;
return user.cellNumber || "Account";
}
/** Header label: first name only. */
export function firstNameFor(user: WebsiteAuthUser | null | undefined) {
if (!user) return "";
const first = (user.firstNameEn || user.firstName || "").trim();
if (first) return first.split(/\s+/)[0] || first;
return user.cellNumber || "Account";
}
export async function fetchCurrentUser(
signal?: AbortSignal,
): Promise<WebsiteAuthUser | null> {
const token = getAccessToken();
if (!token) return null;
const response = await fetch(`${MESHKEE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
signal,
cache: "no-store",
});
if (response.status === 401 || response.status === 403) {
clearTokens();
return null;
}
if (!response.ok) return null;
const payload = (await response.json()) as { user?: WebsiteAuthUser };
return payload.user ?? null;
}
+44
View File
@@ -0,0 +1,44 @@
import { getAccessToken } from "@/lib/session";
const API_BASE_URL =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
export async function registerShop(body: {
name: string;
slug?: string;
about?: string;
meshkeeCategoryIds?: string[];
ownerPassword: string;
}) {
const token = getAccessToken();
if (!token) {
throw new Error("Sign in required");
}
const response = await fetch(`${API_BASE_URL}/businesses/register-shop`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
typeof payload?.message === "string"
? payload.message
: Array.isArray(payload?.message)
? payload.message.join(", ")
: `Registration failed (${response.status})`;
throw new Error(message);
}
return payload as {
listing: { id: string; slug: string; status: string };
meshkeeBusinessId: string;
message: string;
};
}
+156
View File
@@ -0,0 +1,156 @@
import type { MegaTab } from "@/data/home";
import type { MeshkeeCategory } from "@/lib/meshkee-categories";
type TreeNode = MeshkeeCategory & { children: TreeNode[] };
const FEATURED_BY_SLUG: Record<
string,
{ title: string; image: string; imageClass?: string }
> = {
industry: {
title: "Iranian industry",
image: "/images/categories/industry.png",
imageClass: "drum",
},
industries: {
title: "Iranian industry",
image: "/images/categories/industry.png",
imageClass: "drum",
},
stores: {
title: "Persian unique style",
image: "/images/categories/clothes.png",
imageClass: "dress",
},
wholesellers: {
title: "Wholesale market",
image: "/images/categories/shoes.png",
imageClass: "shoes",
},
"whole-sellers": {
title: "Wholesale market",
image: "/images/categories/shoes.png",
imageClass: "shoes",
},
services: {
title: "Professional services",
image: "/images/categories/services.png",
imageClass: "notebook",
},
};
const ICON_BY_SLUG: Record<string, string> = {
industry: "/images/icons/gear.svg",
industries: "/images/icons/gear.svg",
stores: "/images/icons/cart.svg",
wholesellers: "/images/icons/gear.svg",
"whole-sellers": "/images/icons/gear.svg",
services: "/images/icons/home-outline.svg",
};
const DEFAULT_FEATURED = {
title: "Explore IranBay",
image: "/images/categories/clothes.png",
imageClass: "dress",
};
function sortNodes(nodes: TreeNode[]) {
nodes.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children);
}
function buildTree(categories: MeshkeeCategory[]): TreeNode[] {
const byId = new Map<string, TreeNode>();
for (const category of categories) {
byId.set(category.id, { ...category, children: [] });
}
const roots: TreeNode[] = [];
for (const node of byId.values()) {
if (node.parentId && byId.has(node.parentId)) {
byId.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
sortNodes(roots);
return roots;
}
function categoryHref(category: MeshkeeCategory) {
const slug = category.slug?.trim();
if (slug) return `/businesses?category=${encodeURIComponent(slug)}`;
return `/businesses?categoryId=${encodeURIComponent(category.id)}`;
}
function resolveIcon(category: MeshkeeCategory) {
const icon = category.icon?.trim();
if (icon) {
if (
icon.startsWith("/") ||
icon.startsWith("http://") ||
icon.startsWith("https://")
) {
return icon;
}
}
return ICON_BY_SLUG[category.slug] ?? "/images/icons/category.svg";
}
function resolveFeatured(category: MeshkeeCategory) {
const mapped = FEATURED_BY_SLUG[category.slug];
if (mapped) return mapped;
return {
...DEFAULT_FEATURED,
title: category.name ? `Explore ${category.name}` : DEFAULT_FEATURED.title,
};
}
/** Roots → groups (L2) → links (L3) for the website mega menu. */
export function buildMegaMenuTabs(categories: MeshkeeCategory[]): MegaTab[] {
const roots = buildTree(categories).filter(
(root) => root.slug !== "other" && root.name.trim(),
);
return roots.map((root) => {
const groups = root.children.map((group) => {
const links = group.children.length
? group.children.map((child) => ({
label: child.name,
href: categoryHref(child),
}))
: [
{
label: group.name,
href: categoryHref(group),
},
];
return {
title: group.name,
links,
};
});
return {
id: root.slug || root.id,
label: root.name,
icon: resolveIcon(root),
groups:
groups.length > 0
? groups
: [
{
title: root.name,
links: [{ label: "Browse", href: categoryHref(root) }],
},
],
featured: resolveFeatured(root),
};
});
}
+76
View File
@@ -0,0 +1,76 @@
import { getAccessToken } from "@/lib/session";
export interface MeshkeeCategory {
id: string;
parentId: string | null;
name: string;
nameFa: string | null;
slug: string;
description: string | null;
icon: string | null;
sortOrder: number;
tags: string[];
}
export function categoryLabel(category: {
name: string;
nameFa?: string | null;
slug: string;
}) {
if (category.nameFa && category.nameFa !== category.name) {
return `${category.name} / ${category.nameFa}`;
}
return category.name || category.slug;
}
function mapCategory(row: Record<string, unknown>): MeshkeeCategory {
return {
id: String(row.id),
parentId:
row.parentId == null && row.parent_id == null
? null
: String(row.parentId ?? row.parent_id),
name: String(row.name ?? ""),
nameFa:
row.nameFa == null && row.name_fa == null
? null
: String(row.nameFa ?? row.name_fa),
slug: String(row.slug ?? ""),
description: row.description == null ? null : String(row.description),
icon: row.icon == null ? null : String(row.icon),
sortOrder: Number(row.sortOrder ?? row.sort_order ?? 0),
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
};
}
export async function searchMeshkeeCategories(
q = "",
signal?: AbortSignal,
): Promise<MeshkeeCategory[]> {
const params = new URLSearchParams();
if (q.trim()) params.set("q", q.trim());
const headers = new Headers();
const token = getAccessToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`/api/meshkee/categories?${params}`, {
headers,
signal,
cache: "no-store",
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message =
typeof payload?.message === "string"
? payload.message
: Array.isArray(payload?.message)
? payload.message.join(", ")
: `Failed to load categories (${response.status})`;
throw new Error(message);
}
const items = Array.isArray(payload?.items) ? payload.items : [];
return items.map((row: Record<string, unknown>) => mapCategory(row));
}
+80
View File
@@ -0,0 +1,80 @@
const ACCESS_TOKEN_KEY = "marketplace_access_token";
const REFRESH_TOKEN_KEY = "marketplace_refresh_token";
const TOKEN_MAX_AGE_SEC = 60 * 60 * 24 * 14;
function cookieDomain(): string {
const root = (process.env.NEXT_PUBLIC_ROOT_DOMAIN || "localhost").trim();
if (!root || root === "localhost" || root.startsWith("127.")) {
return "localhost";
}
return root.startsWith(".") ? root : `.${root}`;
}
function readCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const prefix = `${name}=`;
for (const part of document.cookie.split(";")) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return null;
}
function writeCookie(name: string, value: string) {
if (typeof document === "undefined") return;
const secure =
typeof window !== "undefined" && window.location.protocol === "https:"
? "; Secure"
: "";
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${TOKEN_MAX_AGE_SEC}; SameSite=Lax; Domain=${cookieDomain()}${secure}`;
}
export function getAccessToken() {
if (typeof window === "undefined") return null;
return readCookie(ACCESS_TOKEN_KEY) || localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken() {
if (typeof window === "undefined") return null;
return (
readCookie(REFRESH_TOKEN_KEY) || localStorage.getItem(REFRESH_TOKEN_KEY)
);
}
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
writeCookie(ACCESS_TOKEN_KEY, accessToken);
writeCookie(REFRESH_TOKEN_KEY, refreshToken);
}
export function clearTokens() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
if (typeof document === "undefined") return;
const domain = cookieDomain();
document.cookie = `${ACCESS_TOKEN_KEY}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`;
document.cookie = `${REFRESH_TOKEN_KEY}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`;
}
export function getWebsiteOrigin() {
return (
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
(typeof window !== "undefined" ? window.location.origin : "http://localhost:3001")
);
}
export function getCustomerLoginUrl(returnPath = "/register") {
const customer =
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://customer.localhost:3002";
const website = getWebsiteOrigin();
const next = returnPath.startsWith("http")
? returnPath
: `${website}${returnPath.startsWith("/") ? returnPath : `/${returnPath}`}`;
const url = new URL(`${customer}/login`);
url.searchParams.set("next", next);
return url.toString();
}
+69
View File
@@ -0,0 +1,69 @@
import type { Producer } from "@/data/home";
const API_BASE_URL =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
process.env.MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
export type WebsiteCarouselBusiness = {
id: string;
slug: string;
name: string;
tagline: string;
about: string;
logoUrl: string | null;
bannerUrl: string | null;
categoryName: string | null;
productCount: number;
publishedAt: string | null;
};
export type WebsiteCarousel = {
id: string;
title: string;
subtext: string;
slug: string;
businesses: WebsiteCarouselBusiness[];
};
const PLACEHOLDER_COVER = "/images/producers/cover-laundry.png";
const PLACEHOLDER_LOGO = "/images/producers/logo-sani.png";
const DEFAULT_DESCRIPTION =
"we will show your business to verity of visitor around the world.";
export async function getWebsiteCarousels(): Promise<WebsiteCarousel[]> {
try {
const response = await fetch(`${API_BASE_URL}/website/carousels`, {
cache: "no-store",
});
if (!response.ok) return [];
const data = (await response.json()) as { carousels?: WebsiteCarousel[] };
return Array.isArray(data.carousels) ? data.carousels : [];
} catch {
return [];
}
}
export function carouselBusinessToProducer(
business: WebsiteCarouselBusiness,
): Producer {
const since = business.publishedAt
? String(new Date(business.publishedAt).getFullYear())
: "—";
return {
slug: business.slug,
name: business.name || business.slug,
tagline: business.tagline || "",
products: String(business.productCount ?? 0),
since,
// English about when present; otherwise English placeholder.
// Farsi about is already stripped by the API.
description: business.about?.trim() || DEFAULT_DESCRIPTION,
typeIcon: "gear",
typeLabel: "Business",
category: business.categoryName || "Business",
cover: business.bannerUrl || PLACEHOLDER_COVER,
logo: business.logoUrl || PLACEHOLDER_LOGO,
};
}
+56
View File
@@ -0,0 +1,56 @@
const API_BASE_URL =
process.env.NEXT_PUBLIC_MARKETPLACE_API_URL?.replace(/\/$/, "") ||
process.env.MARKETPLACE_API_URL?.replace(/\/$/, "") ||
"http://localhost:3001/api/v1";
export type WebsiteMediaItem = {
id: string;
imageUrl: string;
linkUrl: string;
title?: string;
subtitle?: string;
cta?: string;
};
export type WebsiteHomeMedia = {
slider: WebsiteMediaItem[];
standingBanners: WebsiteMediaItem[];
categories: WebsiteMediaItem[];
menuImages: WebsiteMediaItem[];
aboutUs: WebsiteMediaItem;
banners: WebsiteMediaItem[];
};
function resolveMediaUrl(url: string) {
if (!url) return url;
if (url.startsWith("http://") || url.startsWith("https://")) return url;
if (url.startsWith("/api/v1/")) {
const origin = API_BASE_URL.replace(/\/api\/v1$/, "");
return `${origin}${url}`;
}
return url;
}
function mapItem(item: WebsiteMediaItem): WebsiteMediaItem {
return { ...item, imageUrl: resolveMediaUrl(item.imageUrl) };
}
export async function getWebsiteHomeMedia(): Promise<WebsiteHomeMedia | null> {
try {
const response = await fetch(`${API_BASE_URL}/website/home-media`, {
next: { revalidate: 30 },
});
if (!response.ok) return null;
const data = (await response.json()) as WebsiteHomeMedia;
return {
slider: (data.slider ?? []).map(mapItem),
standingBanners: (data.standingBanners ?? []).map(mapItem),
categories: (data.categories ?? []).map(mapItem),
menuImages: (data.menuImages ?? []).map(mapItem),
aboutUs: mapItem(data.aboutUs),
banners: (data.banners ?? []).map(mapItem),
};
} catch {
return null;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { NextConfig } from "next";
const dashboardUrl =
process.env.NEXT_PUBLIC_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://localhost:3002";
const customerDashboardUrl =
process.env.NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL?.replace(/\/$/, "") ||
"http://customer.localhost:3002";
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: "/login",
destination: `${customerDashboardUrl}/login`,
permanent: false,
},
{
source: "/signup",
destination: `${customerDashboardUrl}/login`,
permanent: false,
},
{
source: "/forgot-password",
destination: `${dashboardUrl}/forgot-password`,
permanent: false,
},
];
},
};
export default nextConfig;
+6144
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "iranbay-website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.4",
"typescript": "^5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Some files were not shown because too many files have changed in this diff Show More