Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user