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
+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>
);
}