Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:27 +03:30
co-authored by Cursor
commit 1153e8e78f
178 changed files with 30517 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
NEXT_PUBLIC_WEBSITE_URL=http://localhost:3000
# Meshkee CMS API — always remote (do not point at a local Meshkee)
NEXT_PUBLIC_MESHKEE_API_URL=https://api.meshkee.com/api/v1
# IranBay marketplace API (local is fine)
NEXT_PUBLIC_MARKETPLACE_API_URL=http://localhost:3001/api/v1
# Dashboard hosts (same Next.js app; middleware switches by Host header)
NEXT_PUBLIC_ROOT_DOMAIN=localhost
# Local customer dashboard (shared login): http://customer.localhost:3002
NEXT_PUBLIC_CUSTOMER_DASHBOARD_HOST=customer.localhost:3002
# Local business dashboard: http://business.localhost:3002
NEXT_PUBLIC_BUSINESS_DASHBOARD_HOST=business.localhost:3002
# Local superadmin dashboard: http://localhost:3002
NEXT_PUBLIC_ADMIN_DASHBOARD_HOST=localhost:3002
NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN=iranbay.com
+40
View File
@@ -0,0 +1,40 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+78
View File
@@ -0,0 +1,78 @@
# Dashboard
Admin, customer, and business dashboards for IranBay — one Next.js app; the `Host` header picks the experience.
| Host | App |
|------|-----|
| `http://localhost:3002` (or `admin.<root>`) | Superadmin |
| `http://customer.localhost:3002` (or `customer.<root>`) | Customer account |
| `http://business.localhost:3002` (or `business.<root>`) | Business owner |
Public site stays on the main domain (`ariyaway.com` → `website/`).
## Auth model
- **One shared login** on the customer host (`/login`).
- After login, everyone lands in the **customer** dashboard. Superadmins and
business owners open admin/business from links there.
- If the user **owns a business**, customer UI shows a link to the business dashboard (session handoff across subdomains).
- Visiting `business.*/login` redirects to the customer login.
- Superadmin login stays on the admin host.
```bash
# 1) Meshkee API (port 3010 — website already uses 3000)
cd "../MeshkeeApp Backend"
docker compose up -d
PORT=3010 npm run start:dev
# 2) Dashboard
cd ../dashboard
cp .env.example .env.local
npm install
npm run dev
```
## Env
| Variable | Local example | Prod example |
|----------|---------------|--------------|
| `NEXT_PUBLIC_ROOT_DOMAIN` | `localhost` | `ariyaway.com` |
| `NEXT_PUBLIC_CUSTOMER_DASHBOARD_HOST` | `customer.localhost:3002` | `customer.ariyaway.com` |
| `NEXT_PUBLIC_BUSINESS_DASHBOARD_HOST` | `business.localhost:3002` | `business.ariyaway.com` |
| `NEXT_PUBLIC_ADMIN_DASHBOARD_HOST` | `localhost:3002` | `admin.ariyaway.com` |
| `NEXT_PUBLIC_MESHKEE_API_URL` | Meshkee API base | same |
| `NEXT_PUBLIC_MARKETPLACE_API_URL` | `http://localhost:3001/api/v1` | marketplace API |
Modern browsers resolve `*.localhost` without a hosts file.
Prod: DNS `customer.*`, `business.*` (and optionally `admin.*`) → same dashboard deploy; TLS for those hosts.
## Routes
### Superadmin (`localhost:3002`)
| Route | Purpose |
|-------|---------|
| `/login` | Super admin login |
| `/` | Admin home |
| `/businesses` | Marketplace listings |
### Customer (`customer.localhost:3002`)
| Route | Purpose |
|-------|---------|
| `/login` | Shared customer + business-owner login |
| `/` | Customer home |
| `/profile` | Account profile (read-only for now) |
### Business (`business.localhost:3002`)
Clean URLs (rewritten to `app/business/*`). Login redirects to customer host.
| Route | Purpose |
|-------|---------|
| `/profile` | Meshkee business profile |
| `/products` | Product list |
| `/blog` | Blog list |
| `/portfolios` | Portfolio list |
| `/gallery` | Gallery list |
+64
View File
@@ -0,0 +1,64 @@
import type { ReactNode } from "react";
/**
* Install session from the URL hash BEFORE React AuthProvider runs.
* Cross-subdomain localStorage is empty until this runs; without it,
* ProtectedRoute saw ?businessId= and bounced admins back to /businesses.
*/
const HANDOFF_BOOT = `
(function () {
try {
var raw = location.hash ? location.hash.replace(/^#/, "") : "";
if (!raw) return;
var p = new URLSearchParams(raw);
var access = p.get("access");
var refresh = p.get("refresh");
var businessId = p.get("businessId");
var clearManaged = p.get("clearManaged") === "1";
var next = p.get("next") || "/profile";
if (!access || !refresh) return;
localStorage.setItem("marketplace_access_token", access);
localStorage.setItem("marketplace_refresh_token", refresh);
var root = "localhost";
try {
// Best-effort shared cookie for *.localhost / prod root
document.cookie =
"marketplace_access_token=" +
encodeURIComponent(access) +
"; Path=/; Max-Age=1209600; SameSite=Lax; Domain=" +
root;
document.cookie =
"marketplace_refresh_token=" +
encodeURIComponent(refresh) +
"; Path=/; Max-Age=1209600; SameSite=Lax; Domain=" +
root;
} catch (e) {}
if (businessId) {
localStorage.setItem("marketplace_managed_business_id", businessId);
sessionStorage.setItem("marketplace_managed_business_id", businessId);
} else if (clearManaged) {
localStorage.removeItem("marketplace_managed_business_id");
sessionStorage.removeItem("marketplace_managed_business_id");
}
var target = next.charAt(0) === "/" ? next : "/" + next;
location.replace(target);
} catch (e) {}
})();
`;
export default function AuthHandoffLayout({
children,
}: {
children: ReactNode;
}) {
return (
<>
<script dangerouslySetInnerHTML={{ __html: HANDOFF_BOOT }} />
{children}
</>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
/**
* Fallback UI if the inline boot script did not redirect
* (e.g. missing hash). Prefer the layout script for session install.
*/
export default function AuthHandoffPage() {
return (
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#f6f6f6",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
padding: 24,
textAlign: "center",
lineHeight: 1.6,
}}
>
Opening business dashboard…
<br />
<span style={{ letterSpacing: "0.06em", textTransform: "none", marginTop: 12 }}>
If this page stays here, go back and click Manage business again.
</span>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { BusinessMeshkeeGate } from "@/components/business/BusinessPageChrome";
import AddBlogForm from "@/components/catalog/AddBlogForm";
import { en } from "@/lib/i18n/en";
export default function BusinessAddBlogPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<AddBlogForm
meshkeeId={meshkeeId}
listHref="/blog"
title={en.blog.addTitle}
lead={en.blog.addLead}
/>
)}
</BusinessMeshkeeGate>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import {
BusinessListHeader,
BusinessMeshkeeGate,
} from "@/components/business/BusinessPageChrome";
import BlogPanel from "@/components/catalog/BlogPanel";
import { en } from "@/lib/i18n/en";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function BusinessBlogPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<>
<BusinessListHeader
title={en.blog.title}
lead={en.blog.lead}
action={
<Link href="/blog/new" className={`outline-btn ${styles.actionBtn}`}>
{en.blog.add}
</Link>
}
/>
<BlogPanel
meshkeeId={meshkeeId}
emptyLabel={en.blog.empty}
addHref="/blog/new"
/>
</>
)}
</BusinessMeshkeeGate>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import {
BusinessListHeader,
BusinessMeshkeeGate,
} from "@/components/business/BusinessPageChrome";
import GalleryPanel from "@/components/catalog/GalleryPanel";
import { en } from "@/lib/i18n/en";
export default function BusinessGalleryPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<>
<BusinessListHeader
title={en.gallery.title}
lead={en.gallery.lead}
/>
<GalleryPanel
meshkeeId={meshkeeId}
emptyLabel={en.gallery.empty}
/>
</>
)}
</BusinessMeshkeeGate>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ProtectedRoute } from "@/components/AuthGuards";
import BusinessShell from "@/components/BusinessShell";
export default function BusinessLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProtectedRoute requiredApp="business">
<BusinessShell>{children}</BusinessShell>
</ProtectedRoute>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function BusinessIndexPage() {
redirect("/profile");
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { BusinessMeshkeeGate } from "@/components/business/BusinessPageChrome";
import AddPortfolioForm from "@/components/catalog/AddPortfolioForm";
import { en } from "@/lib/i18n/en";
export default function BusinessAddPortfolioPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<AddPortfolioForm
meshkeeId={meshkeeId}
listHref="/portfolios"
title={en.portfolios.addTitle}
lead={en.portfolios.addLead}
/>
)}
</BusinessMeshkeeGate>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import {
BusinessListHeader,
BusinessMeshkeeGate,
} from "@/components/business/BusinessPageChrome";
import PortfoliosPanel from "@/components/catalog/PortfoliosPanel";
import { en } from "@/lib/i18n/en";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function BusinessPortfoliosPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<>
<BusinessListHeader
title={en.portfolios.title}
lead={en.portfolios.lead}
action={
<Link
href="/portfolios/new"
className={`outline-btn ${styles.actionBtn}`}
>
{en.portfolios.add}
</Link>
}
/>
<PortfoliosPanel
meshkeeId={meshkeeId}
emptyLabel={en.portfolios.empty}
addHref="/portfolios/new"
/>
</>
)}
</BusinessMeshkeeGate>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import {
BusinessMeshkeeGate,
} from "@/components/business/BusinessPageChrome";
import ManageCategoriesPage from "@/components/catalog/ManageCategoriesPage";
import { en } from "@/lib/i18n/en";
export default function BusinessProductCategoriesPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<ManageCategoriesPage
meshkeeId={meshkeeId}
entityType="product"
title={en.products.categoriesTitle}
lead={en.products.categoriesLead}
listHref="/products"
listLabel={en.products.title}
/>
)}
</BusinessMeshkeeGate>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import {
BusinessMeshkeeGate,
} from "@/components/business/BusinessPageChrome";
import AddProductForm from "@/components/catalog/AddProductForm";
import { en } from "@/lib/i18n/en";
export default function BusinessAddProductPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<AddProductForm
meshkeeId={meshkeeId}
listHref="/products"
title={en.products.addTitle}
lead={en.products.addLead}
/>
)}
</BusinessMeshkeeGate>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import {
BusinessListHeader,
BusinessMeshkeeGate,
} from "@/components/business/BusinessPageChrome";
import ProductsPanel from "@/components/catalog/ProductsPanel";
import { en } from "@/lib/i18n/en";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function BusinessProductsPage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<>
<BusinessListHeader
title={en.products.title}
lead={en.products.lead}
action={
<>
<Link
href="/products/categories"
className={`outline-btn ${styles.actionBtn}`}
>
{en.products.manageCategories}
</Link>
<Link
href="/products/new"
className={`outline-btn ${styles.actionBtn}`}
>
{en.products.add}
</Link>
</>
}
/>
<ProductsPanel
meshkeeId={meshkeeId}
emptyLabel={en.products.empty}
addHref="/products/new"
/>
</>
)}
</BusinessMeshkeeGate>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { BusinessMeshkeeGate } from "@/components/business/BusinessPageChrome";
import EditBusinessPage from "@/components/EditBusinessPage";
export default function BusinessProfilePage() {
return (
<BusinessMeshkeeGate>
{(meshkeeId) => (
<EditBusinessPage meshkeeBusinessId={meshkeeId} variant="business" />
)}
</BusinessMeshkeeGate>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import DashboardShell from "@/components/DashboardShell";
import EditBusinessPage from "@/components/EditBusinessPage";
export const metadata: Metadata = {
title: "Edit business | IranBay Dashboard",
};
export default async function EditBusinessRoute({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<EditBusinessPage listingId={id} />
</DashboardShell>
</ProtectedRoute>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import BusinessDetailsPage from "@/components/BusinessDetailsPage";
import DashboardShell from "@/components/DashboardShell";
export const metadata: Metadata = {
title: "Business details | IranBay Dashboard",
};
export default async function BusinessDetailsRoute({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<BusinessDetailsPage listingId={id} />
</DashboardShell>
</ProtectedRoute>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import BusinessCategoriesPage from "@/components/BusinessCategoriesPage";
import DashboardShell from "@/components/DashboardShell";
export const metadata: Metadata = {
title: "Business categories | IranBay Dashboard",
};
export default function BusinessCategoriesRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<BusinessCategoriesPage />
</DashboardShell>
</ProtectedRoute>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { ProtectedRoute } from "@/components/AuthGuards";
import BusinessesPage from "@/components/BusinessesPage";
import DashboardShell from "@/components/DashboardShell";
export const metadata: Metadata = {
title: "Businesses | IranBay Dashboard",
};
export default function BusinessesRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<Suspense
fallback={
<div
style={{
minHeight: 220,
display: "grid",
placeItems: "center",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
}}
>
Loading businesses…
</div>
}
>
<BusinessesPage />
</Suspense>
</DashboardShell>
</ProtectedRoute>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { ProtectedRoute } from "@/components/AuthGuards";
import CustomerShell from "@/components/CustomerShell";
export default function CustomerLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProtectedRoute requiredApp="customer">
<CustomerShell>{children}</CustomerShell>
</ProtectedRoute>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import CustomerHome from "@/components/CustomerHome";
export const metadata: Metadata = {
title: "Home | IranBay Account",
};
export default function CustomerHomePage() {
return <CustomerHome />;
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import CustomerProfile from "@/components/CustomerProfile";
export const metadata: Metadata = {
title: "Profile | IranBay Account",
};
export default function CustomerProfilePage() {
return <CustomerProfile />;
}
+16
View File
@@ -0,0 +1,16 @@
/* Self-hosted Iran Yekan — public/fonts/iranyekan/ */
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iranyekan/IRANYekanWebLight.ttf") format("truetype");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IRANYekan";
src: url("/fonts/iranyekan/IRANYekanWebRegular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ForgotPasswordForm from "@/components/ForgotPasswordForm";
export const metadata: Metadata = {
title: "Forgot Password | IranBay",
};
export default function ForgotPasswordPage() {
return <ForgotPasswordForm />;
}
+207
View File
@@ -0,0 +1,207 @@
:root {
--font-montserrat: "Montserrat", sans-serif;
--font-farsi: "IRANYekan", Tahoma, "Segoe UI", sans-serif;
--font-ui: var(--font-montserrat), var(--font-farsi);
--color-black: #000;
--color-white: #fff;
--color-bg: #ffffff;
--color-card: #eeeeee;
--color-muted: #4a4a4a;
--color-soft: #8e8e8e;
--color-body: #aaaaaa;
--color-dark: #1e1e1e;
--color-explore: #f9f9f9;
--color-footer: #070707;
--color-footer-text: #bababa;
--color-footer-line: #232323;
--page-pad: 40px;
--max-width: 1920px;
--container-width: 1080px;
--sidebar-width: 248px;
--page-title-pattern: radial-gradient(
rgba(0, 0, 0, 0.12) 0.75px,
transparent 0.85px
);
--page-title-pattern-size: 14px 14px;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
height: 100%;
scroll-behavior: smooth;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
color: var(--color-black);
background: var(--color-bg);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img {
display: block;
max-width: 100%;
}
button,
a {
font-family: inherit;
color: inherit;
}
a {
text-decoration: none;
color: inherit;
}
button {
background: none;
border: none;
cursor: pointer;
}
.icon {
display: block;
object-fit: contain;
}
.outline-btn {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0.5px solid var(--color-black);
border-radius: 3px;
background: transparent;
color: var(--color-black);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.2em;
white-space: nowrap;
transition: color 0.3s ease, border-color 0.3s ease;
}
.outline-btn:hover,
.outline-btn:focus-visible {
color: var(--color-white);
background: transparent;
}
.outline-btn:hover :is(span, strong, em, p, a, label, button),
.outline-btn:focus-visible :is(span, strong, em, p, a, label, button) {
color: var(--color-white);
}
.tile-hover,
.outline-btn {
position: relative;
isolation: isolate;
}
.tile-hover::before,
.outline-btn::before {
content: "";
position: absolute;
inset: 0;
background: var(--color-black);
clip-path: inset(100% 0 0 0);
transition: clip-path 0.45s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
border-radius: inherit;
}
.tile-hover::before {
z-index: 0;
}
.outline-btn::before {
z-index: -1;
}
.tile-hover:hover::before,
.tile-hover:focus-within::before,
.outline-btn:hover::before,
.outline-btn:focus-visible::before {
clip-path: inset(0 0 0 0);
}
.tile-hover > *:not(img),
.outline-btn > * {
position: relative;
z-index: 2;
}
.tile-hover:hover,
.tile-hover:focus-within,
.tile-hover:hover :is(h1, h2, h3, p, span, strong, a),
.tile-hover:focus-within :is(h1, h2, h3, p, span, strong, a) {
color: var(--color-white);
}
.tile-hover :is(h1, h2, h3, p, span, strong, a) {
transition: color 0.3s ease, border-color 0.3s ease;
}
.outline-btn:hover img,
.outline-btn:focus-visible img {
position: relative;
z-index: 2;
}
@media (prefers-reduced-motion: reduce) {
.tile-hover::before,
.outline-btn::before {
transition: none;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.fa,
[lang="fa"],
:lang(fa) {
font-family: var(--font-farsi);
font-weight: 400;
letter-spacing: 0;
}
[dir="ltr"] {
unicode-bidi: isolate;
direction: ltr;
}
@media (max-width: 1100px) {
:root {
--page-pad: 28px;
}
}
@media (max-width: 720px) {
:root {
--page-pad: 20px;
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import { AuthProvider } from "@/components/AuthProvider";
import "./fonts/iranyekan.css";
import "./globals.css";
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
variable: "--font-montserrat",
display: "swap",
});
export const metadata: Metadata = {
title: "IranBay Dashboard",
description: "Admin dashboard for IranBay marketplace gate.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" dir="ltr" className={montserrat.variable}>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { GuestRoute } from "@/components/AuthGuards";
import LoginForm from "@/components/LoginForm";
export const metadata: Metadata = {
title: "Login | IranBay",
};
export default function LoginPage() {
return (
<GuestRoute>
<Suspense
fallback={
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#f6f6f6",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
}}
>
Loading
</div>
}
>
<LoginForm />
</Suspense>
</GuestRoute>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
import AddBlogForm from "@/components/catalog/AddBlogForm";
import { en } from "@/lib/i18n/en";
export default function ManageAddBlogPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const listHref = `/manage/${encodeURIComponent(meshkeeId)}/blog`;
return (
<AddBlogForm
meshkeeId={meshkeeId}
listHref={listHref}
title={en.blog.addTitle}
lead={en.blog.addLead}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { BusinessListHeader } from "@/components/business/BusinessPageChrome";
import BlogPanel from "@/components/catalog/BlogPanel";
import { en } from "@/lib/i18n/en";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function ManageBusinessBlogPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const addHref = `/manage/${encodeURIComponent(meshkeeId)}/blog/new`;
return (
<>
<BusinessListHeader
title={en.blog.title}
lead={en.blog.lead}
action={
<Link href={addHref} className={`outline-btn ${styles.actionBtn}`}>
{en.blog.add}
</Link>
}
/>
<BlogPanel
meshkeeId={meshkeeId}
emptyLabel={en.blog.empty}
addHref={addHref}
/>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useParams } from "next/navigation";
import { BusinessListHeader } from "@/components/business/BusinessPageChrome";
import GalleryPanel from "@/components/catalog/GalleryPanel";
import { en } from "@/lib/i18n/en";
export default function ManageBusinessGalleryPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
return (
<>
<BusinessListHeader title={en.gallery.title} lead={en.gallery.lead} />
<GalleryPanel meshkeeId={meshkeeId} emptyLabel={en.gallery.empty} />
</>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { useParams } from "next/navigation";
import { ProtectedRoute } from "@/components/AuthGuards";
import BusinessShell from "@/components/BusinessShell";
import { setManagedBusinessId } from "@/lib/managed-business";
import { useAuth } from "@/components/AuthProvider";
function ManageBusinessBootstrap({ children }: { children: React.ReactNode }) {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const { isLoading } = useAuth();
useEffect(() => {
if (meshkeeId) setManagedBusinessId(meshkeeId);
}, [meshkeeId]);
if (isLoading) {
return (
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#f6f6f6",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
}}
>
Loading
</div>
);
}
return (
<BusinessShell manageMeshkeeId={meshkeeId}>{children}</BusinessShell>
);
}
export default function ManageBusinessLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ProtectedRoute requiredApp="admin">
<ManageBusinessBootstrap>{children}</ManageBusinessBootstrap>
</ProtectedRoute>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { redirect } from "next/navigation";
export default async function ManageBusinessIndex({
params,
}: {
params: Promise<{ meshkeeId: string }>;
}) {
const { meshkeeId } = await params;
redirect(`/manage/${meshkeeId}/profile`);
}
@@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
import AddPortfolioForm from "@/components/catalog/AddPortfolioForm";
import { en } from "@/lib/i18n/en";
export default function ManageAddPortfolioPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const listHref = `/manage/${encodeURIComponent(meshkeeId)}/portfolios`;
return (
<AddPortfolioForm
meshkeeId={meshkeeId}
listHref={listHref}
title={en.portfolios.addTitle}
lead={en.portfolios.addLead}
/>
);
}
@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { BusinessListHeader } from "@/components/business/BusinessPageChrome";
import PortfoliosPanel from "@/components/catalog/PortfoliosPanel";
import { en } from "@/lib/i18n/en";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function ManageBusinessPortfoliosPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const addHref = `/manage/${encodeURIComponent(meshkeeId)}/portfolios/new`;
return (
<>
<BusinessListHeader
title={en.portfolios.title}
lead={en.portfolios.lead}
action={
<Link href={addHref} className={`outline-btn ${styles.actionBtn}`}>
{en.portfolios.add}
</Link>
}
/>
<PortfoliosPanel
meshkeeId={meshkeeId}
emptyLabel={en.portfolios.empty}
addHref={addHref}
/>
</>
);
}
@@ -0,0 +1,23 @@
"use client";
import { useParams } from "next/navigation";
import ManageCategoriesPage from "@/components/catalog/ManageCategoriesPage";
import { en } from "@/lib/i18n/en";
export default function ManageProductCategoriesPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const listHref = `/manage/${encodeURIComponent(meshkeeId)}/products`;
return (
<ManageCategoriesPage
meshkeeId={meshkeeId}
entityType="product"
title={en.products.categoriesTitle}
lead={en.products.categoriesLead}
homeHref={`/manage/${encodeURIComponent(meshkeeId)}`}
listHref={listHref}
listLabel={en.products.title}
/>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useParams } from "next/navigation";
import AddProductForm from "@/components/catalog/AddProductForm";
import { en } from "@/lib/i18n/en";
export default function ManageAddProductPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const listHref = `/manage/${encodeURIComponent(meshkeeId)}/products`;
return (
<AddProductForm
meshkeeId={meshkeeId}
listHref={listHref}
title={en.products.addTitle}
lead={en.products.addLead}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { BusinessListHeader } from "@/components/business/BusinessPageChrome";
import ProductsPanel from "@/components/catalog/ProductsPanel";
import { en } from "@/lib/i18n/en";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function ManageBusinessProductsPage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
const base = `/manage/${encodeURIComponent(meshkeeId)}/products`;
return (
<>
<BusinessListHeader
title={en.products.title}
lead={en.products.lead}
action={
<>
<Link
href={`${base}/categories`}
className={`outline-btn ${styles.actionBtn}`}
>
{en.products.manageCategories}
</Link>
<Link href={`${base}/new`} className={`outline-btn ${styles.actionBtn}`}>
{en.products.add}
</Link>
</>
}
/>
<ProductsPanel
meshkeeId={meshkeeId}
emptyLabel={en.products.empty}
addHref={`${base}/new`}
/>
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useParams } from "next/navigation";
import EditBusinessPage from "@/components/EditBusinessPage";
export default function ManageBusinessProfilePage() {
const params = useParams<{ meshkeeId: string }>();
const meshkeeId = String(params.meshkeeId ?? "");
return (
<EditBusinessPage meshkeeBusinessId={meshkeeId} variant="business" />
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import DashboardShell from "@/components/DashboardShell";
import HomeDashboard from "@/components/HomeDashboard";
export const metadata: Metadata = {
title: "Overview | IranBay Dashboard",
};
export default function DashboardHomePage() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<HomeDashboard />
</DashboardShell>
</ProtectedRoute>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function WebsiteImagesRedirect() {
redirect("/website/images");
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import BlogCategoriesPage from "@/components/BlogCategoriesPage";
import DashboardShell from "@/components/DashboardShell";
export const metadata: Metadata = {
title: "Blog categories | IranBay Dashboard",
};
export default function BlogCategoriesRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<BlogCategoriesPage />
</DashboardShell>
</ProtectedRoute>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import DashboardShell from "@/components/DashboardShell";
import WebsiteBlogForm from "@/components/WebsiteBlogForm";
export const metadata: Metadata = {
title: "Add blog post | IranBay Dashboard",
};
export default function WebsiteBlogNewRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<WebsiteBlogForm />
</DashboardShell>
</ProtectedRoute>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import DashboardShell from "@/components/DashboardShell";
import WebsiteBlogListPage from "@/components/WebsiteBlogListPage";
export const metadata: Metadata = {
title: "Blog | IranBay Dashboard",
};
export default function WebsiteBlogRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<WebsiteBlogListPage />
</DashboardShell>
</ProtectedRoute>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import DashboardShell from "@/components/DashboardShell";
import SpecialBusinessesPage from "@/components/SpecialBusinessesPage";
export const metadata: Metadata = {
title: "Carousels | IranBay Dashboard",
};
export default function CarouselsRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<SpecialBusinessesPage />
</DashboardShell>
</ProtectedRoute>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { ProtectedRoute } from "@/components/AuthGuards";
import DashboardShell from "@/components/DashboardShell";
import WebsiteImagesPage from "@/components/WebsiteImagesPage";
export const metadata: Metadata = {
title: "Website images | IranBay Dashboard",
};
export default function WebsiteImagesRoute() {
return (
<ProtectedRoute requiredApp="admin">
<DashboardShell>
<WebsiteImagesPage />
</DashboardShell>
</ProtectedRoute>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SpecialBusinessesRedirect() {
redirect("/website/carousels");
}
+77
View File
@@ -0,0 +1,77 @@
.account {
position: relative;
display: flex;
align-items: center;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 160px;
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;
}
.chevron {
width: 7px;
height: 10px;
object-fit: contain;
transform: rotate(90deg);
opacity: 0.65;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(-90deg);
}
.menu {
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);
}
.item {
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;
text-decoration: none;
}
.item:hover,
.item:focus-visible {
color: var(--color-black);
background: #f6f6f6;
}
.itemMuted {
color: #8e8e8e;
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { en } from "@/lib/i18n/en";
import {
getAdminDashboardOrigin,
getCustomerDashboardOrigin,
hostAppFor,
} from "@/lib/host";
import { openBusinessDashboardAsUser } from "@/lib/session-handoff";
import { getFirstName } from "@/lib/userName";
import styles from "./AccountMenu.module.css";
export default function AccountMenu() {
const router = useRouter();
const { user, logout, isBusinessOwner, isSuperAdmin } = useAuth();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const firstName = getFirstName(user);
const app =
typeof window !== "undefined" ? hostAppFor(window.location.host) : "admin";
const customerUrl = getCustomerDashboardOrigin();
const adminUrl = getAdminDashboardOrigin();
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className={styles.account} ref={rootRef}>
<button
type="button"
className={styles.trigger}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
{firstName}
<img
className={`${styles.chevron} ${open ? styles.chevronOpen : ""}`}
src="/images/icons/chevron.svg"
alt=""
width={7}
height={12}
/>
</button>
{open ? (
<div className={styles.menu} role="menu">
<a
role="menuitem"
href={`${customerUrl}/profile`}
className={styles.item}
onClick={() => setOpen(false)}
>
My profile
</a>
<a
role="menuitem"
href={`${customerUrl}/forgot-password`}
className={styles.item}
onClick={() => setOpen(false)}
>
Change password
</a>
{isBusinessOwner ? (
<button
type="button"
role="menuitem"
className={styles.item}
onClick={() => {
setOpen(false);
openBusinessDashboardAsUser("/profile");
}}
>
{en.openBusinessDashboard}
</button>
) : null}
{isSuperAdmin ? (
<a
role="menuitem"
href={adminUrl}
className={styles.item}
onClick={() => setOpen(false)}
>
Super admin
</a>
) : null}
{app !== "customer" ? (
<a
role="menuitem"
href={customerUrl}
className={styles.item}
onClick={() => setOpen(false)}
>
{en.openCustomerDashboard}
</a>
) : null}
<button
type="button"
role="menuitem"
className={`${styles.item} ${styles.itemMuted}`}
onClick={() => {
setOpen(false);
logout();
router.replace("/login");
}}
>
{en.logOut}
</button>
</div>
) : null}
</div>
);
}
+351
View File
@@ -0,0 +1,351 @@
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px var(--page-pad);
background: rgba(0, 0, 0, 0.28);
}
.modal {
width: min(100%, 520px);
max-height: min(90vh, 720px);
overflow: auto;
border-radius: 5px;
background: var(--color-white);
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
padding: 28px 28px 24px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.kicker {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
margin-bottom: 8px;
}
.title {
font-size: 18px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.closeBtn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 5px;
font-size: 22px;
line-height: 1;
color: var(--color-soft);
transition: background 0.2s ease, color 0.2s ease;
}
.closeBtn:hover,
.closeBtn:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
border-radius: 5px;
background: #f6f6f6;
}
.switchBtn {
min-height: 40px;
padding: 0 12px;
border-radius: 3px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
transition: background 0.2s ease, color 0.2s ease;
}
.switchBtnActive {
background: var(--color-black);
color: var(--color-white);
}
.modeHint {
margin: 16px 0 22px;
font-size: 13px;
font-weight: 400;
line-height: 1.55;
color: var(--color-muted);
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
}
.searchBlock {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
}
.latestBlock {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.latestLabel {
margin: 0;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
}
.clearSelection {
align-self: flex-start;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.clearSelection:hover,
.clearSelection:focus-visible {
color: var(--color-black);
}
.results {
border: 1px solid #ececec;
border-radius: 5px;
background: #fff;
max-height: 220px;
overflow: auto;
}
.resultsStatus,
.resultsError {
padding: 14px 16px;
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.resultsError {
color: var(--color-muted);
}
.resultItem {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f3f3f3;
transition: background 0.2s ease;
}
.resultItem:last-child {
border-bottom: 0;
}
.resultItem:hover,
.resultItem:focus-visible {
background: #f6f6f6;
}
.resultItem strong {
font-size: 14px;
font-weight: 400;
}
.resultName {
display: inline;
font-weight: 400;
}
.resultName span {
font-size: inherit;
font-weight: inherit;
letter-spacing: normal;
color: inherit;
}
.resultSep {
font-weight: 400;
color: var(--color-body);
}
.resultFa {
font-family: var(--font-farsi);
font-weight: 400;
letter-spacing: 0;
}
.resultItem > span,
.resultMeta {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.06em;
color: var(--color-soft);
}
.resultMeta :global(.fa) {
letter-spacing: 0;
}
.selectedMeta {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
border: 1px solid #ececec;
border-radius: 5px;
background: #fafafa;
}
.selectedMeta p {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
}
.selectedMeta strong {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
}
.selectedMeta span {
font-size: 14px;
font-weight: 400;
color: var(--color-black);
}
.selectedMeta :global(.fa) {
letter-spacing: 0;
}
.submitError {
margin: 0;
font-size: 12px;
font-weight: 400;
line-height: 1.45;
color: var(--color-muted);
}
.field {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 46px;
padding: 14px 14px 10px;
border: 1px solid #f0f0f0;
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: 400;
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 {
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 15px;
font-weight: 400;
width: 100%;
color: var(--color-black);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 6px;
}
.secondaryBtn,
.primaryBtn {
min-height: 34px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.14em;
}
.secondaryBtn {
border-color: #9e9e9e;
color: #9e9e9e;
}
.secondaryBtn:hover,
.secondaryBtn:focus-visible {
border-color: var(--color-black);
color: var(--color-white);
}
.primaryBtn {
min-width: 112px;
}
@media (max-width: 560px) {
.modal {
padding: 24px 18px 20px;
}
.actions {
flex-direction: column-reverse;
}
.secondaryBtn,
.primaryBtn {
width: 100%;
}
}
+619
View File
@@ -0,0 +1,619 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ApiError, isAbortError } from "@/lib/meshkee-api";
import { createMarketplaceBusiness } from "@/services/marketplace";
import { searchMeshkeeCategories } from "@/services/meshkee-categories";
import {
createMeshkeeBusiness,
getMeshkeeBusiness,
listLatestMeshkeeBusinesses,
searchMeshkeeBusinesses,
type MeshkeeBusinessDetail,
type MeshkeeBusinessSearchItem,
} from "@/services/meshkee-businesses";
import { PhoneLtr, withFaSpans } from "@/lib/faText";
import styles from "./AddBusinessModal.module.css";
export type AddBusinessMode = "connect" | "create";
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function ownerParts(detail: MeshkeeBusinessDetail | null, fallback?: {
ownerName?: string | null;
ownerCellNumber?: string | null;
}): { name: string; cell: string } {
if (detail?.owner) {
const name = [detail.owner.firstName, detail.owner.lastName]
.filter(Boolean)
.join(" ")
.trim();
return { name, cell: detail.owner.cellNumber || "" };
}
return {
name: fallback?.ownerName?.trim() || "",
cell: fallback?.ownerCellNumber?.trim() || "",
};
}
function BusinessPickRow({
item,
onSelect,
}: {
item: MeshkeeBusinessSearchItem;
onSelect: (item: MeshkeeBusinessSearchItem) => void;
}) {
return (
<button
type="button"
className={styles.resultItem}
role="option"
onClick={() => onSelect(item)}
>
<strong className={styles.resultName}>
<span>{item.name}</span>
{item.nameFa && item.nameFa !== item.name ? (
<>
<span className={styles.resultSep} aria-hidden>
{" "}
/{" "}
</span>
<span className={`fa ${styles.resultFa}`} lang="fa">
{item.nameFa}
</span>
</>
) : null}
</strong>
<span className={styles.resultMeta}>
/{item.slug}
{item.ownerName ? (
<>
{" · "}
{withFaSpans(item.ownerName)}
</>
) : null}
</span>
</button>
);
}
export default function AddBusinessModal({
open,
onClose,
onConnected,
}: {
open: boolean;
onClose: () => void;
onConnected?: () => void;
}) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const [mode, setMode] = useState<AddBusinessMode>("connect");
const [query, setQuery] = useState("");
const [results, setResults] = useState<MeshkeeBusinessSearchItem[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState("");
const [latest, setLatest] = useState<MeshkeeBusinessSearchItem[]>([]);
const [latestLoading, setLatestLoading] = useState(false);
const [latestError, setLatestError] = useState("");
const [selected, setSelected] = useState<MeshkeeBusinessSearchItem | null>(
null,
);
const [selectedDetail, setSelectedDetail] =
useState<MeshkeeBusinessDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [businessName, setBusinessName] = useState("");
const [ownerFirstName, setOwnerFirstName] = useState("");
const [ownerLastName, setOwnerLastName] = useState("");
const [ownerCell, setOwnerCell] = useState("+98");
const [ownerPassword, setOwnerPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
closeRef.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
useEffect(() => {
if (!open) {
setMode("connect");
setQuery("");
setResults([]);
setSearchError("");
setLatest([]);
setLatestError("");
setSelected(null);
setSelectedDetail(null);
setBusinessName("");
setOwnerFirstName("");
setOwnerLastName("");
setOwnerCell("+98");
setOwnerPassword("");
setSubmitError("");
setSubmitting(false);
}
}, [open]);
useEffect(() => {
if (!open || mode !== "connect") return;
const controller = new AbortController();
setLatestLoading(true);
setLatestError("");
void listLatestMeshkeeBusinesses(5, controller.signal)
.then((data) => {
if (controller.signal.aborted) return;
setLatest(data.items);
})
.catch((err) => {
if (isAbortError(err) || controller.signal.aborted) return;
setLatest([]);
setLatestError(
err instanceof ApiError
? err.message
: "Unable to load latest businesses.",
);
})
.finally(() => {
if (!controller.signal.aborted) setLatestLoading(false);
});
return () => controller.abort();
}, [open, mode]);
useEffect(() => {
if (!open || mode !== "connect") return;
const trimmed = query.trim();
if (trimmed.length < 2 || selected) {
setResults([]);
setSearching(false);
setSearchError("");
return;
}
const controller = new AbortController();
const timer = window.setTimeout(async () => {
setSearching(true);
setSearchError("");
try {
const data = await searchMeshkeeBusinesses(trimmed, controller.signal);
if (controller.signal.aborted) return;
setResults(data.items);
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return;
setResults([]);
setSearchError(
err instanceof ApiError
? err.message
: "Unable to search Meshkee businesses.",
);
} finally {
if (!controller.signal.aborted) setSearching(false);
}
}, 250);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [open, mode, query, selected]);
async function selectBusiness(item: MeshkeeBusinessSearchItem) {
setSelected(item);
setQuery(item.label || item.name);
setResults([]);
setSubmitError("");
setDetailLoading(true);
setSelectedDetail(null);
try {
const detail = await getMeshkeeBusiness(String(item.id));
setSelectedDetail(detail);
} catch (err) {
setSelectedDetail(null);
setSubmitError(
err instanceof Error ? err.message : "Could not load business details.",
);
} finally {
setDetailLoading(false);
}
}
const showSearchResults = !selected && query.trim().length >= 2;
const showLatestShortcut = !selected && query.trim().length < 2;
if (!open) return null;
return (
<div className={styles.overlay} role="presentation" onClick={onClose}>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.header}>
<div>
<p className={styles.kicker}>Marketplace</p>
<h2 id={titleId} className={styles.title}>
Add business
</h2>
</div>
<button
ref={closeRef}
type="button"
className={styles.closeBtn}
aria-label="Close"
onClick={onClose}
>
×
</button>
</div>
<div
className={styles.switch}
role="tablist"
aria-label="Add business mode"
>
<button
type="button"
role="tab"
aria-selected={mode === "connect"}
className={`${styles.switchBtn} ${mode === "connect" ? styles.switchBtnActive : ""}`}
onClick={() => setMode("connect")}
>
Connect Meshkee
</button>
<button
type="button"
role="tab"
aria-selected={mode === "create"}
className={`${styles.switchBtn} ${mode === "create" ? styles.switchBtnActive : ""}`}
onClick={() => setMode("create")}
>
Create new
</button>
</div>
<p className={styles.modeHint}>
{mode === "connect"
? "Choose an existing Meshkee business to list on the marketplace."
: "Create a Meshkee business with an owner, then list it here."}
</p>
{mode === "connect" ? (
<form
className={styles.form}
onSubmit={async (event) => {
event.preventDefault();
if (!selected) {
setSubmitError("Select a Meshkee business from search first.");
return;
}
setSubmitting(true);
setSubmitError("");
try {
await createMarketplaceBusiness({
meshkeeBusinessId: String(selected.id),
slug: selected.slug,
name: selected.name,
status: "draft",
});
onConnected?.();
onClose();
} catch (err) {
setSubmitError(
err instanceof Error
? err.message
: "Could not connect business.",
);
} finally {
setSubmitting(false);
}
}}
>
<div className={styles.searchBlock}>
<label className={styles.field}>
<span>Search Meshkee</span>
<input
value={selected ? selected.name : query}
onChange={(event) => {
setSelected(null);
setSelectedDetail(null);
setQuery(event.target.value);
setSubmitError("");
}}
placeholder="Type at least 2 characters…"
autoComplete="off"
disabled={submitting}
/>
</label>
{selected ? (
<button
type="button"
className={styles.clearSelection}
onClick={() => {
setSelected(null);
setSelectedDetail(null);
setQuery("");
}}
>
Clear selection
</button>
) : null}
{showSearchResults ? (
<div className={styles.results} role="listbox">
{searching ? (
<p className={styles.resultsStatus}>Searching…</p>
) : searchError ? (
<p className={styles.resultsError}>{searchError}</p>
) : results.length === 0 ? (
<p className={styles.resultsStatus}>No matches</p>
) : (
results.map((item) => (
<BusinessPickRow
key={String(item.id)}
item={item}
onSelect={(picked) => void selectBusiness(picked)}
/>
))
)}
</div>
) : null}
{showLatestShortcut ? (
<div className={styles.latestBlock}>
<p className={styles.latestLabel}>Latest on Meshkee</p>
<div className={styles.results} role="listbox">
{latestLoading ? (
<p className={styles.resultsStatus}>Loading…</p>
) : latestError ? (
<p className={styles.resultsError}>{latestError}</p>
) : latest.length === 0 ? (
<p className={styles.resultsStatus}>No businesses yet</p>
) : (
latest.map((item) => (
<BusinessPickRow
key={String(item.id)}
item={item}
onSelect={(picked) => void selectBusiness(picked)}
/>
))
)}
</div>
</div>
) : null}
</div>
{selected ? (
<div className={styles.selectedMeta}>
<p>
<strong>Business</strong>
<span>
{selected.name}
{selected.nameFa && selected.nameFa !== selected.name ? (
<>
{" / "}
<span className="fa" lang="fa">
{selected.nameFa}
</span>
</>
) : null}
</span>
</p>
<p>
<strong>Owner</strong>
<span>
{detailLoading ? (
"Loading…"
) : (
(() => {
const { name, cell } = ownerParts(
selectedDetail,
selected,
);
if (!name && !cell) return "No owner linked";
return (
<>
{name ? withFaSpans(name) : null}
{name && cell ? " · " : null}
{cell ? <PhoneLtr value={cell} /> : null}
</>
);
})()
)}
</span>
</p>
</div>
) : null}
{submitError ? (
<p className={styles.submitError} role="alert">
{submitError}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`outline-btn ${styles.secondaryBtn}`}
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.primaryBtn}`}
disabled={submitting || !selected || detailLoading}
>
{submitting ? "…" : "Connect"}
</button>
</div>
</form>
) : (
<form
className={styles.form}
onSubmit={async (event) => {
event.preventDefault();
setSubmitting(true);
setSubmitError("");
try {
const categories = await searchMeshkeeCategories("other");
const other =
categories.find((c) => c.slug === "other") ?? categories[0];
if (!other) {
throw new Error("Meshkee “Other” category not found.");
}
const name = businessName.trim();
const slug = slugify(name);
if (!slug) throw new Error("Enter a valid business name.");
const created = await createMeshkeeBusiness({
name,
nameFa: name,
slug,
categoryIds: [Number(other.id)],
ownerFirstName: ownerFirstName.trim(),
ownerLastName: ownerLastName.trim(),
ownerFirstNameEn: ownerFirstName.trim(),
ownerLastNameEn: ownerLastName.trim(),
ownerCellNumber: ownerCell.trim(),
ownerPassword,
});
await createMarketplaceBusiness({
meshkeeBusinessId: created.id,
slug: created.slug || slug,
name: created.name || name,
status: "draft",
});
onConnected?.();
onClose();
} catch (err) {
setSubmitError(
err instanceof Error
? err.message
: "Could not create business.",
);
} finally {
setSubmitting(false);
}
}}
>
<label className={styles.field}>
<span>Business name</span>
<input
value={businessName}
onChange={(event) => setBusinessName(event.target.value)}
placeholder="Business name"
required
autoComplete="organization"
disabled={submitting}
/>
</label>
<label className={styles.field}>
<span>Owner first name</span>
<input
value={ownerFirstName}
onChange={(event) => setOwnerFirstName(event.target.value)}
required
autoComplete="given-name"
disabled={submitting}
/>
</label>
<label className={styles.field}>
<span>Owner last name</span>
<input
value={ownerLastName}
onChange={(event) => setOwnerLastName(event.target.value)}
required
autoComplete="family-name"
disabled={submitting}
/>
</label>
<label className={styles.field}>
<span>Owner cell</span>
<input
value={ownerCell}
onChange={(event) => setOwnerCell(event.target.value)}
placeholder="+98912…"
pattern="^\+[1-9]\d{6,14}$"
required
autoComplete="tel"
inputMode="tel"
dir="ltr"
disabled={submitting}
/>
</label>
<label className={styles.field}>
<span>Owner password</span>
<input
type="password"
value={ownerPassword}
onChange={(event) => setOwnerPassword(event.target.value)}
minLength={8}
required
autoComplete="new-password"
disabled={submitting}
/>
</label>
{submitError ? (
<p className={styles.submitError} role="alert">
{submitError}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`outline-btn ${styles.secondaryBtn}`}
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.primaryBtn}`}
disabled={submitting}
>
{submitting ? "…" : "Create"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
.panel {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid #ececec;
border-radius: 5px;
background: #fafafa;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.title {
margin: 0;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.subtitle {
margin: 6px 0 0;
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.closeBtn {
width: 28px;
height: 28px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font-size: 18px;
font-weight: 400;
line-height: 1;
color: var(--color-soft);
transition: border-color 0.35s ease, color 0.35s ease;
}
.closeBtn:hover:not(:disabled),
.closeBtn:focus-visible:not(:disabled) {
border-color: var(--color-black);
color: var(--color-black);
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field span {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.field textarea {
width: 100%;
min-height: 180px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 12px;
font: inherit;
font-size: 13px;
font-weight: 400;
line-height: 1.55;
color: var(--color-black);
outline: none;
resize: vertical;
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
.field textarea:hover {
border-color: #d8d8d8;
}
.field textarea:focus {
border-color: var(--color-black);
}
.error {
margin: 0;
font-size: 13px;
font-weight: 400;
color: #8a1f11;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.secondaryBtn,
.primaryBtn {
min-height: 36px;
padding: 0 16px;
border-radius: 3px;
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
transition:
color 0.35s ease,
border-color 0.35s ease,
background-color 0.35s ease,
box-shadow 0.35s ease;
}
.secondaryBtn {
border: 1px solid #d4d4d4;
background: #fff;
color: var(--color-soft);
}
.secondaryBtn:hover:not(:disabled),
.secondaryBtn:focus-visible:not(:disabled) {
border-color: var(--color-black);
color: var(--color-black);
}
.primaryBtn {
border: 1px solid var(--color-black);
background: var(--color-black);
color: var(--color-white);
}
.primaryBtn:hover:not(:disabled),
.primaryBtn:focus-visible:not(:disabled) {
border-color: transparent;
}
.secondaryBtn:disabled,
.primaryBtn:disabled,
.closeBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import aiStyles from "@/styles/ai.module.css";
import styles from "./AiPromptPanel.module.css";
type Props = {
open: boolean;
title: string;
initialPrompt: string;
busy?: boolean;
error?: string;
disabled?: boolean;
onClose: () => void;
onRun: (prompt: string) => void;
};
export default function AiPromptPanel({
open,
title,
initialPrompt,
busy = false,
error = "",
disabled = false,
onClose,
onRun,
}: Props) {
const [prompt, setPrompt] = useState(initialPrompt);
useEffect(() => {
if (open) setPrompt(initialPrompt);
}, [open, initialPrompt]);
if (!open) return null;
return (
<div className={styles.panel}>
<div className={styles.head}>
<div>
<h3 className={styles.title}>{title}</h3>
<p className={styles.subtitle}>
Edit the prompt, then run AI to fill this field.
</p>
</div>
<button
type="button"
className={styles.closeBtn}
disabled={busy}
aria-label="Close"
onClick={onClose}
>
×
</button>
</div>
<label className={styles.field}>
<span>Prompt</span>
<textarea
rows={10}
value={prompt}
disabled={busy || disabled}
className={busy ? aiStyles.aiWorkingBorder : undefined}
onChange={(event) => setPrompt(event.target.value)}
/>
</label>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={styles.secondaryBtn}
disabled={busy}
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className={`${styles.primaryBtn} ${aiStyles.aiTrigger}`}
disabled={busy || disabled || prompt.trim().length < 10}
onClick={() => onRun(prompt.trim())}
>
{busy ? "Generating…" : "Run AI"}
</button>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useState, type FocusEvent } from "react";
import styles from "./AuthShell.module.css";
function clearAutofillOnFocus(event: FocusEvent<HTMLInputElement>) {
event.currentTarget.removeAttribute("readonly");
}
export function AuthField({
label,
type = "text",
name,
placeholder,
autoComplete,
required,
disabled,
dir,
}: {
label: string;
type?: string;
name: string;
placeholder?: string;
autoComplete?: string;
required?: boolean;
disabled?: boolean;
dir?: "ltr" | "rtl" | "auto";
}) {
return (
<label className={styles.field}>
<span>{label}</span>
<input
type={type}
name={name}
placeholder={placeholder}
autoComplete={autoComplete ?? "off"}
required={required}
disabled={disabled}
dir={dir}
defaultValue=""
readOnly
onFocus={clearAutofillOnFocus}
/>
</label>
);
}
export function AuthPasswordField({
label,
name,
autoComplete,
required,
disabled,
}: {
label: string;
name: string;
autoComplete?: string;
required?: boolean;
disabled?: boolean;
}) {
const [visible, setVisible] = useState(false);
return (
<div className={styles.passwordWrap}>
<label className={styles.field}>
<span>{label}</span>
<input
type={visible ? "text" : "password"}
name={name}
autoComplete={autoComplete ?? "off"}
required={required}
disabled={disabled}
minLength={8}
defaultValue=""
readOnly
onFocus={clearAutofillOnFocus}
/>
</label>
<button
type="button"
className={styles.eyeBtn}
aria-label={visible ? "Hide password" : "Show password"}
onClick={() => setVisible((v) => !v)}
disabled={disabled}
>
<img src="/images/icons/eye.svg" alt="" />
</button>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth, userCanAccessApp } from "@/components/AuthProvider";
import { getAccessToken, getRefreshToken } from "@/lib/meshkee-api";
import {
getCustomerDashboardOrigin,
hostAppFor,
type HostApp,
} from "@/lib/host";
function LoadingScreen({ message = "Loading" }: { message?: string }) {
return (
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#f6f6f6",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
padding: 24,
textAlign: "center",
}}
>
{message}
</div>
);
}
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
function handoffToWebsite(next: string) {
try {
const target = new URL(next);
if (target.origin !== new URL(websiteUrl).origin) return false;
const access = getAccessToken();
const refresh = getRefreshToken();
if (!access || !refresh) return false;
const hash = new URLSearchParams({
access,
refresh,
next: `${target.pathname}${target.search}${target.hash}` || "/register",
}).toString();
window.location.replace(`${target.origin}/auth/handoff#${hash}`);
return true;
} catch {
return false;
}
}
export function ProtectedRoute({
children,
requiredApp,
}: {
children: React.ReactNode;
requiredApp?: HostApp;
}) {
const { user, isLoading } = useAuth();
const router = useRouter();
const app =
requiredApp ??
hostAppFor(typeof window !== "undefined" ? window.location.host : "");
useEffect(() => {
if (isLoading) return;
if (!user) {
if (app === "business") {
// Do not auto-bounce to admin (that was the manage-business loop).
// Owners can sign in on the customer host.
const params = new URLSearchParams(window.location.search);
if (params.has("businessId")) {
return;
}
window.location.replace(`${getCustomerDashboardOrigin()}/login`);
return;
}
router.replace("/login");
return;
}
if (!userCanAccessApp(user, app)) {
if (app === "admin") {
window.location.replace(`${getCustomerDashboardOrigin()}/`);
return;
}
router.replace("/login");
}
}, [isLoading, user, router, app]);
if (isLoading) {
return <LoadingScreen />;
}
if (!user) {
if (
app === "business" &&
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).has("businessId")
) {
return (
<LoadingScreen message="Could not open this business. Go back and use Manage business again." />
);
}
return <LoadingScreen message="Opening…" />;
}
if (!userCanAccessApp(user, app)) {
return <LoadingScreen />;
}
return children;
}
export function GuestRoute({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (isLoading || !user) return;
const nextParam = new URLSearchParams(window.location.search).get("next");
if (nextParam && handoffToWebsite(nextParam)) return;
const app = hostAppFor(window.location.host);
if (app === "admin") {
router.replace("/");
return;
}
if (app === "business") {
router.replace("/profile");
return;
}
router.replace("/");
}, [isLoading, user, router]);
if (isLoading) return <LoadingScreen />;
if (user) return null;
return children;
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { ApiError, getAccessToken, isAbortError, migrateTokensToSharedCookies } from "@/lib/meshkee-api";
import {
getAdminDashboardOrigin,
getCustomerDashboardOrigin,
hostAppFor,
type HostApp,
} from "@/lib/host";
import {
clearManagedBusinessId,
getManagedBusinessId,
setManagedBusinessId,
} from "@/lib/managed-business";
import {
fetchCurrentUser,
login as loginRequest,
logout as logoutRequest,
register as registerRequest,
type RegisterPayload,
} from "@/services/auth";
import type { AuthUser, DashboardType } from "@/types/auth";
interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
activeBusinessId: string | null;
isBusinessOwner: boolean;
isSuperAdmin: boolean;
login: (cellNumber: string, password: string) => Promise<DashboardType>;
register: (payload: RegisterPayload) => Promise<DashboardType>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function pickActiveBusinessId(user: AuthUser): string | null {
const owned = user.businesses.find((item) => item.isOwner);
const first = owned ?? user.businesses[0];
return first ? String(first.id) : null;
}
export function userCanAccessApp(user: AuthUser, app: HostApp): boolean {
if (app === "admin") return user.dashboard === "super_admin";
if (app === "customer") {
// Shared entry: customer, business owners, and superadmins all land here first.
return (
user.dashboard === "customer" ||
user.dashboard === "business" ||
user.dashboard === "super_admin"
);
}
// Business dashboard: owners/staff, or superadmin (via managed business id).
if (user.dashboard === "super_admin") return true;
return Boolean(pickActiveBusinessId(user));
}
function currentHost(): string {
if (typeof window === "undefined") return "";
return window.location.host;
}
/** Prefer ?businessId= on the URL, then stored managed id. */
function syncManagedBusinessFromUrl() {
if (typeof window === "undefined") return getManagedBusinessId();
const fromUrl = new URLSearchParams(window.location.search)
.get("businessId")
?.trim();
if (fromUrl) {
setManagedBusinessId(fromUrl);
return fromUrl;
}
return getManagedBusinessId();
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [managedBusinessId, setManagedBusinessIdState] = useState<string | null>(
null,
);
const logout = useCallback(() => {
logoutRequest();
clearManagedBusinessId();
setManagedBusinessIdState(null);
setUser(null);
}, []);
useEffect(() => {
const controller = new AbortController();
async function init() {
// Handoff installs tokens via inline script and redirects — don't race it.
if (
typeof window !== "undefined" &&
window.location.pathname === "/auth/handoff"
) {
if (!controller.signal.aborted) setIsLoading(false);
return;
}
migrateTokensToSharedCookies();
setManagedBusinessIdState(syncManagedBusinessFromUrl());
if (!getAccessToken()) {
if (!controller.signal.aborted) setIsLoading(false);
return;
}
try {
const { user: currentUser } = await fetchCurrentUser(controller.signal);
if (controller.signal.aborted) return;
const app = hostAppFor(currentHost());
if (!userCanAccessApp(currentUser, app)) {
logout();
return;
}
const managed = syncManagedBusinessFromUrl();
setManagedBusinessIdState(managed);
setUser(currentUser);
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return;
// On business host, keep tokens so a transient /me failure doesn't
// bounce manage-business back to admin.
if (hostAppFor(currentHost()) === "business") {
if (!controller.signal.aborted) setIsLoading(false);
return;
}
logout();
} finally {
if (!controller.signal.aborted) setIsLoading(false);
}
}
void init();
return () => controller.abort();
}, [logout]);
const login = useCallback(async (cellNumber: string, password: string) => {
const data = await loginRequest(cellNumber, password);
const app = hostAppFor(currentHost());
if (!userCanAccessApp(data.user, app)) {
logoutRequest();
const message =
app === "admin"
? "This account does not have super admin access."
: app === "business"
? "No business is linked to this account."
: "This account cannot use the customer dashboard.";
throw new ApiError(message, 403);
}
if (app === "business" && data.user.dashboard === "super_admin") {
const managed = syncManagedBusinessFromUrl();
setManagedBusinessIdState(managed);
if (!managed && !pickActiveBusinessId(data.user)) {
logoutRequest();
throw new ApiError(
"Open a business from the admin dashboard and use Manage business.",
403,
);
}
}
setManagedBusinessIdState(syncManagedBusinessFromUrl());
setUser(data.user);
return data.user.dashboard;
}, []);
const register = useCallback(async (payload: RegisterPayload) => {
const data = await registerRequest(payload);
if (!data.accessToken || !data.refreshToken || !data.user) {
throw new ApiError(
data.message ||
"Registration needs phone verification before you can continue.",
403,
);
}
const app = hostAppFor(currentHost());
if (!userCanAccessApp(data.user, app)) {
logoutRequest();
throw new ApiError(
"This account cannot use the customer dashboard.",
403,
);
}
setManagedBusinessIdState(syncManagedBusinessFromUrl());
setUser(data.user);
return data.user.dashboard;
}, []);
const isSuperAdmin = user?.dashboard === "super_admin";
/**
* Shared business.* host — which Meshkee business?
* - Superadmin: managed id from “Manage business” handoff (required for other businesses)
* - Everyone else: first owned / linked business on the user
*/
const activeBusinessId = useMemo(() => {
if (!user) return null;
if (user.dashboard === "super_admin") {
return managedBusinessId || pickActiveBusinessId(user);
}
return pickActiveBusinessId(user);
}, [user, managedBusinessId]);
const isBusinessOwner = useMemo(
() => Boolean(user?.businesses.some((item) => item.isOwner)),
[user],
);
const value = useMemo(
() => ({
user,
isLoading,
activeBusinessId,
isBusinessOwner,
isSuperAdmin: Boolean(isSuperAdmin),
login,
register,
logout,
}),
[
user,
isLoading,
activeBusinessId,
isBusinessOwner,
isSuperAdmin,
login,
register,
logout,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
/**
* After login: stay on the host you signed into.
* Superadmin can open the admin dashboard from customer home when needed.
*/
export function postLoginHref(dashboard: DashboardType): string {
const app = hostAppFor(currentHost());
if (app === "admin") {
return dashboard === "super_admin" ? "/" : `${getCustomerDashboardOrigin()}/`;
}
if (app === "business") return "/profile";
if (app === "customer") return "/";
if (dashboard === "super_admin") return `${getAdminDashboardOrigin()}/`;
return `${getCustomerDashboardOrigin()}/`;
}
+352
View File
@@ -0,0 +1,352 @@
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px var(--page-pad);
background-color: #f6f6f6;
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.card {
position: relative;
width: min(100%, 1148px);
min-height: 660px;
display: grid;
grid-template-columns: minmax(220px, 344px) minmax(0, 1fr);
grid-template-rows: 1fr;
background: #fff;
border-radius: 5px;
box-shadow: 33px 25px 29.7px -14px rgba(0, 0, 0, 0.03);
overflow: visible;
}
.visual {
position: relative;
align-self: stretch;
background: #efefef;
border-radius: 5px 0 0 5px;
overflow: visible;
}
.ship {
position: absolute;
left: 8%;
bottom: 0;
width: auto;
height: 72%;
max-width: none;
object-fit: contain;
object-position: left bottom;
pointer-events: none;
z-index: 2;
filter: drop-shadow(0 18px 28px rgba(0, 0, 0, 0.12));
}
.content {
position: relative;
z-index: 1;
align-self: stretch;
display: grid;
place-items: center;
padding: 48px 40px;
min-width: 0;
border-radius: 0 5px 5px 0;
}
.contentInner {
width: min(100%, 468px);
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
width: 100%;
}
.brand img {
width: 69px;
height: 69px;
object-fit: contain;
}
.brandText {
display: flex;
flex-direction: column;
gap: 2px;
text-transform: uppercase;
}
.brandText span {
font-size: 18px;
font-weight: 400;
}
.brandText strong {
font-size: 20px;
font-weight: 500;
}
.form {
display: flex;
flex-direction: column;
gap: 22px;
width: 100%;
}
.field {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 46px;
padding: 14px 14px 10px;
border: 1px solid #f6f6f6;
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: 400;
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 {
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 16px;
font-weight: 400;
width: 100%;
color: var(--color-black);
}
.passwordWrap {
position: relative;
}
.passwordWrap .field {
padding-right: 40px;
}
.eyeBtn {
position: absolute;
right: 14px;
bottom: 14px;
width: 18px;
height: 18px;
display: grid;
place-items: center;
opacity: 0.55;
}
.eyeBtn img {
width: 14px;
height: 14px;
object-fit: contain;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 4px;
}
.secondaryBtn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 29px;
padding: 0 4px;
border: 0;
background: transparent;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6a6a6a;
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-thickness: 1px;
transition: color 0.2s ease;
}
.secondaryBtn:hover,
.secondaryBtn:focus-visible {
color: var(--color-black);
}
.secondaryBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primaryBtn {
min-width: 107px;
min-height: 29px;
margin-left: auto;
padding: 0 18px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
}
.error {
margin: 0;
padding: 10px 12px;
border-radius: 5px;
background: #f6f6f6;
border: 1px solid #e8e8e8;
font-size: 12px;
font-weight: 400;
line-height: 1.45;
color: #4a4a4a;
width: 100%;
}
.note {
margin-top: 28px;
font-size: 10.5px;
font-weight: 400;
color: #8e8e8e;
text-align: center;
text-transform: capitalize;
}
.note a,
.noteLink {
color: #8e8e8e;
background: none;
border: 0;
padding: 0;
font: inherit;
font-size: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.note a:hover,
.noteLink:hover {
color: var(--color-black);
}
.hint {
margin: 0 0 4px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8e8e8e;
}
.note em {
font-style: normal;
color: #cacaca;
}
.rule {
border: 0;
border-top: 1px solid #ececec;
margin: 20px 0 14px;
width: 100%;
}
.links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
font-size: 10px;
font-weight: 400;
color: #8e8e8e;
width: 100%;
}
.links a {
color: inherit;
}
.socials {
margin-top: 16px;
display: flex;
justify-content: center;
gap: 18px;
width: 100%;
}
.socials img {
object-fit: contain;
}
@media (max-width: 900px) {
.card {
grid-template-columns: 1fr;
min-height: auto;
}
.visual {
min-height: 220px;
border-radius: 5px 5px 0 0;
}
.ship {
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: auto;
height: 88%;
}
.content {
padding: 32px 24px 28px;
border-radius: 0 0 5px 5px;
}
.brand,
.form,
.note,
.rule,
.links,
.socials {
width: 100%;
}
.actions {
flex-direction: column-reverse;
align-items: stretch;
}
.secondaryBtn,
.primaryBtn {
width: 100%;
min-width: 0;
}
.secondaryBtn {
justify-content: center;
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { ReactNode } from "react";
import styles from "./AuthShell.module.css";
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
export default function AuthShell({
titleLine1,
titleLine2,
children,
footerNote,
}: {
titleLine1: string;
titleLine2: string;
children: ReactNode;
footerNote?: ReactNode;
}) {
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.visual}>
<img className={styles.ship} src="/images/auth/ship.png" alt="" />
</div>
<div className={styles.content}>
<div className={styles.contentInner}>
<div className={styles.brand}>
<img src="/images/icons/logo.png" alt="" width={69} height={69} />
<div className={styles.brandText}>
<span>{titleLine1}</span>
<strong>{titleLine2}</strong>
</div>
</div>
{children}
{footerNote ? <div className={styles.note}>{footerNote}</div> : null}
<hr className={styles.rule} />
<nav className={styles.links} aria-label="Auth footer">
<a href={`${websiteUrl}/businesses`}>Categories</a>
<span>|</span>
<a href={`${websiteUrl}/#vision`}>Vision</a>
<span>|</span>
<a href={`${websiteUrl}/about`}>About us</a>
<span>|</span>
<a href={`${websiteUrl}/contact`}>Contact us</a>
</nav>
<div className={styles.socials}>
<a href="https://instagram.com" aria-label="Instagram">
<img
src="/images/icons/instagram.png"
alt=""
width={20}
height={20}
/>
</a>
<a href="https://telegram.org" aria-label="Telegram">
<img
src="/images/icons/telegram.png"
alt=""
width={20}
height={20}
/>
</a>
<a href="https://facebook.com" aria-label="Facebook">
<img
src="/images/icons/facebook.png"
alt=""
width={16}
height={16}
/>
</a>
</div>
</div>
</div>
</div>
</div>
);
}
+444
View File
@@ -0,0 +1,444 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
import CategoryTreeView, {
type CategoryTreeNode,
} from "@/components/CategoryTreeView";
import ConfirmDialog from "@/components/ConfirmDialog";
import type { MeshkeeCategory } from "@/services/meshkee-categories";
import {
createWebsiteBlogCategory,
deleteWebsiteBlogCategory,
listWebsiteBlogCategories,
updateWebsiteBlogCategory,
type WebsiteBlogCategory,
} from "@/services/website-blog";
import styles from "./BusinessCategoriesPage.module.css";
type Draft = {
id?: string;
parentId: string;
name: string;
nameFa: string;
description: string;
};
function emptyDraft(parentId = ""): Draft {
return {
parentId,
name: "",
nameFa: "",
description: "",
};
}
function categoryLabel(category: {
name: string;
nameFa?: string | null;
}) {
if (category.nameFa && category.nameFa !== category.name) {
return `${category.name} / ${category.nameFa}`;
}
return category.name;
}
function toTreeSelect(items: WebsiteBlogCategory[]): MeshkeeCategory[] {
return items.map((item) => ({
id: item.id,
parentId: item.parentId,
name: item.name,
nameFa: item.nameFa ?? null,
slug: "",
description: item.description ?? null,
icon: null,
sortOrder: 0,
tags: [],
}));
}
function toViewNodes(items: WebsiteBlogCategory[]): CategoryTreeNode[] {
const byId = new Map<string, CategoryTreeNode>();
for (const item of items) {
byId.set(item.id, {
id: item.id,
name: categoryLabel(item),
children: [],
});
}
const roots: CategoryTreeNode[] = [];
for (const item of items) {
const node = byId.get(item.id)!;
if (item.parentId && byId.has(item.parentId)) {
byId.get(item.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: CategoryTreeNode[]) => {
nodes.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(roots);
return roots;
}
function collectDescendantIds(
rootId: string,
items: WebsiteBlogCategory[],
): Set<string> {
const childrenByParent = new Map<string, string[]>();
for (const item of items) {
if (!item.parentId) continue;
const list = childrenByParent.get(item.parentId) ?? [];
list.push(item.id);
childrenByParent.set(item.parentId, list);
}
const result = new Set<string>([rootId]);
const queue = [rootId];
while (queue.length) {
const current = queue.shift()!;
for (const childId of childrenByParent.get(current) ?? []) {
if (!result.has(childId)) {
result.add(childId);
queue.push(childId);
}
}
}
return result;
}
export default function BlogCategoriesPage() {
const [categories, setCategories] = useState<WebsiteBlogCategory[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [removing, setRemoving] = useState<WebsiteBlogCategory | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const tree = useMemo(() => toViewNodes(categories), [categories]);
const byId = useMemo(() => {
const map = new Map<string, WebsiteBlogCategory>();
for (const item of categories) map.set(item.id, item);
return map;
}, [categories]);
const parentOptions = useMemo(() => {
const draftId = draft?.id;
const source = !draftId
? categories
: categories.filter(
(item) => !collectDescendantIds(draftId, categories).has(item.id),
);
return toTreeSelect(source);
}, [categories, draft?.id]);
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
setCategories(await listWebsiteBlogCategories());
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
function openCreate(parentId = "") {
setDraft(emptyDraft(parentId));
setError("");
}
function openEdit(id: string) {
const category = byId.get(id);
if (!category) return;
setDraft({
id: category.id,
parentId: category.parentId ?? "",
name: category.name,
nameFa: category.nameFa ?? "",
description: category.description ?? "",
});
setError("");
}
async function saveDraft() {
if (!draft) return;
if (!draft.name.trim()) {
setError("Enter an English name.");
return;
}
setSaving(true);
setError("");
try {
const body = {
parentId: draft.parentId || null,
name: draft.name.trim(),
nameFa: draft.nameFa.trim(),
description: draft.description.trim(),
};
if (draft.id) {
await updateWebsiteBlogCategory(draft.id, body);
} else {
await createWebsiteBlogCategory(body);
}
setDraft(null);
await reload();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not save category.",
);
} finally {
setSaving(false);
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
setError("");
try {
await deleteWebsiteBlogCategory(removing.id);
setRemoving(null);
await reload();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not remove category.",
);
} finally {
setRemoveBusy(false);
}
}
const removingName = removing ? categoryLabel(removing) : "this category";
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<Link href="/">Home</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<Link href="/website/blog">Blog</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Categories</span>
</nav>
<h1 className={styles.title}>Categories</h1>
<p className={styles.lead}>
Blog categories. Add, edit, or remove the tree.
</p>
</div>
<div className={styles.headActions}>
<p className={styles.count}>
{loading ? "Loading…" : `${categories.length} categories`}
</p>
<button
type="button"
className={`outline-btn ${styles.addBtn}`}
onClick={() => openCreate()}
>
Add category
</button>
</div>
</header>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<section className={styles.card}>
{loading ? <p className={styles.empty}>Loading categories…</p> : null}
{!loading ? (
<CategoryTreeView
nodes={tree}
actions={{
onAddChild: openCreate,
onEdit: openEdit,
onRemove: (id) => {
const category = byId.get(id);
if (category) setRemoving(category);
},
}}
/>
) : null}
</section>
{draft ? (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) {
setDraft(null);
}
}}
>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="blog-category-draft-title"
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Categories</p>
<h2 id="blog-category-draft-title" className={styles.modalTitle}>
{draft.id ? "Edit category" : "Add category"}
</h2>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
disabled={saving}
onClick={() => setDraft(null)}
>
×
</button>
</div>
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault();
void saveDraft();
}}
>
<CategoryTreeSelect
categories={parentOptions}
value={draft.parentId ? [draft.parentId] : []}
selection="single"
label="Parent"
placeholder="Top level"
disabled={saving}
onChange={(ids) =>
setDraft((current) =>
current
? { ...current, parentId: ids[0] ?? "" }
: current,
)
}
/>
{draft.parentId ? (
<button
type="button"
className={styles.clearParent}
disabled={saving}
onClick={() =>
setDraft((current) =>
current ? { ...current, parentId: "" } : current,
)
}
>
Top level
</button>
) : null}
<label className={styles.field}>
<span>Name EN</span>
<input
value={draft.name}
disabled={saving}
required
autoFocus
onChange={(event) =>
setDraft((current) =>
current
? { ...current, name: event.target.value }
: current,
)
}
/>
</label>
<label className={styles.field}>
<span>Name FA</span>
<input
className="fa"
lang="fa"
dir="rtl"
value={draft.nameFa}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, nameFa: event.target.value }
: current,
)
}
/>
</label>
<label className={styles.field}>
<span>Description</span>
<textarea
rows={3}
value={draft.description}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, description: event.target.value }
: current,
)
}
/>
</label>
<div className={styles.modalActions}>
<button
type="button"
className={`outline-btn ${styles.ghostBtn}`}
disabled={saving}
onClick={() => setDraft(null)}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.addBtn}`}
disabled={saving}
>
{saving ? "Saving…" : draft.id ? "Update" : "Add"}
</button>
</div>
</form>
</div>
</div>
) : null}
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove category"
title={`Remove ${removingName}?`}
message="This category and its subcategories will be removed."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
+584
View File
@@ -0,0 +1,584 @@
.grid {
list-style: none;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
background: transparent;
cursor: pointer;
}
.card:has(.moreWrapOpen),
.card:has(.moreWrap:hover),
.card:has(.moreWrap:focus-within) {
z-index: 5;
}
.coverWrap {
position: relative;
height: 168px;
overflow: hidden;
border-radius: 5px 5px 0 0;
background: var(--color-card);
}
.cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.coverFallback {
width: 100%;
height: 100%;
background-color: #e8e8e8;
}
.status {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 5px 9px;
border-radius: 3px;
background: rgba(246, 246, 246, 0.92);
color: var(--color-black);
}
.statusLive {
background: #111;
color: #fff;
}
.statusReview {
background: rgba(232, 232, 232, 0.95);
}
.statusDraft,
.statusHidden,
.statusRejected {
color: var(--color-soft);
}
.body {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid var(--color-card);
border-top: 0;
border-radius: 0 0 5px 5px;
padding: 0;
background: var(--color-white);
overflow: visible;
}
.body:has(.moreWrapOpen),
.body:has(.moreWrap:hover),
.body:has(.moreWrap:focus-within) {
z-index: 4;
}
.info {
position: relative;
z-index: 2;
margin: -40px 18px 0;
background: var(--color-card);
border-radius: 5px;
padding: 14px 16px;
display: flex;
align-items: center;
}
/* Keep logo / name readable while the body wipe fills behind them */
.body:global(.tile-hover):hover .info .name,
.body:global(.tile-hover):focus-within .info .name {
color: var(--color-black);
}
.body:global(.tile-hover):hover .info .tagline,
.body:global(.tile-hover):focus-within .info .tagline {
color: var(--color-soft);
}
.body:global(.tile-hover):hover .info .logoMark,
.body:global(.tile-hover):focus-within .info .logoMark {
color: var(--color-muted);
}
.identity {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.logo {
width: auto;
height: 52px;
max-width: min(160px, 42%);
object-fit: contain;
object-position: left center;
flex-shrink: 0;
border-radius: 4px;
background: transparent;
}
.logoMark {
width: 52px;
height: 52px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 4px;
background: var(--color-white);
font-size: 18px;
font-weight: 400;
letter-spacing: 0.08em;
color: var(--color-muted);
}
.copy {
min-width: 0;
}
.name {
font-size: 13px;
font-weight: 400;
letter-spacing: 0.6px;
text-transform: uppercase;
line-height: 1.25;
}
.tagline {
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
margin-top: 3px;
transition: color 0.25s ease;
}
.bottom {
position: relative;
z-index: 1;
margin-top: auto;
padding: 14px 18px 16px;
border-radius: 0 0 5px 5px;
overflow: visible;
}
.bottom:has(.moreWrapOpen),
.bottom:has(.moreWrap:hover),
.bottom:has(.moreWrap:focus-within) {
z-index: 6;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.slug {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.04em;
color: var(--color-body);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.featured {
font-size: 10px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-black);
}
.moreItemSpecial {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--color-muted);
}
.body:global(.tile-hover):hover .moreItemSpecial,
.body:global(.tile-hover):focus-within .moreItemSpecial,
.body:hover .moreItemSpecial,
.body:focus-within .moreItemSpecial {
color: var(--color-muted);
}
.body:global(.tile-hover):hover .moreItemSpecial:hover,
.body:global(.tile-hover):focus-within .moreItemSpecial:hover,
.body:global(.tile-hover):hover .moreItemSpecial:focus-visible,
.body:global(.tile-hover):focus-within .moreItemSpecial:focus-visible,
.moreItemSpecial:hover,
.moreItemSpecial:focus-visible {
color: var(--color-black);
}
.specialChip {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: auto;
min-width: 18px;
height: 18px;
margin-left: 10px;
padding: 0 5px;
border-radius: 999px;
background: var(--color-black);
color: var(--color-white);
font-style: normal;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.04em;
line-height: 1;
pointer-events: none;
}
.body:global(.tile-hover):hover .specialChip,
.body:global(.tile-hover):focus-within .specialChip,
.body:hover .specialChip,
.body:focus-within .specialChip,
.moreItemSpecial:hover .specialChip,
.moreItemSpecial:focus-visible .specialChip,
.body:global(.tile-hover):hover .moreItemSpecial:hover .specialChip,
.body:global(.tile-hover):focus-within .moreItemSpecial:hover .specialChip {
background: var(--color-black);
color: var(--color-white);
}
.controls {
margin-top: 12px;
padding-top: 14px;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0;
border-top: 1px solid #f0f0f0;
overflow: visible;
}
.control {
appearance: none;
border: 0;
background: transparent;
padding: 0 8px;
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-soft);
text-decoration: none;
cursor: pointer;
line-height: 1.4;
white-space: nowrap;
flex-shrink: 0;
transition: color 0.2s ease;
}
.control + .control {
border-left: 1px solid #e4e4e4;
}
.control:hover,
.control:focus-visible {
color: var(--color-black);
}
.body:hover .control,
.body:focus-within .control,
.body:hover .control:hover,
.body:focus-within .control:hover,
.body:hover .control:focus-visible,
.body:focus-within .control:focus-visible,
.body:hover .controlAccent,
.body:focus-within .controlAccent,
.body:hover .slug,
.body:focus-within .slug,
.body:hover .featured,
.body:focus-within .featured {
color: var(--color-white);
}
.body:hover .controls,
.body:focus-within .controls {
border-top-color: rgba(255, 255, 255, 0.2);
}
.body:hover .control + .control,
.body:focus-within .control + .control {
border-left-color: rgba(255, 255, 255, 0.22);
}
.body:hover .moreWrap::before,
.body:focus-within .moreWrap::before {
background: rgba(255, 255, 255, 0.22);
}
.body:hover .moreTrigger img,
.body:focus-within .moreTrigger img {
opacity: 1;
filter: brightness(0) invert(1);
}
.control:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.controlAccent {
color: var(--color-muted);
}
.controlPrimary {
color: var(--color-black);
font-weight: 400;
}
.moreWrap {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
}
.moreWrapOpen,
.moreWrap:hover,
.moreWrap:focus-within {
z-index: 8;
}
.moreWrap::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: #e4e4e4;
}
/* Invisible bridge so the cursor can reach the menu without losing hover */
.moreWrap::after {
content: "";
position: absolute;
right: 0;
bottom: 100%;
width: 148px;
height: 14px;
}
.moreTrigger {
display: inline-flex;
align-items: center;
gap: 6px;
border-left: 0 !important;
}
.moreTrigger img {
width: 9px;
height: 9px;
object-fit: contain;
opacity: 0.55;
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
}
.moreWrap:hover .moreTrigger,
.moreWrap:focus-within .moreTrigger,
.moreWrapOpen .moreTrigger {
color: var(--color-black);
}
.body:hover .moreWrap:hover .moreTrigger,
.body:focus-within .moreWrap:hover .moreTrigger,
.body:hover .moreWrap:focus-within .moreTrigger,
.body:focus-within .moreWrap:focus-within .moreTrigger,
.body:hover .moreWrapOpen .moreTrigger,
.body:focus-within .moreWrapOpen .moreTrigger {
color: var(--color-white);
}
.moreWrap:hover .moreTrigger img,
.moreWrap:focus-within .moreTrigger img,
.moreWrapOpen .moreTrigger img {
opacity: 1;
filter: brightness(0);
}
.body:hover .moreWrap:hover .moreTrigger img,
.body:focus-within .moreWrap:hover .moreTrigger img,
.body:hover .moreWrap:focus-within .moreTrigger img,
.body:focus-within .moreWrap:focus-within .moreTrigger img,
.body:hover .moreWrapOpen .moreTrigger img,
.body:focus-within .moreWrapOpen .moreTrigger img {
filter: brightness(0) invert(1);
}
.moreMenu {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 20;
min-width: 200px;
padding: 6px;
border: 1px solid #ececec;
border-radius: 5px;
background: var(--color-white);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
gap: 2px;
opacity: 0;
visibility: hidden;
transform: translateY(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease,
visibility 0.16s ease;
}
.moreWrap:hover .moreMenu,
.moreWrap:focus-within .moreMenu,
.moreWrapOpen .moreMenu {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.moreWrapOpen::after,
.moreWrap:hover::after,
.moreWrap:focus-within::after {
z-index: 7;
}
.moreItem {
appearance: none;
border: 0;
background: transparent;
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border-radius: 3px;
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-muted);
text-decoration: none;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
/* Beat global .tile-hover a { color: white } so Website stays readable */
.body:global(.tile-hover):hover .moreItem,
.body:global(.tile-hover):focus-within .moreItem,
.body:hover .moreItem,
.body:focus-within .moreItem {
color: var(--color-muted);
}
.moreItem:hover,
.moreItem:focus-visible,
.body:global(.tile-hover):hover .moreItem:hover,
.body:global(.tile-hover):focus-within .moreItem:hover,
.body:global(.tile-hover):hover .moreItem:focus-visible,
.body:global(.tile-hover):focus-within .moreItem:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.moreItemDanger:hover,
.moreItemDanger:focus-visible,
.body:global(.tile-hover):hover .moreItemDanger:hover,
.body:global(.tile-hover):focus-within .moreItemDanger:hover,
.body:global(.tile-hover):hover .moreItemDanger:focus-visible,
.body:global(.tile-hover):focus-within .moreItemDanger:focus-visible {
color: #b42318;
background: #fff5f4;
}
.moreItem:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.empty {
min-height: 280px;
border-radius: 5px;
background: var(--color-white);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 28px;
}
.emptyTitle {
font-size: 14px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.emptyCopy {
margin-top: 12px;
max-width: 360px;
font-size: 13px;
font-weight: 400;
line-height: 1.6;
color: var(--color-muted);
}
@media (max-width: 1280px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 980px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.grid {
grid-template-columns: 1fr;
}
.controls {
gap: 8px 0;
}
.control {
padding: 0 8px;
}
}
+364
View File
@@ -0,0 +1,364 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { openBusinessDashboardAsAdmin } from "@/lib/session-handoff";
import { getSpecialBusinesses } from "@/services/special-businesses";
import type { BusinessStatus, MarketplaceBusiness } from "@/types/business";
import styles from "./BusinessCardList.module.css";
const STATUS_LABEL: Record<BusinessStatus, string> = {
draft: "Pending",
pending_review: "Pending",
published: "Published",
hidden: "Hidden",
rejected: "Rejected",
};
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
function statusClass(status: BusinessStatus) {
switch (status) {
case "published":
return styles.statusLive;
case "pending_review":
case "draft":
return styles.statusReview;
case "hidden":
return styles.statusHidden;
case "rejected":
return styles.statusRejected;
default:
return styles.statusReview;
}
}
function publicUrl(slug: string) {
return `${websiteUrl}/businesses/${slug}`;
}
export default function BusinessCardList({
items,
onApprove,
onDisable,
onRemove,
onMetaTag,
onMakeSpecial,
busyId,
specialTick = 0,
emptyTitle = "No businesses yet",
emptyCopy = "Marketplace listings will appear here once you link a Meshkee business.",
}: {
items: MarketplaceBusiness[];
onApprove?: (business: MarketplaceBusiness) => void;
onDisable?: (business: MarketplaceBusiness) => void;
onRemove?: (business: MarketplaceBusiness) => void;
onMetaTag?: (business: MarketplaceBusiness) => void;
onMakeSpecial?: (business: MarketplaceBusiness) => void;
busyId?: string | null;
/** Bump to reload special-carousel membership chips */
specialTick?: number;
emptyTitle?: string;
emptyCopy?: string;
}) {
const router = useRouter();
const [openMoreId, setOpenMoreId] = useState<string | null>(null);
const [specialCountByBusinessId, setSpecialCountByBusinessId] = useState<
Map<string, number>
>(() => new Map());
const moreRefs = useRef<Map<string, HTMLDivElement>>(new Map());
useEffect(() => {
if (!openMoreId) return;
const onPointerDown = (event: PointerEvent) => {
const root = moreRefs.current.get(openMoreId);
if (root && !root.contains(event.target as Node)) {
setOpenMoreId(null);
}
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [openMoreId]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const config = await getSpecialBusinesses();
if (cancelled) return;
const next = new Map<string, number>();
for (const carousel of config.carousels) {
for (const businessId of carousel.businessIds) {
next.set(businessId, (next.get(businessId) ?? 0) + 1);
}
}
setSpecialCountByBusinessId(next);
} catch {
if (!cancelled) setSpecialCountByBusinessId(new Map());
}
})();
return () => {
cancelled = true;
};
}, [specialTick, items.length]);
if (items.length === 0) {
return (
<div className={styles.empty}>
<p className={styles.emptyTitle}>{emptyTitle}</p>
<p className={styles.emptyCopy}>{emptyCopy}</p>
</div>
);
}
return (
<ul className={styles.grid}>
{items.map((business) => {
const displayName = business.name || business.slug;
const category = business.categoryName || "Uncategorized";
const categoryTrail =
business.categoryTrail || business.categoryName || "Uncategorized";
const href = publicUrl(business.slug);
const showHref = `/businesses/${business.id}`;
const isHidden = business.status === "hidden";
const isPending =
business.status === "pending_review" ||
business.status === "draft";
const canPublish =
isPending || business.status === "rejected";
const isBusy = busyId === business.id;
const moreOpen = openMoreId === business.id;
const specialCount = specialCountByBusinessId.get(business.id) ?? 0;
function openShow() {
router.push(showHref);
}
return (
<li key={business.id}>
<article
className={styles.card}
role="link"
tabIndex={0}
aria-label={`Show ${displayName}`}
onClick={openShow}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openShow();
}
}}
>
<div className={styles.coverWrap}>
{business.bannerUrl ? (
<img
src={business.bannerUrl}
alt=""
className={styles.cover}
/>
) : (
<div className={styles.coverFallback} aria-hidden />
)}
<span
className={`${styles.status} ${statusClass(business.status)}`}
>
{STATUS_LABEL[business.status]}
</span>
</div>
<div className={`tile-hover ${styles.body}`}>
<div className={styles.info}>
<div className={styles.identity}>
{business.logoUrl ? (
<img
className={styles.logo}
src={business.logoUrl}
alt=""
height={52}
/>
) : (
<span className={styles.logoMark} aria-hidden>
{displayName.slice(0, 1).toUpperCase()}
</span>
)}
<div className={styles.copy}>
<h2 className={styles.name}>{displayName}</h2>
<p className={styles.tagline}>{category}</p>
</div>
</div>
</div>
<div className={styles.bottom}>
<div className={styles.foot}>
<span className={styles.slug}>{categoryTrail}</span>
{business.isFeatured ? (
<span className={styles.featured}>Featured</span>
) : null}
</div>
<div
className={styles.controls}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{canPublish && onApprove ? (
<button
type="button"
className={`${styles.control} ${styles.controlPrimary}`}
disabled={isBusy}
onClick={() => onApprove(business)}
>
{isBusy ? "…" : "Approve"}
</button>
) : null}
<button
type="button"
className={styles.control}
disabled={isBusy}
onClick={() =>
router.push(`/businesses/${business.id}/edit`)
}
>
Edit
</button>
<button
type="button"
className={styles.control}
disabled={isBusy || !business.meshkeeBusinessId}
onClick={() =>
openBusinessDashboardAsAdmin(
business.meshkeeBusinessId,
"/profile",
)
}
>
Manage
</button>
<button
type="button"
className={styles.control}
disabled={isBusy}
onClick={async () => {
try {
await navigator.clipboard.writeText(href);
} catch {
window.prompt("Copy link", href);
}
}}
>
Link
</button>
<div
className={`${styles.moreWrap} ${moreOpen ? styles.moreWrapOpen : ""}`}
ref={(node) => {
if (node) moreRefs.current.set(business.id, node);
else moreRefs.current.delete(business.id);
}}
>
<button
type="button"
className={`${styles.control} ${styles.moreTrigger}`}
aria-haspopup="menu"
aria-expanded={moreOpen}
disabled={isBusy}
onClick={() =>
setOpenMoreId((id) =>
id === business.id ? null : business.id,
)
}
>
More
<img
src="/images/icons/add.svg"
alt=""
width={10}
height={10}
/>
</button>
<div className={styles.moreMenu} role="menu">
<a
role="menuitem"
className={styles.moreItem}
href={href}
target="_blank"
rel="noreferrer"
onClick={() => setOpenMoreId(null)}
>
Website
</a>
<button
type="button"
role="menuitem"
className={styles.moreItem}
disabled={isBusy}
onClick={() => {
setOpenMoreId(null);
onMetaTag?.(business);
}}
>
Meta tag
</button>
{onMakeSpecial ? (
<button
type="button"
role="menuitem"
className={`${styles.moreItem} ${styles.moreItemSpecial}`}
disabled={isBusy}
onClick={() => {
setOpenMoreId(null);
onMakeSpecial(business);
}}
>
Make special
{specialCount > 0 ? (
<em
className={styles.specialChip}
aria-label={`In ${specialCount} special carousel${specialCount === 1 ? "" : "s"}`}
>
{specialCount}
</em>
) : null}
</button>
) : null}
{!isPending ? (
<button
type="button"
role="menuitem"
className={styles.moreItem}
disabled={isBusy || !onDisable}
onClick={() => {
setOpenMoreId(null);
onDisable?.(business);
}}
>
{isBusy ? "…" : isHidden ? "Enable" : "Disable"}
</button>
) : null}
<button
type="button"
role="menuitem"
className={`${styles.moreItem} ${styles.moreItemDanger}`}
disabled={isBusy || !onRemove}
onClick={() => {
setOpenMoreId(null);
onRemove?.(business);
}}
>
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</article>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,265 @@
.page {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.breadcrumb a {
color: inherit;
}
.breadcrumb a:hover,
.breadcrumb a:focus-visible {
color: var(--color-black);
}
.breadcrumbSep {
opacity: 0.45;
}
.breadcrumbCurrent {
color: var(--color-muted);
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
}
.lead {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.headActions {
display: flex;
align-items: center;
gap: 12px;
}
.count {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-soft);
white-space: nowrap;
}
.addBtn {
min-height: 38px;
padding: 0 16px;
font-size: 11px;
letter-spacing: 0.16em;
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.addBtn::before {
background: #fff;
}
.addBtn:hover,
.addBtn:focus-visible {
color: var(--color-black);
}
.ghostBtn {
min-height: 38px;
padding: 0 16px;
font-size: 11px;
letter-spacing: 0.16em;
}
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px var(--page-pad);
background: rgba(0, 0, 0, 0.28);
}
.modal {
width: min(100%, 520px);
max-height: min(90vh, 720px);
overflow: auto;
border-radius: 5px;
background: var(--color-white);
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
padding: 28px 28px 24px;
}
.modalHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.kicker {
margin: 0 0 8px;
font-size: 11px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.modalTitle {
margin: 0;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.closeBtn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 5px;
font-size: 22px;
line-height: 1;
color: var(--color-soft);
}
.closeBtn:hover,
.closeBtn:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-soft);
}
.field input,
.field textarea {
width: 100%;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font: inherit;
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--color-black);
}
.field input {
min-height: 38px;
padding: 0 12px;
}
.field textarea {
padding: 10px 12px;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: #bbb;
}
.clearParent {
align-self: flex-start;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.clearParent:hover,
.clearParent:focus-visible {
color: var(--color-black);
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
.error {
margin: 0;
padding: 14px 16px;
border-radius: 5px;
background: #fff5f5;
color: #b42318;
font-size: 13px;
}
.card {
padding: 8px 12px 12px;
border-radius: 5px;
background: #fff;
}
.empty {
margin: 0;
padding: 28px 8px;
text-align: center;
font-size: 13px;
color: var(--color-muted);
}
@media (max-width: 720px) {
.head {
flex-direction: column;
align-items: stretch;
padding: 22px 18px;
}
.headActions {
justify-content: space-between;
}
}
+431
View File
@@ -0,0 +1,431 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
import CategoryTreeView, {
type CategoryTreeNode,
} from "@/components/CategoryTreeView";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
categoryLabel,
createMeshkeeCategory,
deleteMeshkeeCategory,
searchMeshkeeCategories,
updateMeshkeeCategory,
type MeshkeeCategory,
} from "@/services/meshkee-categories";
import styles from "./BusinessCategoriesPage.module.css";
type Draft = {
id?: string;
parentId: string;
name: string;
nameFa: string;
description: string;
};
function emptyDraft(parentId = ""): Draft {
return {
parentId,
name: "",
nameFa: "",
description: "",
};
}
function toViewNodes(items: MeshkeeCategory[]): CategoryTreeNode[] {
const byId = new Map<string, CategoryTreeNode>();
for (const item of items) {
byId.set(item.id, {
id: item.id,
name: categoryLabel(item),
hint: item.slug ? `/${item.slug}` : undefined,
children: [],
});
}
const roots: CategoryTreeNode[] = [];
for (const item of items) {
const node = byId.get(item.id)!;
if (item.parentId && byId.has(item.parentId)) {
byId.get(item.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: CategoryTreeNode[], source: MeshkeeCategory[]) => {
const order = new Map(source.map((item) => [item.id, item.sortOrder]));
nodes.sort(
(a, b) =>
(order.get(a.id) ?? 0) - (order.get(b.id) ?? 0) ||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children, source);
};
sortNodes(roots, items);
return roots;
}
function collectDescendantIds(
rootId: string,
items: MeshkeeCategory[],
): Set<string> {
const childrenByParent = new Map<string, string[]>();
for (const item of items) {
if (!item.parentId) continue;
const list = childrenByParent.get(item.parentId) ?? [];
list.push(item.id);
childrenByParent.set(item.parentId, list);
}
const result = new Set<string>([rootId]);
const queue = [rootId];
while (queue.length) {
const current = queue.shift()!;
for (const childId of childrenByParent.get(current) ?? []) {
if (!result.has(childId)) {
result.add(childId);
queue.push(childId);
}
}
}
return result;
}
export default function BusinessCategoriesPage() {
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [removing, setRemoving] = useState<MeshkeeCategory | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const tree = useMemo(() => toViewNodes(categories), [categories]);
const byId = useMemo(() => {
const map = new Map<string, MeshkeeCategory>();
for (const item of categories) map.set(item.id, item);
return map;
}, [categories]);
const parentOptions = useMemo(() => {
if (!draft?.id) return categories;
const blocked = collectDescendantIds(draft.id, categories);
return categories.filter((item) => !blocked.has(item.id));
}, [categories, draft?.id]);
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
setCategories(await searchMeshkeeCategories(""));
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
function openCreate(parentId = "") {
setDraft(emptyDraft(parentId));
setError("");
}
function openEdit(id: string) {
const category = byId.get(id);
if (!category) return;
setDraft({
id: category.id,
parentId: category.parentId ?? "",
name: category.name,
nameFa: category.nameFa ?? "",
description: category.description ?? "",
});
setError("");
}
async function saveDraft() {
if (!draft) return;
if (!draft.name.trim()) {
setError("Enter an English name.");
return;
}
setSaving(true);
setError("");
try {
const body = {
parentId: draft.parentId || null,
name: draft.name.trim(),
nameFa: draft.nameFa.trim(),
description: draft.description.trim(),
};
if (draft.id) {
const updated = await updateMeshkeeCategory(draft.id, body);
setCategories((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
} else {
const created = await createMeshkeeCategory(body);
setCategories((current) => [...current, created]);
}
setDraft(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not save category.",
);
} finally {
setSaving(false);
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
setError("");
try {
const removed = await deleteMeshkeeCategory(removing.id);
const deleted = new Set(
(removed.deletedIds ?? [removing.id]).map(String),
);
deleted.add(removing.id);
setCategories((current) =>
current.filter((item) => !deleted.has(item.id)),
);
setRemoving(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not remove category.",
);
} finally {
setRemoveBusy(false);
}
}
const removingName = removing
? categoryLabel(removing)
: "this category";
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<Link href="/">Home</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<Link href="/businesses">Businesses</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Categories</span>
</nav>
<h1 className={styles.title}>Categories</h1>
<p className={styles.lead}>
Marketplace categories from Meshkee. Add, edit, or remove the tree.
</p>
</div>
<div className={styles.headActions}>
<p className={styles.count}>
{loading ? "Loading…" : `${categories.length} categories`}
</p>
<button
type="button"
className={`outline-btn ${styles.addBtn}`}
onClick={() => openCreate()}
>
Add category
</button>
</div>
</header>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<section className={styles.card}>
{loading ? <p className={styles.empty}>Loading categories…</p> : null}
{!loading ? (
<CategoryTreeView
nodes={tree}
actions={{
onAddChild: openCreate,
onEdit: openEdit,
onRemove: (id) => {
const category = byId.get(id);
if (category) setRemoving(category);
},
}}
/>
) : null}
</section>
{draft ? (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) {
setDraft(null);
}
}}
>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby="category-draft-title"
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Categories</p>
<h2 id="category-draft-title" className={styles.modalTitle}>
{draft.id ? "Edit category" : "Add category"}
</h2>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
disabled={saving}
onClick={() => setDraft(null)}
>
×
</button>
</div>
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault();
void saveDraft();
}}
>
<CategoryTreeSelect
categories={parentOptions}
value={draft.parentId ? [draft.parentId] : []}
selection="single"
label="Parent"
placeholder="Top level"
disabled={saving}
onChange={(ids) =>
setDraft((current) =>
current
? { ...current, parentId: ids[0] ?? "" }
: current,
)
}
/>
{draft.parentId ? (
<button
type="button"
className={styles.clearParent}
disabled={saving}
onClick={() =>
setDraft((current) =>
current ? { ...current, parentId: "" } : current,
)
}
>
Top level
</button>
) : null}
<label className={styles.field}>
<span>Name EN</span>
<input
value={draft.name}
disabled={saving}
required
autoFocus
onChange={(event) =>
setDraft((current) =>
current
? { ...current, name: event.target.value }
: current,
)
}
/>
</label>
<label className={styles.field}>
<span>Name FA</span>
<input
className="fa"
lang="fa"
dir="rtl"
value={draft.nameFa}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, nameFa: event.target.value }
: current,
)
}
/>
</label>
<label className={styles.field}>
<span>Description</span>
<textarea
rows={3}
value={draft.description}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, description: event.target.value }
: current,
)
}
/>
</label>
<div className={styles.modalActions}>
<button
type="button"
className={`outline-btn ${styles.ghostBtn}`}
disabled={saving}
onClick={() => setDraft(null)}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.addBtn}`}
disabled={saving}
>
{saving ? "Saving…" : draft.id ? "Update" : "Add"}
</button>
</div>
</form>
</div>
</div>
) : null}
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove category"
title={`Remove ${removingName}?`}
message="This category and its subcategories will be removed from the marketplace tree."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
+911
View File
@@ -0,0 +1,911 @@
.page {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
padding-bottom: 48px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px 32px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.breadcrumb a {
color: inherit;
transition: color 0.2s ease;
}
.breadcrumb a:hover,
.breadcrumb a:focus-visible {
color: var(--color-black);
}
.breadcrumbSep {
opacity: 0.45;
}
.breadcrumbCurrent {
color: var(--color-muted);
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
}
.lead {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.headActions {
display: flex;
align-items: center;
gap: 10px;
}
.actionBtn {
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
.hero {
position: relative;
margin-top: 24px;
border-radius: 5px;
overflow: hidden;
background: #e8e8e8;
line-height: 0;
}
.heroImg {
width: 100%;
height: auto;
display: block;
}
.heroFallback {
width: 100%;
aspect-ratio: 18 / 6;
background: linear-gradient(180deg, #ececec 0%, #dedede 100%);
}
.profile {
margin-top: -36px;
position: relative;
z-index: 2;
padding: 0 clamp(20px, 4vw, 48px);
}
.profileBar {
background: var(--color-white);
border-radius: 5px;
min-height: 110px;
padding: 16px 20px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.identity {
display: flex;
align-items: center;
gap: 16px;
min-width: 220px;
}
.logo {
width: auto;
height: 56px;
max-width: min(180px, 42%);
flex-shrink: 0;
border-radius: 4px;
background: transparent;
object-fit: contain;
object-position: left center;
}
.logoMark {
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid #f0f0f0;
background: #fff;
display: grid;
place-items: center;
font-size: 20px;
font-weight: 400;
color: var(--color-muted);
}
.name {
font-size: clamp(15px, 1.4vw, 22px);
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.tagline {
margin-top: 4px;
font-size: 13px;
font-weight: 400;
color: var(--color-soft);
}
.stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: clamp(16px, 2.5vw, 36px);
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.statWide {
display: flex;
align-items: center;
gap: 10px;
}
.statLabel {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #969696;
}
.statLabelStack {
font-size: 12px;
line-height: 1.15;
text-transform: uppercase;
color: #969696;
}
.statValue {
font-size: clamp(18px, 1.8vw, 26px);
font-weight: 400;
line-height: 1.15;
}
.statMonth {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.04em;
color: var(--color-soft);
line-height: 1.2;
}
.statValueLg {
font-size: clamp(28px, 2.6vw, 42px);
font-weight: 400;
line-height: 1;
}
.description {
margin-top: 20px;
max-width: 920px;
font-size: 14px;
line-height: 1.65;
font-weight: 400;
color: var(--color-muted);
white-space: pre-wrap;
}
.tabs {
margin-top: 32px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(16px, 2vw, 32px);
border-bottom: 1px solid #ececec;
padding: 0 8px;
}
.tab,
.tabActive {
position: relative;
padding: 0 4px 12px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.14em;
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: 2px;
background: var(--color-black);
}
.tab:hover {
color: var(--color-black);
}
.panel {
margin-top: 28px;
}
.productsSection {
scroll-margin-top: 32px;
}
.pills,
.childPills {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin-bottom: 16px;
}
.childPills {
margin-top: -6px;
margin-bottom: 20px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
}
.pill,
.pillActive {
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 5px 10px;
font-size: 10px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
background: #fff;
}
.pillActive {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.productGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.blogGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.card,
.blogCard {
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 5px;
overflow: hidden;
}
.cardBody {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
background: transparent;
border-radius: 0;
}
.cardFoot {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 0 0 4px 4px;
padding: 14px 16px 0;
}
.cardFootSolo .cardBody {
min-height: 28px;
}
/* Same bottom→top wipe when hovering the whole card */
.card:hover .cardFoot:global(.tile-hover)::before,
.card:focus-within .cardFoot:global(.tile-hover)::before,
.blogCard:hover .cardFoot:global(.tile-hover)::before,
.blogCard:focus-within .cardFoot:global(.tile-hover)::before,
.galleryItem:hover .galleryFoot:global(.tile-hover)::before,
.galleryItem:focus-within .galleryFoot:global(.tile-hover)::before {
clip-path: inset(0 0 0 0);
}
.card:hover .cardFoot:global(.tile-hover) :is(h3, p, span, button),
.card:focus-within .cardFoot:global(.tile-hover) :is(h3, p, span, button),
.blogCard:hover .cardFoot:global(.tile-hover) :is(h3, p, span, button),
.blogCard:focus-within .cardFoot:global(.tile-hover) :is(h3, p, span, button),
.galleryItem:hover .galleryFoot:global(.tile-hover) :is(button),
.galleryItem:focus-within .galleryFoot:global(.tile-hover) :is(button) {
color: var(--color-white);
}
.card:hover .cardControls,
.card:focus-within .cardControls,
.blogCard:hover .cardControls,
.blogCard:focus-within .cardControls,
.galleryItem:hover .cardControls,
.galleryItem:focus-within .cardControls {
border-top-color: rgba(255, 255, 255, 0.2);
}
.card:hover .cardControl + .cardControl,
.card:focus-within .cardControl + .cardControl,
.blogCard:hover .cardControl + .cardControl,
.blogCard:focus-within .cardControl + .cardControl,
.galleryItem:hover .cardControl + .cardControl,
.galleryItem:focus-within .cardControl + .cardControl {
border-left-color: rgba(255, 255, 255, 0.22);
}
.cardMedia {
position: relative;
aspect-ratio: 1;
background: #f3f3f3;
overflow: hidden;
}
.cardControls {
position: relative;
z-index: 2;
margin-top: auto;
padding: 14px 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
border-top: 1px solid #f0f0f0;
}
.cardControl {
appearance: none;
border: 0;
background: transparent;
padding: 0 10px;
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
cursor: pointer;
line-height: 1.4;
transition: color 0.2s ease;
}
.cardControl:first-child {
padding-left: 0;
}
.cardControl + .cardControl {
border-left: 1px solid #e4e4e4;
}
.cardControl:hover,
.cardControl:focus-visible {
color: var(--color-black);
}
.card:hover .cardControl:hover,
.card:focus-within .cardControl:hover,
.blogCard:hover .cardControl:hover,
.blogCard:focus-within .cardControl:hover,
.galleryItem:hover .cardControl:hover,
.galleryItem:focus-within .cardControl:hover {
color: var(--color-white);
}
.cardControlDanger:hover,
.cardControlDanger:focus-visible {
color: #b42318;
}
.card:hover .cardControlDanger:hover,
.card:focus-within .cardControlDanger:hover,
.blogCard:hover .cardControlDanger:hover,
.blogCard:focus-within .cardControlDanger:hover,
.galleryItem:hover .cardControlDanger:hover,
.galleryItem:focus-within .cardControlDanger:hover {
color: #ffb4a8;
}
.cardControl:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.galleryFoot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
padding: 0 16px;
background: rgba(255, 255, 255, 0.94);
}
.galleryFoot .cardControls {
margin-top: 0;
border-top: 0;
}
.cardMedia img,
.galleryItem img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.portfolioMedia img {
width: 100%;
height: auto;
display: block;
}
.mediaFallback {
width: 100%;
height: 100%;
background: #ececec;
}
.cardBody,
.blogBody {
position: relative;
z-index: 2;
padding: 0;
}
.cardBody h3 {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.04em;
line-height: 1.3;
}
.blogBody h3,
.portfolioOverlay h3 {
font-size: 14px;
font-weight: 400;
letter-spacing: 0.04em;
line-height: 1.35;
}
.cardBody p {
margin-top: 4px;
font-size: 11px;
font-weight: 400;
line-height: 1.4;
color: var(--color-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.blogBody p,
.portfolioOverlay p {
margin-top: 8px;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
color: var(--color-muted);
text-align: justify;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.panelHint {
margin: 0 0 14px;
text-align: center;
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.pagination {
margin-top: 22px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.pageNumbers {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
justify-content: center;
}
.pageBtn,
.pageNum,
.pageNumActive {
min-height: 34px;
min-width: 34px;
padding: 0 12px;
border: 1px solid #e8e8e8;
border-radius: 4px;
background: #fff;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
}
.pageNum,
.pageNumActive {
letter-spacing: 0;
text-transform: none;
padding: 0 8px;
}
.pageNumActive {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
cursor: default;
}
.pageBtn:hover:not(:disabled),
.pageBtn:focus-visible:not(:disabled),
.pageNum:hover:not(:disabled),
.pageNum:focus-visible:not(:disabled) {
border-color: var(--color-black);
color: var(--color-black);
}
.pageBtn:disabled,
.pageNum:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pageNumActive:disabled {
opacity: 1;
}
.pageEllipsis {
min-width: 24px;
text-align: center;
font-size: 12px;
color: var(--color-soft);
}
.meta {
display: inline-block;
margin-top: 10px;
margin-bottom: 14px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-soft);
}
.contact {
display: flex;
flex-direction: column;
gap: 22px;
}
.contactRows {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.contactRow {
border: 1px solid #f0f0f0;
border-radius: 5px;
padding: 16px 18px;
background: #fff;
}
.contactLabel {
display: block;
margin-bottom: 8px;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
}
.contactRow p {
font-size: 14px;
font-weight: 400;
line-height: 1.5;
color: var(--color-muted);
}
.socials {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.socialChip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #ececec;
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
color: var(--color-muted);
background: #fff;
}
.socialChip em {
font-style: normal;
text-transform: capitalize;
color: var(--color-black);
}
.gallery {
columns: 3;
column-gap: 14px;
}
.galleryItem {
position: relative;
break-inside: avoid;
margin: 0 0 14px;
border-radius: 5px;
overflow: hidden;
background: #f3f3f3;
}
.galleryTall img {
min-height: 220px;
}
.portfolioGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.portfolioCard {
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 5px;
overflow: hidden;
}
.portfolioCard .portfolioMedia {
border-radius: 0;
}
.portfolioCard .cardFoot {
flex: 0 0 auto;
padding: 0 12px;
}
.portfolioCard .cardControls {
padding: 12px 0 14px;
}
.portfolioMeta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
padding: 12px 12px 10px;
min-width: 0;
}
.portfolioTitleEn {
margin: 0;
max-width: 100%;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
line-height: 1.25;
color: var(--color-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portfolioTitleFa {
margin: 0;
max-width: 100%;
font-size: 11px;
font-weight: 400;
line-height: 1.35;
color: var(--color-soft);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portfolioCard:hover .cardFoot:global(.tile-hover)::before,
.portfolioCard:focus-within .cardFoot:global(.tile-hover)::before {
clip-path: inset(0 0 0 0);
}
.portfolioCard:hover .cardFoot:global(.tile-hover) :is(h3, p, span, button),
.portfolioCard:focus-within .cardFoot:global(.tile-hover) :is(h3, p, span, button) {
color: var(--color-white);
}
.portfolioCard:hover .cardControls,
.portfolioCard:focus-within .cardControls {
border-top-color: rgba(255, 255, 255, 0.2);
}
.portfolioCard:hover .cardControl + .cardControl,
.portfolioCard:focus-within .cardControl + .cardControl {
border-left-color: rgba(255, 255, 255, 0.22);
}
.portfolioCard:hover .cardControl:hover,
.portfolioCard:focus-within .cardControl:hover {
color: var(--color-white);
}
.portfolioCard:hover .cardControlDanger:hover,
.portfolioCard:focus-within .cardControlDanger:hover {
color: #ffb4a8;
}
.portfolioMedia {
position: relative;
width: 100%;
border-radius: 0;
overflow: hidden;
background: #f3f3f3;
line-height: 0;
}
.portfolioMedia .mediaFallback {
aspect-ratio: 4 / 3;
}
.emptyTabs,
.loading,
.error {
margin-top: 28px;
padding: 36px 28px;
border-radius: 5px;
background: var(--color-white);
border: 1px solid #f0f0f0;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
text-align: center;
}
.errorTitle {
font-size: 13px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-black);
}
.errorCopy {
margin-top: 10px;
}
.backLink {
display: inline-block;
margin-top: 16px;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-black);
}
@media (max-width: 980px) {
.productGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.blogGrid,
.portfolioGrid,
.contactRows {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gallery {
columns: 2;
}
}
@media (max-width: 640px) {
.head {
flex-direction: column;
align-items: flex-start;
padding: 22px 18px;
}
.productGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.blogGrid,
.portfolioGrid,
.contactRows {
grid-template-columns: 1fr;
}
.gallery {
columns: 1;
}
.profile {
padding: 0 14px;
}
}
+437
View File
@@ -0,0 +1,437 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import BlogPanel from "@/components/catalog/BlogPanel";
import PortfoliosPanel from "@/components/catalog/PortfoliosPanel";
import ProductsPanel from "@/components/catalog/ProductsPanel";
import { toPlainText } from "@/components/catalog/utils";
import { openBusinessDashboardAsAdmin } from "@/lib/session-handoff";
import { getMarketplaceBusiness } from "@/services/marketplace";
import {
getBusinessProfile,
getMeshkeeBusiness,
type BusinessAddress,
type BusinessPhoneNumber,
} from "@/services/meshkee-businesses";
import {
listMeshkeeBlogs,
listMeshkeePortfolios,
listMeshkeeProducts,
} from "@/services/meshkee-content";
import styles from "./BusinessDetailsPage.module.css";
type TabId = "products" | "contact" | "blog" | "portfolios";
const TAB_LABELS: Record<TabId, string> = {
products: "Products",
contact: "Contact info",
blog: "Blog",
portfolios: "Portfolios",
};
const SOCIAL_ICONS: Record<string, string> = {
instagram: "/images/icons/instagram.png",
telegram: "/images/icons/telegram.png",
facebook: "/images/icons/facebook.png",
whatsapp: "/images/icons/telegram.png",
linkedin: "/images/icons/facebook.png",
youtube: "/images/icons/facebook.png",
aparat: "/images/icons/facebook.png",
};
function hasContactData(input: {
addresses: BusinessAddress[];
phones: BusinessPhoneNumber[];
emails: string[];
socials: { network: string; handle: string }[];
}) {
return (
input.addresses.some(
(item) =>
item.address.trim() ||
item.city.trim() ||
item.province.trim() ||
(item.landline ?? "").trim(),
) ||
input.phones.some((item) => item.number.trim()) ||
input.emails.some((item) => item.trim()) ||
input.socials.length > 0
);
}
export default function BusinessDetailsPage({
listingId,
}: {
listingId: string;
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [meshkeeId, setMeshkeeId] = useState("");
const [tab, setTab] = useState<TabId>("products");
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [categoryName, setCategoryName] = useState("");
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
const [about, setAbout] = useState("");
const [vision, setVision] = useState("");
const [sinceMonth, setSinceMonth] = useState("");
const [sinceYear, setSinceYear] = useState("");
const [province, setProvince] = useState("");
const [telephone, setTelephone] = useState("");
const [productTotal, setProductTotal] = useState(0);
const [blogTotal, setBlogTotal] = useState(0);
const [portfolioTotal, setPortfolioTotal] = useState(0);
const [addresses, setAddresses] = useState<BusinessAddress[]>([]);
const [phones, setPhones] = useState<BusinessPhoneNumber[]>([]);
const [emails, setEmails] = useState<string[]>([]);
const [socials, setSocials] = useState<
{ network: string; handle: string; icon: string }[]
>([]);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
try {
const listing = await getMarketplaceBusiness(listingId);
const id = String(listing.meshkeeBusinessId);
const [
meshkee,
profile,
productsRes,
blogsRes,
portfoliosRes,
] = await Promise.all([
getMeshkeeBusiness(id),
getBusinessProfile(id),
listMeshkeeProducts(id, { page: 1, pageSize: 1 }).catch(() => ({
total: 0,
})),
listMeshkeeBlogs(id, { page: 1, pageSize: 1 }).catch(() => ({
total: 0,
})),
listMeshkeePortfolios(id, { page: 1, pageSize: 1 }).catch(() => ({
total: 0,
})),
]);
if (cancelled) return;
const nextAddresses = profile.addresses ?? [];
const nextPhones = profile.profile.phoneNumbers ?? [];
const nextEmails = (profile.profile.emails ?? []).filter(Boolean);
const socialMedia = profile.profile.socialMedia ?? {};
const nextSocials = Object.entries(socialMedia)
.filter(([, handle]) => Boolean(handle?.trim()))
.map(([network, handle]) => ({
network,
handle: handle.trim(),
icon: SOCIAL_ICONS[network] ?? "/images/icons/telegram.png",
}));
setMeshkeeId(id);
setName(meshkee.name || listing.slug);
setSlug(listing.slug);
setCategoryName(meshkee.categories[0]?.name ?? "");
setLogoUrl(profile.profile.logoUrl ?? meshkee.logoUrl);
setBannerUrl(profile.profile.bannerUrl ?? meshkee.bannerUrl);
setAbout(profile.profile.about ?? "");
setVision(profile.profile.vision ?? "");
if (meshkee.createdAt) {
const joined = new Date(meshkee.createdAt);
setSinceMonth(
joined.toLocaleDateString("en-US", { month: "long" }),
);
setSinceYear(String(joined.getFullYear()));
} else {
setSinceMonth("");
setSinceYear("");
}
setProvince(nextAddresses[0]?.province ?? "");
setTelephone(
nextPhones.find((item) => item.number.trim())?.number ??
nextAddresses.find((item) => (item.landline ?? "").trim())
?.landline ??
"",
);
setProductTotal(productsRes.total ?? 0);
setBlogTotal(blogsRes.total ?? 0);
setPortfolioTotal(portfoliosRes.total ?? 0);
setAddresses(nextAddresses);
setPhones(nextPhones as BusinessPhoneNumber[]);
setEmails(nextEmails);
setSocials(nextSocials);
} catch (err) {
if (cancelled) return;
setError(
err instanceof Error
? err.message
: "Could not load business details.",
);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [listingId]);
const contactReady = hasContactData({
addresses,
phones,
emails,
socials,
});
const availableTabs = useMemo(() => {
const tabs: TabId[] = [];
if (productTotal > 0) tabs.push("products");
if (contactReady) tabs.push("contact");
if (blogTotal > 0) tabs.push("blog");
// Gallery disabled until a dedicated gallery section exists (media library ≠ gallery).
if (portfolioTotal > 0) tabs.push("portfolios");
return tabs;
}, [productTotal, contactReady, blogTotal, portfolioTotal]);
useEffect(() => {
if (!availableTabs.length) return;
if (!availableTabs.includes(tab)) setTab(availableTabs[0]);
}, [availableTabs, tab]);
const description = toPlainText(about) || toPlainText(vision);
function openBusinessDashboard(nextPath: string) {
if (!meshkeeId) return;
openBusinessDashboardAsAdmin(meshkeeId, nextPath);
}
if (loading) {
return <div className={styles.loading}>Loading business details…</div>;
}
if (error) {
return (
<div className={styles.error} role="alert">
<p className={styles.errorTitle}>Could not load details</p>
<p className={styles.errorCopy}>{error}</p>
<Link href="/businesses" className={styles.backLink}>
Back to businesses
</Link>
</div>
);
}
return (
<div className={styles.page}>
<header className={styles.head}>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<Link href="/">Home</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<Link href="/businesses">Businesses</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>{name}</span>
</nav>
<div className={styles.headActions}>
<button
type="button"
className={`outline-btn ${styles.actionBtn}`}
disabled={!meshkeeId}
onClick={() => openBusinessDashboard("/profile")}
>
Manage business
</button>
<Link
href={`/businesses/${listingId}/edit`}
className={`outline-btn ${styles.actionBtn}`}
>
Edit listing
</Link>
</div>
</header>
<section className={styles.hero}>
{bannerUrl ? (
<img src={bannerUrl} alt="" className={styles.heroImg} />
) : (
<div className={styles.heroFallback} aria-hidden />
)}
</section>
<section className={styles.profile}>
<div className={styles.profileBar}>
<div className={styles.identity}>
{logoUrl ? (
<img className={styles.logo} src={logoUrl} alt="" height={56} />
) : (
<span className={styles.logoMark} aria-hidden>
{name.slice(0, 1).toUpperCase()}
</span>
)}
<div>
<h2 className={styles.name}>{name}</h2>
<p className={styles.tagline}>{categoryName || `/${slug}`}</p>
</div>
</div>
<div className={styles.stats}>
{sinceMonth && sinceYear ? (
<div className={styles.stat}>
<span className={styles.statLabel}>since</span>
<strong className={styles.statValue}>{sinceYear}</strong>
<span className={styles.statMonth}>{sinceMonth}</span>
</div>
) : null}
{province ? (
<div className={styles.stat}>
<span className={styles.statLabel}>province</span>
<strong className={styles.statValue}>{province}</strong>
</div>
) : null}
<div className={styles.statWide}>
<strong className={styles.statValueLg}>{portfolioTotal}</strong>
<span className={styles.statLabelStack}>
active
<br />
{portfolioTotal === 1 ? "portfolio" : "portfolios"}
</span>
</div>
{telephone ? (
<div className={styles.stat}>
<span className={styles.statLabel}>Telephone</span>
<strong className={styles.statValue}>{telephone}</strong>
</div>
) : null}
</div>
</div>
{description ? (
<p className={styles.description} dir="auto">
{description}
</p>
) : null}
</section>
{availableTabs.length && meshkeeId ? (
<>
<nav className={styles.tabs} aria-label="Business sections">
{availableTabs.map((id) => (
<button
key={id}
type="button"
className={tab === id ? styles.tabActive : styles.tab}
onClick={() => setTab(id)}
>
{TAB_LABELS[id]}
</button>
))}
</nav>
<section className={styles.panel} aria-live="polite">
{tab === "products" ? (
<ProductsPanel
meshkeeId={meshkeeId}
controlsMode="manage"
onManage={() => openBusinessDashboard("/products")}
/>
) : null}
{tab === "contact" ? (
<div className={styles.contact}>
<div className={styles.contactRows}>
{addresses.map((address, index) => (
<div
key={address.id ?? `${address.city}-${index}`}
className={styles.contactRow}
>
<span className={styles.contactLabel}>
{address.name || "Address"}
</span>
<p dir="auto">
{[address.address, address.city, address.province]
.filter(Boolean)
.join(", ")}
</p>
{address.postalCode ? (
<p>Postal: {address.postalCode}</p>
) : null}
{address.landline ? (
<p>Landline: {address.landline}</p>
) : null}
</div>
))}
{phones.length ? (
<div className={styles.contactRow}>
<span className={styles.contactLabel}>Phones</span>
<p>
{phones
.filter((item) => item.number.trim())
.map((item) => item.number)
.join(" · ")}
</p>
</div>
) : null}
{emails.length ? (
<div className={styles.contactRow}>
<span className={styles.contactLabel}>Email</span>
<p>{emails.join(" · ")}</p>
</div>
) : null}
</div>
{socials.length ? (
<div className={styles.socials}>
{socials.map((social) => (
<span key={social.network} className={styles.socialChip}>
<img src={social.icon} alt="" width={16} height={16} />
<em>{social.network}</em> {social.handle}
</span>
))}
</div>
) : null}
<button
type="button"
className={`outline-btn ${styles.actionBtn}`}
style={{ marginTop: 18 }}
onClick={() => openBusinessDashboard("/profile")}
>
Manage business
</button>
</div>
) : null}
{tab === "blog" ? (
<BlogPanel
meshkeeId={meshkeeId}
controlsMode="manage"
onManage={() => openBusinessDashboard("/blog")}
/>
) : null}
{tab === "portfolios" ? (
<PortfoliosPanel
meshkeeId={meshkeeId}
controlsMode="manage"
onManage={() => openBusinessDashboard("/portfolios")}
/>
) : null}
</section>
</>
) : (
<div className={styles.emptyTabs}>
No catalog tabs yet — products, blog, portfolios, or contact data will
appear here when Meshkee has them.
</div>
)}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import AccountMenu from "@/components/AccountMenu";
import { useAuth } from "@/components/AuthProvider";
import { businessNavItems } from "@/data/business-nav";
import { en } from "@/lib/i18n/en";
import {
getAdminDashboardOrigin,
getCustomerDashboardOrigin,
} from "@/lib/host";
import { customerDashboardHref } from "@/lib/session-handoff";
import { getDisplayName } from "@/lib/userName";
import { getBusinessProfile } from "@/services/meshkee-businesses";
import styles from "./DashboardShell.module.css";
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
export default function BusinessShell({
children,
manageMeshkeeId,
}: {
children: React.ReactNode;
/** When set, nav stays on admin host under /manage/[id]/… */
manageMeshkeeId?: string;
}) {
const pathname = usePathname();
const { user, logout, isSuperAdmin, activeBusinessId } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const [businessName, setBusinessName] = useState("");
const [businessTitle, setBusinessTitle] = useState("");
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const displayName = getDisplayName(user);
const linkedBusiness =
user?.businesses.find((item) => item.isOwner) ?? user?.businesses[0];
const meshkeeId = manageMeshkeeId || activeBusinessId || null;
const manageBase = manageMeshkeeId
? `/manage/${encodeURIComponent(manageMeshkeeId)}`
: null;
const publicPath = manageBase
? pathname.replace(manageBase, "") || "/profile"
: pathname.startsWith("/business")
? pathname.replace(/^\/business/, "") || "/"
: pathname;
useEffect(() => {
if (!meshkeeId) {
setBusinessName(linkedBusiness?.name?.trim() || "");
setBusinessTitle("");
setLogoUrl(null);
return;
}
let cancelled = false;
void (async () => {
try {
const profile = await getBusinessProfile(meshkeeId);
if (cancelled) return;
const name =
profile.profile.nameEn?.trim() ||
profile.profile.nameFa?.trim() ||
linkedBusiness?.name?.trim() ||
"";
setBusinessName(name);
setBusinessTitle(profile.profile.title?.trim() || "");
setLogoUrl(profile.profile.logoUrl?.trim() || null);
} catch {
if (cancelled) return;
setBusinessName(linkedBusiness?.name?.trim() || "");
setBusinessTitle("");
setLogoUrl(null);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId, linkedBusiness?.name]);
function navHref(itemHref: string) {
if (!manageBase) return itemHref;
return `${manageBase}${itemHref === "/" ? "/profile" : itemHref}`;
}
const brandName = businessName || linkedBusiness?.name || "Business";
return (
<div className={styles.shell}>
<aside className={`${styles.sidebar} ${menuOpen ? styles.sidebarOpen : ""}`}>
<div className={styles.brand}>
{logoUrl ? (
<img
className={styles.brandLogo}
src={logoUrl}
alt=""
width={48}
height={48}
/>
) : null}
<div className={styles.brandText}>
<strong className={styles.brandBusiness} dir="auto" title={brandName}>
{brandName}
</strong>
{businessTitle ? (
<span
className={styles.brandTitle}
dir="auto"
title={businessTitle}
>
{businessTitle}
</span>
) : null}
</div>
</div>
<nav className={styles.nav} aria-label="Business">
{businessNavItems.map((item) => {
const href = navHref(item.href);
const active =
publicPath === item.href ||
publicPath.startsWith(`${item.href}/`);
return (
<Link
key={item.label}
href={href}
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
onClick={() => setMenuOpen(false)}
>
<img src={item.icon} alt="" width={18} height={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className={styles.sidebarFoot}>
<p className={styles.userLabel}>{displayName}</p>
{isSuperAdmin || manageMeshkeeId ? (
<Link href="/businesses" className={styles.siteLink}>
Admin dashboard
</Link>
) : (
<a href={customerDashboardHref("/")} className={styles.siteLink}>
{en.openCustomerDashboard}
</a>
)}
<a href={websiteUrl} className={styles.siteLink}>
{en.viewWebsite}
</a>
<button
type="button"
className={`outline-btn ${styles.logoutBtn}`}
onClick={() => {
logout();
window.location.href =
isSuperAdmin || manageMeshkeeId
? `${getAdminDashboardOrigin()}/login`
: `${getCustomerDashboardOrigin()}/login`;
}}
>
{en.logOut}
</button>
</div>
</aside>
{menuOpen ? (
<button
type="button"
className={styles.backdrop}
aria-label="Close menu"
onClick={() => setMenuOpen(false)}
/>
) : null}
<div className={styles.main}>
<header className={styles.topbar}>
<button
type="button"
className={styles.menuBtn}
aria-label="Open menu"
onClick={() => setMenuOpen(true)}
>
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
</button>
<div className={styles.topbarCopy}>
<p className={styles.eyebrow}>
{manageMeshkeeId ? "Admin · managing business" : en.workspaceEyebrow}
</p>
<p className={styles.topbarHint}>{en.workspaceHint}</p>
</div>
<div className={styles.topbarActions}>
<AccountMenu />
</div>
</header>
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
+407
View File
@@ -0,0 +1,407 @@
.page {
display: flex;
flex-direction: column;
gap: 28px;
width: 100%;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.breadcrumb a {
color: inherit;
transition: color 0.2s ease;
}
.breadcrumb a:hover,
.breadcrumb a:focus-visible {
color: var(--color-black);
}
.breadcrumbSep {
opacity: 0.45;
}
.breadcrumbCurrent {
color: var(--color-muted);
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
}
.lead {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.count {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-soft);
white-space: nowrap;
}
.headActions {
display: flex;
align-items: center;
gap: 16px;
}
.filterBar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.activeFilters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.filterOpenBtn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 14px;
border-radius: 5px;
background: var(--color-white);
border: 1px solid #ececec;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-black);
transition:
color 0.2s ease,
border-color 0.2s ease,
background 0.2s ease;
}
.filterOpenBtn:hover,
.filterOpenBtn:focus-visible {
border-color: #d8d8d8;
background: #fafafa;
}
.filterOpenBtnActive {
border-color: var(--color-black);
}
.filterCount {
font-style: normal;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
display: inline-grid;
place-items: center;
background: var(--color-black);
color: var(--color-white);
font-size: 10px;
letter-spacing: 0.04em;
}
.activeChip {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 12px 0 14px;
border-radius: 5px;
background: var(--color-black);
border: 1px solid var(--color-black);
font-size: 11px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-white);
max-width: min(100%, 360px);
}
.activeChip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.activeChip em {
font-style: normal;
font-size: 16px;
line-height: 1;
color: rgba(255, 255, 255, 0.72);
}
.activeChip:hover,
.activeChip:focus-visible {
background: #222;
border-color: #222;
}
.activeChip:hover em,
.activeChip:focus-visible em {
color: var(--color-white);
}
.filterDivider {
width: 1px;
height: 22px;
flex-shrink: 0;
background: #d8d8d8;
margin-inline: 4px;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px var(--page-pad);
background: rgba(0, 0, 0, 0.28);
}
.modal {
width: min(100%, 520px);
max-height: min(90vh, 720px);
overflow: auto;
border-radius: 5px;
background: var(--color-white);
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
padding: 28px 28px 24px;
}
.modalHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.kicker {
margin: 0 0 8px;
font-size: 11px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.modalTitle {
margin: 0;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.closeBtn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 5px;
font-size: 22px;
line-height: 1;
color: var(--color-soft);
}
.closeBtn:hover,
.closeBtn:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.modalForm {
display: flex;
flex-direction: column;
gap: 18px;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.ghostBtn {
min-height: 38px;
padding: 0 16px;
font-size: 11px;
letter-spacing: 0.16em;
}
.applyBtn {
min-height: 38px;
padding: 0 16px;
font-size: 11px;
letter-spacing: 0.16em;
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.applyBtn:hover,
.applyBtn:focus-visible {
background: #222;
border-color: #222;
}
.filter {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 14px;
border-radius: 5px;
background: var(--color-white);
border: 1px solid #ececec;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-soft);
transition:
color 0.2s ease,
border-color 0.2s ease,
background 0.2s ease;
}
.filter em {
font-style: normal;
color: var(--color-muted);
}
.filter:hover,
.filter:focus-visible {
color: var(--color-black);
border-color: #d8d8d8;
}
.filterActive {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.filterActive em {
color: rgba(255, 255, 255, 0.72);
}
.addBtn {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 5px;
background: var(--color-white);
transition: background 0.2s ease, transform 0.2s ease;
}
.addBtn:hover,
.addBtn:focus-visible {
background: var(--color-black);
}
.addBtn:hover img,
.addBtn:focus-visible img {
filter: invert(1);
}
.addBtn img {
width: 16px;
height: 16px;
object-fit: contain;
}
.loading,
.error {
min-height: 220px;
border-radius: 5px;
background: var(--color-white);
display: grid;
place-items: center;
padding: 40px 24px;
text-align: center;
}
.loading {
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-soft);
}
.errorTitle {
font-size: 13px;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.errorCopy {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.errorHint {
margin-top: 8px;
font-size: 12px;
font-weight: 400;
color: var(--color-body);
}
@media (max-width: 720px) {
.head {
flex-direction: column;
align-items: flex-start;
padding: 24px 20px;
}
.headActions {
width: 100%;
justify-content: space-between;
}
}
+551
View File
@@ -0,0 +1,551 @@
"use client";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AddBusinessModal from "@/components/AddBusinessModal";
import BusinessCardList from "@/components/BusinessCardList";
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
import ConfirmDialog from "@/components/ConfirmDialog";
import MakeSpecialModal from "@/components/MakeSpecialModal";
import {
deleteMarketplaceBusiness,
listBusinesses,
updateMarketplaceBusiness,
} from "@/services/marketplace";
import {
getBusinessProfile,
getMeshkeeBusiness,
} from "@/services/meshkee-businesses";
import {
categoryLabel,
searchMeshkeeCategories,
type MeshkeeCategory,
} from "@/services/meshkee-categories";
import type { BusinessStatus, MarketplaceBusiness } from "@/types/business";
import styles from "./BusinessesPage.module.css";
type FilterKey = "all" | "pending_review" | "published" | "rejected";
const FILTERS: { key: FilterKey; label: string }[] = [
{ key: "all", label: "All" },
{ key: "pending_review", label: "Pending" },
{ key: "published", label: "Published" },
{ key: "rejected", label: "Rejected" },
];
function normalizeStatusParam(value: string | null): FilterKey {
if (!value) return "all";
if (value === "draft" || value === "pending_review") return "pending_review";
if (value === "published" || value === "rejected" || value === "all") {
return value;
}
return "all";
}
function isPendingStatus(status: BusinessStatus) {
return status === "pending_review" || status === "draft";
}
function categoryAncestors(
categoryId: string | undefined,
byId: Map<string, MeshkeeCategory>,
): string[] {
if (!categoryId) return [];
const names: string[] = [];
const seen = new Set<string>();
let current = byId.get(categoryId);
while (current && !seen.has(current.id)) {
seen.add(current.id);
names.unshift(current.name);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return names;
}
function lastTwoCategoryLevels(trail: string[]) {
if (trail.length === 0) return undefined;
return trail.slice(-2).join(" / ");
}
function businessMatchesCategory(
categoryId: string | undefined,
selectedId: string,
byId: Map<string, MeshkeeCategory>,
) {
if (!categoryId) return false;
const seen = new Set<string>();
let current = byId.get(categoryId);
while (current && !seen.has(current.id)) {
if (current.id === selectedId) return true;
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return false;
}
async function enrichListings(
items: MarketplaceBusiness[],
categories: MeshkeeCategory[],
): Promise<MarketplaceBusiness[]> {
const byId = new Map(categories.map((category) => [category.id, category]));
return Promise.all(
items.map(async (item) => {
try {
const id = String(item.meshkeeBusinessId);
const [meshkee, profile] = await Promise.all([
getMeshkeeBusiness(id),
getBusinessProfile(id),
]);
const primaryCategoryId =
profile.profile.categoryIds[0] ?? meshkee.categories[0]?.id;
const trail = categoryAncestors(primaryCategoryId, byId);
return {
...item,
name: meshkee.name,
categoryId: primaryCategoryId,
categoryName: trail[trail.length - 1] ?? meshkee.categories[0]?.name,
categoryTrail: lastTwoCategoryLevels(trail),
logoUrl: profile.profile.logoUrl ?? meshkee.logoUrl ?? undefined,
bannerUrl:
profile.profile.bannerUrl ?? meshkee.bannerUrl ?? undefined,
};
} catch {
return { ...item, name: item.slug };
}
}),
);
}
export default function BusinessesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const filterTitleId = useId();
const initialFilter = normalizeStatusParam(searchParams.get("status"));
const [filter, setFilter] = useState<FilterKey>(initialFilter);
const [items, setItems] = useState<MarketplaceBusiness[]>([]);
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
const [total, setTotal] = useState(0);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [filterModalOpen, setFilterModalOpen] = useState(false);
const [draftCategoryIds, setDraftCategoryIds] = useState<string[]>([]);
const [categoryFilterId, setCategoryFilterId] = useState<string | null>(null);
const [removing, setRemoving] = useState<MarketplaceBusiness | null>(null);
const [makingSpecial, setMakingSpecial] =
useState<MarketplaceBusiness | null>(null);
const [specialTick, setSpecialTick] = useState(0);
const [busyId, setBusyId] = useState<string | null>(null);
const categoriesById = useMemo(
() => new Map(categories.map((category) => [category.id, category])),
[categories],
);
const activeCategory = categoryFilterId
? categoriesById.get(categoryFilterId)
: undefined;
const activeFilterCount = categoryFilterId ? 1 : 0;
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
const [data, categoryItems] = await Promise.all([
listBusinesses({ status: "all", limit: 100 }),
searchMeshkeeCategories("").catch(() => [] as MeshkeeCategory[]),
]);
setCategories(categoryItems);
const enriched = await enrichListings(data.items, categoryItems);
setItems(enriched);
setTotal(data.total);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not load businesses from the API.",
);
setItems([]);
setTotal(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
useEffect(() => {
setFilter(normalizeStatusParam(searchParams.get("status")));
}, [searchParams]);
useEffect(() => {
if (!filterModalOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setFilterModalOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [filterModalOpen]);
const counts = useMemo(() => {
const next: Record<FilterKey, number> = {
all: 0,
pending_review: 0,
published: 0,
rejected: 0,
};
for (const item of items) {
if (
categoryFilterId &&
!businessMatchesCategory(
item.categoryId,
categoryFilterId,
categoriesById,
)
) {
continue;
}
next.all += 1;
if (isPendingStatus(item.status)) next.pending_review += 1;
else if (item.status === "published") next.published += 1;
else if (item.status === "rejected") next.rejected += 1;
}
return next;
}, [items, categoryFilterId, categoriesById]);
const visible = useMemo(() => {
return items.filter((item) => {
if (
categoryFilterId &&
!businessMatchesCategory(
item.categoryId,
categoryFilterId,
categoriesById,
)
) {
return false;
}
if (filter === "all") return true;
if (filter === "pending_review") return isPendingStatus(item.status);
return item.status === filter;
});
}, [items, filter, categoryFilterId, categoriesById]);
function selectFilter(next: FilterKey) {
setFilter(next);
const url = new URL(window.location.href);
if (next === "all") url.searchParams.delete("status");
else url.searchParams.set("status", next);
router.replace(`${url.pathname}${url.search}`);
}
function openFilterModal() {
setDraftCategoryIds(categoryFilterId ? [categoryFilterId] : []);
setFilterModalOpen(true);
}
function applyCategoryFilter() {
setCategoryFilterId(draftCategoryIds[0] ?? null);
setFilterModalOpen(false);
}
function clearCategoryFilter() {
setCategoryFilterId(null);
setDraftCategoryIds([]);
}
async function setStatus(business: MarketplaceBusiness, status: BusinessStatus) {
setBusyId(business.id);
setError("");
try {
const updated = await updateMarketplaceBusiness(business.id, { status });
setItems((current) =>
current.map((item) =>
item.id === business.id
? {
...item,
status: updated.status,
isFeatured: updated.isFeatured ?? item.isFeatured,
sortWeight: updated.sortWeight ?? item.sortWeight,
slug: updated.slug || item.slug,
}
: item,
),
);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not update listing status.",
);
} finally {
setBusyId(null);
}
}
const removingBusy = Boolean(removing && busyId === removing.id);
const removingName = removing?.name || removing?.slug || "this business";
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<a href="/">Home</a>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Businesses</span>
</nav>
<h1 className={styles.title}>Businesses</h1>
<p className={styles.lead}>
Review submissions, publish listings, and manage the marketplace
gate.
</p>
</div>
<div className={styles.headActions}>
<p className={styles.count}>
{loading
? "Loading…"
: filter === "all" && !categoryFilterId
? `${total} total`
: `${visible.length} shown · ${total} total`}
</p>
<button
type="button"
className={`${styles.filterOpenBtn}${categoryFilterId ? ` ${styles.filterOpenBtnActive}` : ""}`}
onClick={openFilterModal}
aria-label={
categoryFilterId
? `Filter, ${activeFilterCount} active`
: "Filter"
}
>
<span>Filter</span>
{activeFilterCount > 0 ? (
<em className={styles.filterCount}>{activeFilterCount}</em>
) : null}
</button>
<button
type="button"
className={styles.addBtn}
aria-label="Add business"
onClick={() => setModalOpen(true)}
>
<img src="/images/icons/add.svg" alt="" width={18} height={18} />
</button>
</div>
</header>
<div className={styles.filterBar}>
{activeCategory ? (
<>
<div className={styles.activeFilters}>
<button
type="button"
className={styles.activeChip}
onClick={clearCategoryFilter}
aria-label={`Clear category filter ${categoryLabel(activeCategory)}`}
>
<span>{categoryLabel(activeCategory)}</span>
<em aria-hidden>×</em>
</button>
</div>
<div className={styles.filterDivider} aria-hidden />
</>
) : null}
<div className={styles.filters} role="tablist" aria-label="Listing status">
{FILTERS.map((item) => {
const active = filter === item.key;
const count = counts[item.key];
return (
<button
key={item.key}
type="button"
role="tab"
aria-selected={active}
className={`${styles.filter} ${active ? styles.filterActive : ""}`}
onClick={() => selectFilter(item.key)}
>
<span>{item.label}</span>
<em>{loading ? "—" : count}</em>
</button>
);
})}
</div>
</div>
{error ? (
<div className={styles.error} role="alert">
<p className={styles.errorTitle}>API unavailable</p>
<p className={styles.errorCopy}>{error}</p>
<p className={styles.errorHint}>
Start the marketplace backend on port 3001, then refresh.
</p>
</div>
) : loading ? (
<div className={styles.loading}>Loading businesses…</div>
) : (
<BusinessCardList
items={visible}
busyId={busyId}
specialTick={specialTick}
emptyTitle={
filter === "pending_review"
? "No pending submissions"
: "No businesses in this view"
}
emptyCopy={
filter === "pending_review"
? "New shop registrations will appear here for approval."
: "Try another filter, or add a Meshkee business to the marketplace."
}
onApprove={(business) => void setStatus(business, "published")}
onDisable={async (business) => {
const nextStatus =
business.status === "hidden" ? "published" : "hidden";
await setStatus(business, nextStatus);
}}
onRemove={setRemoving}
onMakeSpecial={setMakingSpecial}
onMetaTag={(business) => {
const lines = [
`Name: ${business.name || business.slug}`,
`Slug: /${business.slug}`,
`Category: ${business.categoryName || "Uncategorized"}`,
`Status: ${business.status}`,
`Featured: ${business.isFeatured ? "yes" : "no"}`,
].join("\n");
window.alert(lines);
}}
/>
)}
{filterModalOpen ? (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setFilterModalOpen(false);
}
}}
>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby={filterTitleId}
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Businesses</p>
<h2 id={filterTitleId} className={styles.modalTitle}>
Filter
</h2>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
onClick={() => setFilterModalOpen(false)}
>
×
</button>
</div>
<form
className={styles.modalForm}
onSubmit={(event) => {
event.preventDefault();
applyCategoryFilter();
}}
>
<CategoryTreeSelect
categories={categories}
value={draftCategoryIds}
selection="single"
dropdownVariant="inFlow"
label="Category"
placeholder="Select category"
onChange={setDraftCategoryIds}
/>
<div className={styles.modalActions}>
<button
type="button"
className={`outline-btn ${styles.ghostBtn}`}
onClick={() => {
setDraftCategoryIds([]);
}}
>
Clear
</button>
<button type="submit" className={`outline-btn ${styles.applyBtn}`}>
Apply
</button>
</div>
</form>
</div>
</div>
) : null}
<AddBusinessModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onConnected={() => {
void reload();
}}
/>
<MakeSpecialModal
open={Boolean(makingSpecial)}
business={makingSpecial}
onClose={() => setMakingSpecial(null)}
onSaved={() => setSpecialTick((value) => value + 1)}
/>
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove listing"
title={`Remove ${removingName}?`}
message="This unlists the business from the marketplace. The Meshkee business stays intact."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removingBusy}
onClose={() => {
if (!removingBusy) setRemoving(null);
}}
onConfirm={async () => {
if (!removing) return;
setBusyId(removing.id);
setError("");
try {
await deleteMarketplaceBusiness(removing.id);
setRemoving(null);
await reload();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not remove listing.",
);
} finally {
setBusyId(null);
}
}}
/>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
.root {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.picker {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field > span {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.control {
position: relative;
display: flex;
align-items: center;
}
.control input {
width: 100%;
min-height: 42px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 10px 36px 10px 12px;
font: inherit;
font-size: 14px;
font-weight: 400;
color: var(--color-black);
outline: none;
}
.control input:focus {
border-color: var(--color-black);
}
.caret {
position: absolute;
inset-inline-end: 10px;
width: 18px;
height: 18px;
border: none;
background: transparent;
cursor: pointer;
}
.caret::before {
content: "";
display: block;
width: 7px;
height: 7px;
margin: 4px auto 0;
border-inline-end: 1.5px solid var(--color-soft);
border-bottom: 1.5px solid var(--color-soft);
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.caretOpen::before {
margin-top: 8px;
transform: rotate(225deg);
}
.dropdown {
position: absolute;
z-index: 20;
top: calc(100% - 2px);
inset-inline: 0;
max-height: 280px;
overflow: auto;
border: 1px solid #ececec;
border-radius: 5px;
background: #fff;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
}
.dropdownInFlow {
position: relative;
top: 0;
z-index: 1;
max-height: 320px;
box-shadow: none;
}
.list {
list-style: none;
margin: 0;
padding: 6px 0;
}
.node {
margin: 0;
}
.row {
display: flex;
align-items: center;
gap: 2px;
min-height: 34px;
}
.expand,
.expandSpacer {
flex: 0 0 22px;
width: 22px;
height: 22px;
}
.expand {
display: inline-grid;
place-items: center;
border: 1px solid #e4e4e4;
border-radius: 3px;
background: #fff;
color: var(--color-soft);
font-size: 14px;
font-weight: 400;
line-height: 1;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.expand:hover,
.expand:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.expandOpen {
color: var(--color-muted);
}
.option {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 6px 4px;
border: none;
background: transparent;
text-align: start;
font: inherit;
font-size: 13px;
font-weight: 400;
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;
}
.optionSlug {
margin-inline-start: auto;
flex: 0 0 auto;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--color-soft);
}
.empty {
margin: 0;
padding: 16px 14px;
font-size: 13px;
font-weight: 400;
color: var(--color-body);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 5px 10px;
font-size: 12px;
font-weight: 400;
color: var(--color-white);
background: var(--color-black);
border-color: var(--color-black);
}
.chip:hover,
.chip:focus-visible {
opacity: 0.88;
}
.chip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
categoryLabel,
type MeshkeeCategory,
} from "@/services/meshkee-categories";
import styles from "./CategoryTreeSelect.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);
}
/** Keep ancestors of matches so the tree path stays visible while searching. */
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;
label?: string;
/** multi (default) or single select */
selection?: "single" | "multiple";
/** overlay floats; inFlow expands inside the parent (better in modals) */
dropdownVariant?: "overlay" | "inFlow";
placeholder?: string;
};
export default function CategoryTreeSelect({
categories,
value,
onChange,
disabled = false,
label = "Category",
selection = "multiple",
dropdownVariant = "overlay",
placeholder = "Search categories…",
}: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const single = selection === "single";
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));
const selectedLabel = selected[0] ? categoryLabel(selected[0]) : "";
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);
if (single) setQuery("");
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
if (single) setQuery("");
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open, single]);
function pick(id: string) {
if (single) {
onChange([id]);
setOpen(false);
setQuery("");
return;
}
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} ${isExpanded ? styles.expandOpen : ""}`}
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={() => pick(node.id)}
>
{!single ? (
<span className={styles.check} aria-hidden>
{active ? "✓" : ""}
</span>
) : null}
<span className={styles.optionLabel}>{categoryLabel(node)}</span>
{node.slug ? (
<span className={styles.optionSlug}>/{node.slug}</span>
) : null}
</button>
</div>
{hasChildren && isExpanded ? (
<ul className={styles.list}>{renderNodes(node.children, depth + 1)}</ul>
) : null}
</li>
);
});
}
const inputValue = open || !single ? query : selectedLabel;
return (
<div className={styles.root} ref={rootRef}>
<div className={styles.picker}>
<label className={styles.field}>
<span>{label}</span>
<div className={styles.control}>
<input
value={inputValue}
disabled={disabled}
placeholder={
single && selectedLabel && !open ? selectedLabel : 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}
onClick={() => setOpen((prev) => !prev)}
/>
</div>
</label>
{open ? (
<div
className={`${styles.dropdown} ${
dropdownVariant === "inFlow" ? styles.dropdownInFlow : ""
}`}
role="listbox"
aria-multiselectable={!single}
>
{visibleTree.length ? (
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
) : (
<p className={styles.empty}>No categories found</p>
)}
</div>
) : null}
</div>
{!single && selected.length ? (
<div className={styles.chips}>
{selected.map((category) => (
<button
key={category.id}
type="button"
className={styles.chip}
disabled={disabled}
title="Remove category"
onClick={() => pick(category.id)}
>
{categoryLabel(category)}
<span aria-hidden>×</span>
</button>
))}
</div>
) : null}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
.list {
margin: 0;
padding: 0;
list-style: none;
}
.node {
min-width: 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding-block: 6px;
padding-inline-end: 8px;
border-bottom: 1px solid #f3f3f3;
transition: background-color 0.15s ease;
}
.row:hover {
background: #f7f7f7;
}
.name {
font-size: 14px;
font-weight: 400;
color: var(--color-black);
}
.hint {
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-inline-start: auto;
flex: 0 0 auto;
}
.actions button {
border: 0;
background: transparent;
padding: 0 10px;
min-height: 32px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-soft);
cursor: pointer;
}
.actions button:first-child {
padding-left: 0;
}
.actions button + button {
border-left: 1px solid #e8e8e8;
}
.actions button:hover,
.actions button:focus-visible {
color: var(--color-black);
}
.actions .danger:hover,
.actions .danger:focus-visible {
color: #b42318;
}
.expand,
.expandSpacer {
flex: 0 0 22px;
width: 22px;
height: 22px;
}
.expand {
display: grid;
place-items: center;
border: 1px solid #e4e4e4;
border-radius: 3px;
background: #fff;
color: var(--color-soft);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.expand:hover,
.expand:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.empty {
margin: 0;
padding: 28px 8px;
text-align: center;
font-size: 13px;
color: var(--color-muted);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./CategoryTreeView.module.css";
export type CategoryTreeNode = {
id: string;
name: string;
hint?: string;
children: CategoryTreeNode[];
};
export type CategoryTreeActions = {
onAddChild?: (id: string) => void;
onEdit?: (id: string) => void;
onRemove?: (id: string) => void;
};
export default function CategoryTreeView({
nodes,
emptyLabel = "No categories yet",
actions,
}: {
nodes: CategoryTreeNode[];
emptyLabel?: string;
actions?: CategoryTreeActions;
}) {
if (!nodes.length) {
return <p className={styles.empty}>{emptyLabel}</p>;
}
return (
<ul className={styles.list}>
{nodes.map((node) => (
<TreeBranch key={node.id} node={node} depth={0} actions={actions} />
))}
</ul>
);
}
function TreeBranch({
node,
depth,
actions,
}: {
node: CategoryTreeNode;
depth: number;
actions?: CategoryTreeActions;
}) {
const [open, setOpen] = useState(false);
const childCount = node.children.length;
const previousChildCount = useRef(childCount);
const hasChildren = childCount > 0;
const hasActions = Boolean(
actions?.onAddChild || actions?.onEdit || actions?.onRemove,
);
useEffect(() => {
if (childCount > previousChildCount.current) {
setOpen(true);
}
previousChildCount.current = childCount;
}, [childCount]);
return (
<li className={styles.node}>
<div
className={styles.row}
style={{ paddingInlineStart: `${12 + depth * 56}px` }}
>
{hasChildren ? (
<button
type="button"
className={`${styles.expand} ${open ? styles.expandOpen : ""}`}
aria-expanded={open}
aria-label={open ? "Collapse" : "Expand"}
onClick={() => setOpen((value) => !value)}
>
<span aria-hidden>{open ? "−" : "+"}</span>
</button>
) : (
<span className={styles.expandSpacer} aria-hidden />
)}
<span className={styles.name} dir="auto">
{node.name}
</span>
{node.hint ? <span className={styles.hint}>{node.hint}</span> : null}
{hasActions ? (
<div className={styles.actions}>
{actions?.onAddChild ? (
<button
type="button"
onClick={() => actions.onAddChild?.(node.id)}
>
Add sub
</button>
) : null}
{actions?.onEdit ? (
<button type="button" onClick={() => actions.onEdit?.(node.id)}>
Edit
</button>
) : null}
{actions?.onRemove ? (
<button
type="button"
className={styles.danger}
onClick={() => actions.onRemove?.(node.id)}
>
Remove
</button>
) : null}
</div>
) : null}
</div>
{hasChildren && open ? (
<ul className={styles.list}>
{node.children.map((child) => (
<TreeBranch
key={child.id}
node={child}
depth={depth + 1}
actions={actions}
/>
))}
</ul>
) : null}
</li>
);
}
+135
View File
@@ -0,0 +1,135 @@
.overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px var(--page-pad);
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(2px);
animation: overlayIn 0.22s ease;
}
.dialog {
width: min(100%, 420px);
padding: 32px 30px 26px;
border-radius: 5px;
background: var(--color-white);
box-shadow: 0 28px 64px rgba(0, 0, 0, 0.35);
animation: dialogIn 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.kicker {
margin: 0 0 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
line-height: 1.35;
}
.message {
margin: 14px 0 0;
font-size: 14px;
font-weight: 400;
line-height: 1.6;
color: var(--color-muted);
}
.actions {
margin-top: 28px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancelBtn,
.confirmBtn {
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
.cancelBtn {
border-color: #d4d4d4;
color: var(--color-soft);
}
.cancelBtn::before {
content: none;
}
.cancelBtn:hover,
.cancelBtn:focus-visible {
color: var(--color-black);
border-color: var(--color-black);
background: transparent;
}
.confirmBtn {
border-color: var(--color-black);
color: var(--color-black);
}
.confirmDanger {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.confirmDanger:hover,
.confirmDanger:focus-visible {
background: #1a1a1a;
}
.confirmDanger::before {
background: #fff;
}
.confirmDanger:hover,
.confirmDanger:focus-visible {
color: var(--color-black);
}
.cancelBtn:disabled,
.confirmBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes overlayIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogIn {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.overlay,
.dialog {
animation: none;
}
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useEffect, useId, useRef } from "react";
import styles from "./ConfirmDialog.module.css";
export default function ConfirmDialog({
open,
title,
kicker = "Confirm",
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
danger = false,
busy = false,
onConfirm,
onClose,
}: {
open: boolean;
title: string;
kicker?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
const titleId = useId();
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
cancelRef.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open, busy, onClose]);
if (!open) return null;
return (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !busy) onClose();
}}
>
<div
className={styles.dialog}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
>
<p className={styles.kicker}>{kicker}</p>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<button
ref={cancelRef}
type="button"
className={`outline-btn ${styles.cancelBtn}`}
disabled={busy}
onClick={onClose}
>
{cancelLabel}
</button>
<button
type="button"
className={`outline-btn ${styles.confirmBtn} ${danger ? styles.confirmDanger : ""}`}
disabled={busy}
onClick={onConfirm}
>
{busy ? "…" : confirmLabel}
</button>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.title {
margin: 0;
font-size: 28px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.lead {
margin: 8px 0 0;
font-size: 13px;
font-weight: 400;
color: var(--color-soft);
max-width: 42ch;
}
.card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 5px;
padding: 28px 24px;
}
.cardTitle {
margin: 0 0 8px;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.cardCopy {
margin: 0;
font-size: 14px;
font-weight: 400;
line-height: 1.55;
color: var(--color-muted);
}
.cta {
display: inline-flex;
margin-top: 18px;
min-height: 36px;
align-items: center;
padding: 0 18px;
border: 1px solid var(--color-black);
border-radius: 5px;
background: var(--color-black);
color: var(--color-white);
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
cursor: pointer;
text-decoration: none;
}
.cta:hover {
background: transparent;
color: var(--color-black);
}
.list {
display: grid;
gap: 14px;
margin: 0;
}
.row {
display: grid;
gap: 4px;
padding-bottom: 14px;
border-bottom: 1px solid #f0f0f0;
}
.row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.row dt {
font-size: 10px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-soft);
}
.row dd {
margin: 0;
font-size: 15px;
font-weight: 400;
color: var(--color-black);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useAuth } from "@/components/AuthProvider";
import { en } from "@/lib/i18n/en";
import { getAdminDashboardOrigin } from "@/lib/host";
import { openBusinessDashboardAsUser } from "@/lib/session-handoff";
import { getDisplayName, getFirstName } from "@/lib/userName";
import styles from "./CustomerHome.module.css";
export default function CustomerHome() {
const { user, isBusinessOwner, isSuperAdmin } = useAuth();
const firstName = getFirstName(user);
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>
{en.customerHome.title}, {firstName}
</h1>
<p className={styles.lead}>{en.customerHome.lead}</p>
</div>
</header>
<section className={styles.card}>
<h2 className={styles.cardTitle}>{getDisplayName(user)}</h2>
<p className={styles.cardCopy}>{en.customerHome.lead}</p>
</section>
{isSuperAdmin ? (
<section className={styles.card}>
<h2 className={styles.cardTitle}>{en.openAdminDashboard}</h2>
<p className={styles.cardCopy}>{en.customerHome.adminLead}</p>
<a
href={`${getAdminDashboardOrigin()}/`}
className={styles.cta}
>
{en.customerHome.adminCta}
</a>
</section>
) : null}
{isBusinessOwner ? (
<section className={styles.card}>
<h2 className={styles.cardTitle}>{en.openBusinessDashboard}</h2>
<p className={styles.cardCopy}>{en.customerHome.businessLead}</p>
<button
type="button"
className={styles.cta}
onClick={() => openBusinessDashboardAsUser("/profile")}
>
{en.customerHome.businessCta}
</button>
</section>
) : null}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useAuth } from "@/components/AuthProvider";
import { en } from "@/lib/i18n/en";
import { getDisplayName } from "@/lib/userName";
import styles from "./CustomerHome.module.css";
export default function CustomerProfile() {
const { user } = useAuth();
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>{en.customerProfile.title}</h1>
<p className={styles.lead}>{en.customerProfile.lead}</p>
</div>
</header>
<section className={styles.card}>
<dl className={styles.list}>
<div className={styles.row}>
<dt>{en.customerProfile.name}</dt>
<dd>{getDisplayName(user)}</dd>
</div>
<div className={styles.row}>
<dt>{en.customerProfile.phone}</dt>
<dd dir="ltr">{user?.cellNumber || "—"}</dd>
</div>
<div className={styles.row}>
<dt>{en.customerProfile.email}</dt>
<dd>{user?.email?.trim() || en.customerProfile.emptyEmail}</dd>
</div>
</dl>
</section>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import AccountMenu from "@/components/AccountMenu";
import { useAuth } from "@/components/AuthProvider";
import { customerNavItems } from "@/data/customer-nav";
import { en } from "@/lib/i18n/en";
import { getAdminDashboardOrigin } from "@/lib/host";
import { openBusinessDashboardAsUser } from "@/lib/session-handoff";
import { getDisplayName } from "@/lib/userName";
import styles from "./DashboardShell.module.css";
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
export default function CustomerShell({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { user, logout, isBusinessOwner, isSuperAdmin } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const displayName = getDisplayName(user);
const publicPath = pathname.startsWith("/customer")
? pathname.replace(/^\/customer/, "") || "/"
: pathname;
return (
<div className={styles.shell}>
<aside className={`${styles.sidebar} ${menuOpen ? styles.sidebarOpen : ""}`}>
<div className={styles.brand}>
<img src="/images/icons/logo.png" alt="" width={48} height={48} />
<div className={styles.brandText}>
<span>{en.brandName}</span>
<strong>{en.customerBrandSubtitle}</strong>
</div>
</div>
<nav className={styles.nav} aria-label="Account">
{customerNavItems.map((item) => {
const active =
item.href === "/"
? publicPath === "/"
: publicPath === item.href ||
publicPath.startsWith(`${item.href}/`);
return (
<Link
key={item.label}
href={item.href}
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
onClick={() => setMenuOpen(false)}
>
<img src={item.icon} alt="" width={18} height={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className={styles.sidebarFoot}>
<p className={styles.userLabel}>{displayName}</p>
{isSuperAdmin ? (
<a
href={`${getAdminDashboardOrigin()}/`}
className={styles.siteLink}
>
{en.openAdminDashboard}
</a>
) : null}
{isBusinessOwner ? (
<button
type="button"
className={styles.siteLink}
onClick={() => openBusinessDashboardAsUser("/profile")}
>
{en.openBusinessDashboard}
</button>
) : null}
<a href={websiteUrl} className={styles.siteLink}>
{en.viewWebsite}
</a>
<button
type="button"
className={`outline-btn ${styles.logoutBtn}`}
onClick={() => {
logout();
router.replace("/login");
}}
>
{en.logOut}
</button>
</div>
</aside>
{menuOpen ? (
<button
type="button"
className={styles.backdrop}
aria-label="Close menu"
onClick={() => setMenuOpen(false)}
/>
) : null}
<div className={styles.main}>
<header className={styles.topbar}>
<button
type="button"
className={styles.menuBtn}
aria-label="Open menu"
onClick={() => setMenuOpen(true)}
>
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
</button>
<div className={styles.topbarCopy}>
<p className={styles.eyebrow}>{en.customerEyebrow}</p>
<p className={styles.topbarHint}>{en.customerHint}</p>
</div>
<div className={styles.topbarActions}>
<AccountMenu />
</div>
</header>
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
+367
View File
@@ -0,0 +1,367 @@
.shell {
min-height: 100vh;
background: #f6f6f6;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 20;
width: var(--sidebar-width);
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
padding: 28px 22px;
background: var(--color-white);
border-right: 1px solid #ececec;
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 40px;
padding: 0 6px;
min-width: 0;
}
.brandLogo {
flex: 0 0 auto;
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 5px;
background: #f6f6f6;
}
.brand img {
width: 48px;
height: 48px;
object-fit: contain;
}
.brandText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brandBusiness {
font-size: 14px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: none;
color: var(--color-black);
line-height: 1.3;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.brandTitle {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.01em;
text-transform: none;
color: var(--color-muted);
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.brandText span:not(.brandTitle) {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.brandText strong {
font-size: 15px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.nav {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.navItem {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 0 14px;
border-radius: 5px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-muted);
transition: background 0.25s ease, color 0.25s ease;
}
.navItem img {
opacity: 0.55;
transition: opacity 0.25s ease, filter 0.25s ease;
}
.navItem:hover,
.navItem:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.navItem:hover img,
.navItem:focus-visible img {
opacity: 1;
}
.navItemActive {
background: var(--color-black);
color: var(--color-white);
}
.navItemActive img {
opacity: 1;
filter: invert(1);
}
.navGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.navGroupBtn {
width: 100%;
text-align: left;
border: 0;
background: transparent;
cursor: pointer;
}
.navGroupActive {
background: #f6f6f6;
color: var(--color-black);
}
.navGroupActive img {
opacity: 1;
}
.navChevron {
margin-left: auto;
width: 5px;
height: 9px;
opacity: 0.45;
transform: rotate(90deg);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.navChevronOpen {
transform: rotate(-90deg);
opacity: 0.8;
}
.navSub {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 0 4px 18px;
}
.navSubItem {
position: relative;
z-index: 1;
display: flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border-radius: 5px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.navSubItem:hover,
.navSubItem:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.navSubItemActive {
background: var(--color-black);
color: var(--color-white);
}
.sidebarFoot {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 20px;
border-top: 1px solid #ececec;
}
.userLabel {
padding: 0 6px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
color: var(--color-muted);
}
.siteLink {
appearance: none;
display: inline;
border: 0;
background: transparent;
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-soft);
padding: 0 6px;
text-align: left;
cursor: pointer;
}
.siteLink:hover,
.siteLink:focus-visible {
color: var(--color-black);
}
.logoutBtn {
min-height: 34px;
font-size: 11px;
letter-spacing: 0.16em;
}
.backdrop {
display: none;
}
.main {
margin-left: var(--sidebar-width);
min-width: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 22px var(--page-pad);
background: var(--color-white);
border-bottom: 1px solid #ececec;
}
.menuBtn {
display: none;
width: 40px;
height: 40px;
place-items: center;
}
.topbarCopy {
flex: 1;
min-width: 0;
}
.eyebrow {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.32em;
text-transform: uppercase;
}
.topbarHint {
margin-top: 4px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.topbarActions {
display: flex;
align-items: center;
gap: 12px;
}
.iconBtn {
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 5px;
background: var(--color-card);
}
.content {
padding: 36px var(--page-pad) 56px;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
inset: 0 auto 0 0;
width: min(86vw, var(--sidebar-width));
transform: translateX(-105%);
transition: transform 0.3s ease;
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.08);
}
.sidebarOpen {
transform: translateX(0);
}
.main {
margin-left: 0;
}
.backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.28);
z-index: 15;
}
.menuBtn {
display: grid;
}
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import AccountMenu from "@/components/AccountMenu";
import { useAuth } from "@/components/AuthProvider";
import { navItems, type NavItem } from "@/data/home";
import { getDisplayName } from "@/lib/userName";
import styles from "./DashboardShell.module.css";
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
function hrefMatches(href: string, pathname: string) {
return pathname === href || pathname.startsWith(`${href}/`);
}
function activeChildHref(
children: { href: string }[],
pathname: string,
) {
let best = "";
for (const child of children) {
if (!hrefMatches(child.href, pathname)) continue;
if (child.href.length > best.length) best = child.href;
}
return best;
}
function groupActive(item: Extract<NavItem, { type: "group" }>, pathname: string) {
return activeChildHref(item.children, pathname) !== "";
}
export default function DashboardShell({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const { user, logout } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const [openGroup, setOpenGroup] = useState<string | null>(() => {
const active = navItems.find(
(item) => item.type === "group" && groupActive(item, pathname),
);
return active?.type === "group" ? active.label : null;
});
const displayName = getDisplayName(user);
useEffect(() => {
const active = navItems.find(
(item) => item.type === "group" && groupActive(item, pathname),
);
if (active?.type === "group") setOpenGroup(active.label);
}, [pathname]);
return (
<div className={styles.shell}>
<aside className={`${styles.sidebar} ${menuOpen ? styles.sidebarOpen : ""}`}>
<div className={styles.brand}>
<img src="/images/icons/logo.png" alt="" width={48} height={48} />
<div className={styles.brandText}>
<span>IranBay</span>
<strong>Dashboard</strong>
</div>
</div>
<nav className={styles.nav} aria-label="Dashboard">
{navItems.map((item) => {
if (item.type === "group") {
const active = groupActive(item, pathname);
const expanded = openGroup === item.label;
return (
<div key={item.label} className={styles.navGroup}>
<button
type="button"
className={`${styles.navItem} ${styles.navGroupBtn} ${
active ? styles.navGroupActive : ""
}`}
aria-expanded={expanded}
onClick={() =>
setOpenGroup((current) =>
current === item.label ? null : item.label,
)
}
>
<img src={item.icon} alt="" width={18} height={18} />
<span>{item.label}</span>
<img
className={`${styles.navChevron} ${
expanded ? styles.navChevronOpen : ""
}`}
src="/images/icons/chevron.svg"
alt=""
width={5}
height={9}
/>
</button>
{expanded ? (
<div className={styles.navSub}>
{item.children.map((child) => {
const childActive =
child.href ===
activeChildHref(item.children, pathname);
return (
<Link
key={child.href}
href={child.href}
prefetch
className={`${styles.navSubItem} ${
childActive ? styles.navSubItemActive : ""
}`}
onClick={(event) => {
setMenuOpen(false);
// Ensure navigation even if soft-route is flaky.
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
router.push(child.href);
}}
>
{child.label}
</Link>
);
})}
</div>
) : null}
</div>
);
}
const active =
item.href === "/"
? pathname === "/"
: pathname === item.href ||
pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.label}
href={item.href}
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
onClick={() => setMenuOpen(false)}
>
<img src={item.icon} alt="" width={18} height={18} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className={styles.sidebarFoot}>
<p className={styles.userLabel}>{displayName}</p>
<a href={websiteUrl} className={styles.siteLink}>
View website
</a>
<button
type="button"
className={`outline-btn ${styles.logoutBtn}`}
onClick={() => {
logout();
router.replace("/login");
}}
>
Log out
</button>
</div>
</aside>
{menuOpen ? (
<button
type="button"
className={styles.backdrop}
aria-label="Close menu"
onClick={() => setMenuOpen(false)}
/>
) : null}
<div className={styles.main}>
<header className={styles.topbar}>
<button
type="button"
className={styles.menuBtn}
aria-label="Open menu"
onClick={() => setMenuOpen(true)}
>
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
</button>
<div className={styles.topbarCopy}>
<p className={styles.eyebrow}>Admin workspace</p>
<p className={styles.topbarHint}>Marketplace control</p>
</div>
<div className={styles.topbarActions}>
<AccountMenu />
</div>
</header>
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
+533
View File
@@ -0,0 +1,533 @@
.page {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.breadcrumb a {
color: inherit;
transition: color 0.2s ease;
}
.breadcrumb a:hover,
.breadcrumb a:focus-visible {
color: var(--color-black);
}
.breadcrumbSep {
opacity: 0.45;
}
.breadcrumbCurrent {
color: var(--color-muted);
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
}
.lead {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.headActions,
.footerActions {
display: flex;
align-items: center;
gap: 10px;
}
.ghostBtn,
.saveBtn {
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
.ghostBtn {
border-color: #d4d4d4;
color: var(--color-soft);
}
.ghostBtn::before {
content: none;
}
.ghostBtn:hover,
.ghostBtn:focus-visible {
color: var(--color-black);
border-color: var(--color-black);
background: transparent;
}
.saveBtn {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.saveBtn:hover,
.saveBtn:focus-visible {
color: var(--color-black);
}
.saveBtn::before {
background: #fff;
}
.layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: var(--color-white);
border-radius: 5px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.sectionTitle {
margin: 0;
font-size: 13px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.mediaGrid {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
gap: 18px;
}
.logoBlock,
.bannerBlock,
.tagBlock {
display: flex;
flex-direction: column;
gap: 10px;
}
.blockHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.inlineActions {
display: flex;
gap: 10px;
}
.linkBtn {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.linkBtn:hover,
.linkBtn:focus-visible {
color: var(--color-black);
}
.linkBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.aspectRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.aspectBtn {
min-width: 44px;
height: 28px;
padding: 0 10px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
color: var(--color-soft);
}
.aspectBtn:hover,
.aspectBtn:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.aspectBtnActive {
border-color: var(--color-black);
background: var(--color-black);
color: var(--color-white);
}
.aspectBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.logoFrame,
.bannerFrame {
width: 100%;
border: 1px dashed #d8d8d8;
border-radius: 5px;
background: #fafafa;
overflow: hidden;
display: grid;
place-items: center;
color: var(--color-body);
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.logoFrame {
aspect-ratio: 1;
}
.logoFrame img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
background: transparent;
}
.bannerFrame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fileInput {
display: none;
}
.twoCol {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.identityRow {
display: grid;
grid-template-columns: 4fr 3fr 3fr 2fr;
gap: 14px;
align-items: start;
}
.addressMetaRow {
display: grid;
grid-template-columns: minmax(120px, 1.2fr) repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.descriptionField {
margin-top: 18px;
}
.fieldHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.fieldHead > span {
line-height: 1;
margin: 0;
}
.aiBtn {
box-sizing: border-box;
min-width: 36px;
height: 28px;
min-height: 28px;
padding: 0 10px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 26px;
color: var(--color-muted);
transition:
border-color 0.35s ease,
color 0.35s ease,
background-color 0.35s ease,
box-shadow 0.35s ease;
}
.aiBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.aiBtnActive {
border-color: var(--color-black);
color: var(--color-black);
background: #f4f4f4;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field span {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.field > input,
.field > textarea,
.field > select,
.phoneRow input,
.phoneRow select {
width: 100%;
min-height: 42px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 10px 12px;
font: inherit;
font-size: 14px;
font-weight: 400;
color: var(--color-black);
outline: none;
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
.field > select {
cursor: pointer;
}
.field > textarea {
min-height: 88px;
resize: vertical;
}
.field > input:hover,
.field > textarea:hover,
.field > select:hover,
.phoneRow input:hover,
.phoneRow select:hover {
border-color: #d8d8d8;
}
.field > input:focus,
.field > textarea:focus,
.field > select:focus,
.phoneRow input:focus,
.phoneRow select:focus {
border-color: var(--color-black);
}
.chip {
display: inline-flex;
align-items: center;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 5px 10px;
font-size: 12px;
font-weight: 400;
line-height: 1.2;
color: var(--color-muted);
background: #fff;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chipActive {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.tagRow,
.phoneRow {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
}
.tagInput {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
width: 100%;
min-height: 42px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 6px 8px;
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
.tagInput:hover {
border-color: #d8d8d8;
}
.tagInput:focus-within {
border-color: var(--color-black);
}
.tagInput input {
flex: 1 1 120px;
min-width: 120px;
min-height: 28px;
border: 0;
background: transparent;
padding: 4px 6px;
font: inherit;
font-size: 14px;
font-weight: 400;
color: var(--color-black);
outline: none;
}
.phoneGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.phoneRow {
grid-template-columns: 88px minmax(0, 1fr) auto;
min-width: 0;
}
.phoneRow select {
min-width: 0;
padding-inline: 8px;
}
.phoneRow input {
min-width: 0;
}
.addressCard {
border: 1px solid #f0f0f0;
border-radius: 5px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: #fafafa;
}
.addressHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.addressHead strong {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.loading,
.errorBox {
border-radius: 5px;
background: var(--color-white);
padding: 28px 24px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.errorBox {
color: #8a1f11;
}
.footerActions {
justify-content: flex-end;
padding: 4px 0 12px;
}
@media (max-width: 900px) {
.head {
flex-direction: column;
align-items: flex-start;
}
.mediaGrid,
.twoCol,
.identityRow,
.addressMetaRow,
.phoneGrid,
.phoneRow {
grid-template-columns: 1fr;
}
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import AuthShell from "@/components/AuthShell";
import { AuthField } from "@/components/AuthFields";
import styles from "@/components/AuthShell.module.css";
export default function ForgotPasswordForm() {
return (
<AuthShell titleLine1="Forgot" titleLine2="Password">
<form
className={styles.form}
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
}}
>
<AuthField
label="Phone Number"
name="phone"
type="tel"
autoComplete="off"
/>
<div className={styles.actions}>
<Link href="/login" className={`outline-btn ${styles.secondaryBtn}`}>
Return to login
</Link>
<button type="submit" className={`outline-btn ${styles.primaryBtn}`}>
Send code
</button>
</div>
</form>
</AuthShell>
);
}
+331
View File
@@ -0,0 +1,331 @@
.page {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
max-width: none;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px);
gap: 8px;
min-height: 200px;
}
.heroCopy,
.heroPanel {
background: var(--color-card);
border-radius: 5px;
}
.heroCopy {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 28px 32px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.kicker {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.36em;
text-transform: uppercase;
color: var(--color-soft);
margin-bottom: 12px;
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
white-space: nowrap;
}
.lead {
margin-top: 14px;
max-width: 520px;
font-size: 14px;
font-weight: 400;
line-height: 1.6;
color: var(--color-muted);
}
.heroPanel {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 28px;
text-transform: uppercase;
}
.heroPanel span {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.28em;
color: var(--color-soft);
}
.heroPanel strong {
margin-top: 8px;
font-size: 22px;
font-weight: 500;
letter-spacing: 0.16em;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.statTile {
min-height: 148px;
padding: 22px 24px;
border-radius: 5px;
background: var(--color-white);
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8px;
}
.statLabel {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--color-soft);
}
.statValue {
font-size: 36px;
font-weight: 400;
letter-spacing: 0.06em;
line-height: 1;
}
.statHint {
font-size: 12px;
font-weight: 400;
color: var(--color-body);
}
.split {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 8px;
}
.panel {
background: var(--color-white);
border-radius: 5px;
padding: 28px 26px;
}
.panelHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.panelTitle {
font-size: 14px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.panelSub {
margin-top: 6px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-soft);
}
.panelBtn {
min-height: 30px;
padding: 0 14px;
font-size: 10px;
letter-spacing: 0.16em;
}
.list,
.activity {
list-style: none;
display: flex;
flex-direction: column;
}
.listRow,
.activityRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 16px 0;
border-top: 1px solid #f0f0f0;
}
.listRow:first-child,
.activityRow:first-child {
border-top: 0;
padding-top: 0;
}
.rowTitle {
font-size: 15px;
font-weight: 400;
}
.rowMeta {
margin-top: 4px;
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.rowSide {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.status {
font-size: 10px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 3px;
background: #f6f6f6;
}
.statusLive {
background: #111;
color: #fff;
}
.statusReview {
background: #e8e8e8;
}
.statusDraft {
color: var(--color-soft);
}
.rowTime {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-body);
}
.errorNote {
margin: 0;
padding: 14px 18px;
border-radius: 5px;
background: #fff;
border: 1px solid #ececec;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.activityRow {
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: start;
}
.activityDot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background: var(--color-black);
}
.actionsHead {
margin-bottom: 16px;
}
.actionGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.actionTile {
min-height: 132px;
padding: 24px;
border-radius: 5px;
background: var(--color-card);
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
}
.actionLabel {
font-size: 13px;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.actionDesc {
font-size: 13px;
font-weight: 400;
line-height: 1.5;
color: var(--color-muted);
}
@media (max-width: 1100px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.split,
.actionGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.hero {
grid-template-columns: 1fr;
}
.heroPanel {
min-height: 120px;
}
.heroCopy {
padding: 24px 20px;
}
.title {
white-space: normal;
font-size: 22px;
letter-spacing: 0.14em;
}
.stats {
grid-template-columns: 1fr;
}
.panelHead {
flex-direction: column;
}
}
+332
View File
@@ -0,0 +1,332 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/components/AuthProvider";
import { listBusinesses } from "@/services/marketplace";
import {
getMeshkeeBusiness,
} from "@/services/meshkee-businesses";
import { getFirstName } from "@/lib/userName";
import type { MarketplaceBusiness } from "@/types/business";
import styles from "./HomeDashboard.module.css";
type RecentRow = {
id: string;
name: string;
category: string;
status: string;
statusKey: MarketplaceBusiness["status"];
};
const STATUS_LABEL: Record<MarketplaceBusiness["status"], string> = {
draft: "Draft",
pending_review: "Review",
published: "Published",
hidden: "Hidden",
rejected: "Rejected",
};
export default function HomeDashboard() {
const { user } = useAuth();
const firstName = getFirstName(user);
const [items, setItems] = useState<MarketplaceBusiness[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
try {
const data = await listBusinesses({ status: "all", limit: 100 });
if (cancelled) return;
const sorted = [...data.items].sort((a, b) => {
const aTime = Date.parse(a.updatedAt || a.createdAt || "") || 0;
const bTime = Date.parse(b.updatedAt || b.createdAt || "") || 0;
return bTime - aTime;
});
const pending = data.items.filter(
(item) => item.status === "pending_review",
);
const enrichIds = new Set(
[
...sorted.slice(0, 4).map((item) => item.id),
...pending.slice(0, 4).map((item) => item.id),
],
);
const enrichedEntries = await Promise.all(
data.items
.filter((item) => enrichIds.has(item.id))
.map(async (item) => {
try {
const meshkee = await getMeshkeeBusiness(
String(item.meshkeeBusinessId),
);
return [
item.id,
{
...item,
name: meshkee.name,
categoryName: meshkee.categories[0]?.name,
},
] as const;
} catch {
return [item.id, { ...item, name: item.slug }] as const;
}
}),
);
const byId = new Map(enrichedEntries);
setItems(data.items.map((item) => byId.get(item.id) ?? item));
} catch (err) {
if (cancelled) return;
setError(
err instanceof Error ? err.message : "Could not load marketplace data.",
);
setItems([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const stats = useMemo(() => {
const published = items.filter((item) => item.status === "published").length;
const pending = items.filter(
(item) => item.status === "pending_review",
).length;
const draft = items.filter((item) => item.status === "draft").length;
return [
{
id: "businesses",
label: "Live",
value: loading ? "…" : String(published),
hint: "Published on marketplace",
href: "/businesses?status=published",
},
{
id: "pending",
label: "Pending",
value: loading ? "…" : String(pending),
hint: "Awaiting review",
href: "/businesses?status=pending_review",
},
{
id: "draft",
label: "Draft",
value: loading ? "…" : String(draft),
hint: "Not submitted yet",
href: "/businesses?status=draft",
},
{
id: "total",
label: "Total",
value: loading ? "…" : String(items.length),
hint: "All linked listings",
href: "/businesses",
},
];
}, [items, loading]);
const recent: RecentRow[] = useMemo(
() =>
[...items]
.sort((a, b) => {
const aTime = Date.parse(a.updatedAt || a.createdAt || "") || 0;
const bTime = Date.parse(b.updatedAt || b.createdAt || "") || 0;
return bTime - aTime;
})
.slice(0, 4)
.map((item) => ({
id: item.id,
name: item.name || item.slug,
category: item.categoryName || "Uncategorized",
status: STATUS_LABEL[item.status],
statusKey: item.status,
})),
[items],
);
const pendingItems = useMemo(
() => items.filter((item) => item.status === "pending_review").slice(0, 4),
[items],
);
return (
<div className={styles.page}>
<section className={styles.hero} aria-label="Welcome">
<div className={styles.heroCopy}>
<p className={styles.kicker}>Overview · {firstName}</p>
<h1 className={styles.title}>Marketplace command</h1>
<p className={styles.lead}>
Track listings, review submissions, and keep the public marketplace
gate in sync.
</p>
</div>
<div className={styles.heroPanel} aria-hidden>
<span>IranBay</span>
<strong>Admin</strong>
</div>
</section>
<section className={styles.stats} aria-label="Key metrics">
{stats.map((stat) => (
<a
key={stat.id}
href={stat.href}
className={`tile-hover ${styles.statTile}`}
>
<p className={styles.statLabel}>{stat.label}</p>
<p className={styles.statValue}>{stat.value}</p>
<p className={styles.statHint}>{stat.hint}</p>
</a>
))}
</section>
{error ? (
<p className={styles.errorNote} role="alert">
{error}
</p>
) : null}
<section className={styles.split} id="businesses">
<div className={styles.panel}>
<div className={styles.panelHead}>
<div>
<h2 className={styles.panelTitle}>Recent businesses</h2>
<p className={styles.panelSub}>Latest marketplace listings</p>
</div>
<a href="/businesses" className={`outline-btn ${styles.panelBtn}`}>
View all
</a>
</div>
<ul className={styles.list}>
{loading && recent.length === 0 ? (
<li className={styles.listRow}>
<p className={styles.rowMeta}>Loading…</p>
</li>
) : null}
{!loading && recent.length === 0 ? (
<li className={styles.listRow}>
<p className={styles.rowMeta}>No listings yet</p>
</li>
) : null}
{recent.map((item) => (
<li key={item.id} className={styles.listRow}>
<div>
<p className={styles.rowTitle}>{item.name}</p>
<p className={styles.rowMeta}>{item.category}</p>
</div>
<div className={styles.rowSide}>
<span
className={`${styles.status} ${
item.statusKey === "published"
? styles.statusLive
: item.statusKey === "pending_review"
? styles.statusReview
: styles.statusDraft
}`}
>
{item.status}
</span>
</div>
</li>
))}
</ul>
</div>
<div className={styles.panel} id="activity">
<div className={styles.panelHead}>
<div>
<h2 className={styles.panelTitle}>Pending review</h2>
<p className={styles.panelSub}>Approve new shop submissions</p>
</div>
<a
href="/businesses?status=pending_review"
className={`outline-btn ${styles.panelBtn}`}
>
Review
</a>
</div>
<ul className={styles.activity}>
{loading && pendingItems.length === 0 ? (
<li className={styles.activityRow}>
<div className={styles.activityDot} aria-hidden />
<div>
<p className={styles.rowMeta}>Loading…</p>
</div>
</li>
) : null}
{!loading && pendingItems.length === 0 ? (
<li className={styles.activityRow}>
<div className={styles.activityDot} aria-hidden />
<div>
<p className={styles.rowTitle}>Queue clear</p>
<p className={styles.rowMeta}>No submissions waiting</p>
</div>
</li>
) : null}
{pendingItems.map((item) => (
<li key={item.id} className={styles.activityRow}>
<div className={styles.activityDot} aria-hidden />
<div>
<p className={styles.rowTitle}>
{item.name || item.slug}
</p>
<p className={styles.rowMeta}>
/{item.slug} · awaiting approval
</p>
</div>
<a
className={styles.rowTime}
href={`/businesses/${item.id}`}
>
Open
</a>
</li>
))}
</ul>
</div>
</section>
<section className={styles.actions} id="settings" aria-label="Quick actions">
<div className={styles.actionsHead}>
<h2 className={styles.panelTitle}>Quick actions</h2>
<p className={styles.panelSub}>Jump into common admin work</p>
</div>
<div className={styles.actionGrid}>
<a
href="/businesses?status=pending_review"
className={`tile-hover ${styles.actionTile}`}
>
<p className={styles.actionLabel}>Review pending</p>
<p className={styles.actionDesc}>Approve new marketplace listings</p>
</a>
<a href="/businesses" className={`tile-hover ${styles.actionTile}`}>
<p className={styles.actionLabel}>Manage businesses</p>
<p className={styles.actionDesc}>List, edit, publish, or hide</p>
</a>
<a
href={
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000"
}
className={`tile-hover ${styles.actionTile}`}
target="_blank"
rel="noreferrer"
>
<p className={styles.actionLabel}>Open website</p>
<p className={styles.actionDesc}>Preview the public gate</p>
</a>
</div>
</section>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
.overlay {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.45);
}
.dialog {
width: min(720px, 100%);
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
border-radius: 5px;
background: var(--color-white);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18);
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
}
.title {
margin: 0;
font-size: 13px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.hint {
margin: 0;
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.stage {
position: relative;
width: 100%;
height: min(52vh, 420px);
overflow: hidden;
border-radius: 5px;
background: #111;
}
/* Light checker so transparent logos stay visible while cropping */
.stageLight {
background-color: #f3f3f3;
background-image:
linear-gradient(45deg, #e8e8e8 25%, transparent 25%),
linear-gradient(-45deg, #e8e8e8 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e8e8e8 75%),
linear-gradient(-45deg, transparent 75%, #e8e8e8 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
.stageLight :global(.reactEasyCrop_CropArea) {
color: rgba(0, 0, 0, 0.28);
border-color: rgba(0, 0, 0, 0.55);
}
.zoomRow {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px;
}
.zoomBtn {
width: 32px;
height: 32px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font-size: 18px;
font-weight: 400;
line-height: 1;
color: var(--color-muted);
}
.zoomBtn:hover:not(:disabled),
.zoomBtn:focus-visible:not(:disabled) {
border-color: var(--color-black);
color: var(--color-black);
}
.zoomBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.zoomRange {
width: 100%;
accent-color: var(--color-black);
}
.zoomValue {
min-width: 44px;
text-align: end;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
color: var(--color-soft);
}
.error {
margin: 0;
font-size: 13px;
font-weight: 400;
color: #8a1f11;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cancelBtn,
.applyBtn {
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
.cancelBtn {
border-color: #d4d4d4;
color: var(--color-soft);
}
.cancelBtn::before {
content: none;
}
.cancelBtn:hover,
.cancelBtn:focus-visible {
color: var(--color-black);
border-color: var(--color-black);
background: transparent;
}
.applyBtn {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.applyBtn:hover,
.applyBtn:focus-visible {
color: var(--color-black);
}
.applyBtn::before {
background: #fff;
}
.applyBtn:disabled,
.cancelBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { getCroppedImageFile } from "@/lib/cropImage";
import styles from "./ImageCropDialog.module.css";
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.05;
type Props = {
open: boolean;
imageSrc: string | null;
title: string;
aspect: number;
/** Prefer PNG for logos with transparency */
outputType?: "image/jpeg" | "image/png";
fileName?: string;
onCancel: () => void;
onComplete: (file: File, previewUrl: string) => void;
};
export default function ImageCropDialog({
open,
imageSrc,
title,
aspect,
outputType = "image/jpeg",
fileName = "image",
onCancel,
onComplete,
}: Props) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const transparentOutput = outputType === "image/png";
useEffect(() => {
if (!open) return;
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
setBusy(false);
setError("");
}, [open, imageSrc]);
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedAreaPixels(pixels);
}, []);
function clampZoom(value: number) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
}
if (!open || !imageSrc) return null;
async function apply() {
if (!croppedAreaPixels || !imageSrc) return;
setBusy(true);
setError("");
try {
const file = await getCroppedImageFile(
imageSrc,
croppedAreaPixels,
fileName,
outputType,
);
const previewUrl = URL.createObjectURL(file);
onComplete(file, previewUrl);
setCrop({ x: 0, y: 0 });
setZoom(1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not crop image.");
} finally {
setBusy(false);
}
}
return (
<div className={styles.overlay} role="dialog" aria-modal aria-label={title}>
<div className={styles.dialog}>
<div className={styles.head}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.hint}>
Drag to move · zoom out to add padding around the image
</p>
</div>
<div
className={
transparentOutput
? `${styles.stage} ${styles.stageLight}`
: styles.stage
}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={(value) => setZoom(clampZoom(value))}
onCropComplete={onCropComplete}
showGrid={false}
objectFit="contain"
restrictPosition={false}
style={{
containerStyle: {
background: "transparent",
},
}}
/>
</div>
<div className={styles.zoomRow}>
<button
type="button"
className={styles.zoomBtn}
disabled={busy || zoom <= MIN_ZOOM}
aria-label="Zoom out"
onClick={() => setZoom((value) => clampZoom(value - ZOOM_STEP))}
>
−
</button>
<input
className={styles.zoomRange}
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
disabled={busy}
aria-label="Zoom"
onChange={(event) => setZoom(clampZoom(Number(event.target.value)))}
/>
<button
type="button"
className={styles.zoomBtn}
disabled={busy || zoom >= MAX_ZOOM}
aria-label="Zoom in"
onClick={() => setZoom((value) => clampZoom(value + ZOOM_STEP))}
>
+
</button>
<span className={styles.zoomValue}>{Math.round(zoom * 100)}%</span>
</div>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`outline-btn ${styles.cancelBtn}`}
disabled={busy}
onClick={() => {
setCrop({ x: 0, y: 0 });
setZoom(1);
setError("");
onCancel();
}}
>
Cancel
</button>
<button
type="button"
className={`outline-btn ${styles.applyBtn}`}
disabled={busy || !croppedAreaPixels}
onClick={() => void apply()}
>
{busy ? "Applying…" : "Apply crop"}
</button>
</div>
</div>
</div>
);
}
+319
View File
@@ -0,0 +1,319 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import AuthShell from "@/components/AuthShell";
import { AuthField, AuthPasswordField } from "@/components/AuthFields";
import { postLoginHref, useAuth } from "@/components/AuthProvider";
import { ApiError, getAccessToken, getRefreshToken } from "@/lib/meshkee-api";
import { toE164CellNumber } from "@/lib/cellNumber";
import {
getAdminDashboardOrigin,
getCustomerDashboardOrigin,
hostAppFor,
} from "@/lib/host";
import styles from "@/components/AuthShell.module.css";
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
const tenantDomain =
process.env.NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN?.trim() ||
(() => {
try {
return new URL(websiteUrl).hostname;
} catch {
return "iranbay.com";
}
})();
type AuthMode = "login" | "register";
function isSafeWebsiteReturn(next: string) {
try {
const url = new URL(next);
const allowed = new URL(websiteUrl);
return url.origin === allowed.origin;
} catch {
return false;
}
}
function websiteHandoffUrl(next: string) {
const access = getAccessToken();
const refresh = getRefreshToken();
if (!access || !refresh) return null;
const target = new URL(next);
const hash = new URLSearchParams({
access,
refresh,
next: `${target.pathname}${target.search}${target.hash}` || "/register",
}).toString();
return `${target.origin}/auth/handoff#${hash}`;
}
function splitName(fullName: string) {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
const firstName = parts[0] || "";
const lastName = parts.length > 1 ? parts.slice(1).join(" ") : firstName;
return { firstName, lastName };
}
async function finishAuth(
dashboard: "super_admin" | "business" | "customer",
returnToWebsite: string | null,
router: ReturnType<typeof useRouter>,
) {
if (returnToWebsite) {
const handoff = websiteHandoffUrl(returnToWebsite);
if (handoff) {
window.location.href = handoff;
return;
}
}
const href = postLoginHref(dashboard);
if (href.startsWith("http")) {
window.location.href = href;
} else {
router.push(href);
}
}
export default function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { login, register } = useAuth();
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [hostReady, setHostReady] = useState(false);
const [app, setApp] = useState<"admin" | "customer" | "business">("admin");
const [adminLoginHref, setAdminLoginHref] = useState("");
const nextParam = searchParams.get("next");
const returnToWebsite =
nextParam && isSafeWebsiteReturn(nextParam) ? nextParam : null;
const modeParam = searchParams.get("mode");
const [mode, setMode] = useState<AuthMode>(() =>
modeParam === "register" ? "register" : "login",
);
useEffect(() => {
const host = window.location.host;
const next = hostAppFor(host);
setApp(next);
setHostReady(true);
setAdminLoginHref(`${getAdminDashboardOrigin()}/login`);
if (next === "business") {
const loginUrl = new URL(`${getCustomerDashboardOrigin()}/login`);
if (returnToWebsite) loginUrl.searchParams.set("next", returnToWebsite);
if (mode === "register") loginUrl.searchParams.set("mode", "register");
window.location.replace(loginUrl.toString());
}
}, [returnToWebsite, mode]);
useEffect(() => {
if (modeParam === "register") setMode("register");
if (modeParam === "login") setMode("login");
}, [modeParam]);
if (!hostReady || app === "business") {
return (
<div
style={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#f6f6f6",
fontSize: 12,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "#8e8e8e",
}}
>
Redirecting to login…
</div>
);
}
const isRegister = mode === "register" && app === "customer";
const titleLine1 = isRegister ? "Create" : "Login To";
const titleLine2 = app === "customer" ? "Account" : "IranBay";
return (
<AuthShell
titleLine1={titleLine1}
titleLine2={titleLine2}
footerNote={
isRegister ? (
<button
type="button"
className={styles.noteLink}
onClick={() => {
setError("");
setMode("login");
}}
>
Already have an account? Login
</button>
) : (
<Link href="/forgot-password">Forgot password?</Link>
)
}
>
<form
className={styles.form}
autoComplete="off"
key={isRegister ? "register" : "login"}
onSubmit={async (e) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
const form = new FormData(e.currentTarget);
const phone = String(form.get("phone") ?? "");
const password = String(form.get("password") ?? "");
try {
if (isRegister) {
const name = String(form.get("name") ?? "");
const email = String(form.get("email") ?? "").trim();
const { firstName, lastName } = splitName(name);
if (firstName.length < 2) {
throw new ApiError("Please enter your full name.", 400);
}
const dashboard = await register({
cellNumber: toE164CellNumber(phone),
password,
firstName,
lastName: lastName.length >= 2 ? lastName : firstName,
email: email || undefined,
domain: tenantDomain,
});
await finishAuth(dashboard, returnToWebsite, router);
} else {
const dashboard = await login(toE164CellNumber(phone), password);
await finishAuth(dashboard, returnToWebsite, router);
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError(
isRegister
? "Unable to create account. Is Meshkee API running?"
: "Unable to sign in. Is Meshkee API running?",
);
}
} finally {
setIsSubmitting(false);
}
}}
>
{error ? (
<p className={styles.error} role="alert">
{error}
{app === "customer" &&
adminLoginHref &&
error.includes("super admin access") ? (
<>
{" "}
<a href={adminLoginHref}>Go to admin login</a>
</>
) : null}
</p>
) : null}
{returnToWebsite ? (
<p className={styles.hint}>
{isRegister
? "Create an account to continue business registration"
: "Sign in to continue business registration"}
</p>
) : null}
{isRegister ? (
<AuthField
label="Name"
name="name"
autoComplete="name"
required
disabled={isSubmitting}
/>
) : null}
<AuthField
label={isRegister ? "Cell Number" : "Phone Number"}
name="phone"
type="tel"
autoComplete="off"
dir="ltr"
required
disabled={isSubmitting}
/>
{isRegister ? (
<AuthField
label="Email Address"
name="email"
type="email"
autoComplete="email"
required
disabled={isSubmitting}
/>
) : null}
<AuthPasswordField
label="Password"
name="password"
autoComplete={isRegister ? "new-password" : "current-password"}
required
disabled={isSubmitting}
/>
<div className={styles.actions}>
{app === "customer" ? (
isRegister ? (
<button
type="button"
className={styles.secondaryBtn}
disabled={isSubmitting}
onClick={() => {
setError("");
setMode("login");
}}
>
Login instead
</button>
) : (
<button
type="button"
className={styles.secondaryBtn}
disabled={isSubmitting}
onClick={() => {
setError("");
setMode("register");
}}
>
Create account
</button>
)
) : null}
<button
type="submit"
className={`outline-btn ${styles.primaryBtn}`}
disabled={isSubmitting}
>
{isSubmitting ? "…" : isRegister ? "REGISTER" : "LOGIN"}
</button>
</div>
</form>
</AuthShell>
);
}
+150
View File
@@ -0,0 +1,150 @@
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px var(--page-pad);
background: rgba(0, 0, 0, 0.28);
}
.modal {
width: min(100%, 480px);
max-height: min(90vh, 720px);
overflow: auto;
border-radius: 5px;
background: var(--color-white);
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
padding: 28px 28px 24px;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.kicker {
margin: 0 0 8px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.title {
margin: 0;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.hint {
margin: 8px 0 0;
font-size: 13px;
color: var(--color-soft);
}
.closeBtn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 5px;
font-size: 22px;
line-height: 1;
color: var(--color-soft);
}
.closeBtn:hover,
.closeBtn:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
width: 100%;
text-align: left;
padding: 14px 16px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fafafa;
}
.item:hover:not(:disabled),
.item:focus-visible:not(:disabled) {
border-color: #ddd;
background: #fff;
}
.item:disabled {
opacity: 0.65;
cursor: default;
}
.itemMain {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.itemTitle {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.itemSubtext {
font-size: 12px;
color: var(--color-soft);
}
.itemMeta {
flex-shrink: 0;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-soft);
}
.empty,
.error,
.status {
margin: 0;
font-size: 13px;
}
.empty {
color: var(--color-soft);
padding: 20px 0;
text-align: center;
}
.error {
margin-top: 12px;
color: #8a1f11;
}
.status {
margin-top: 12px;
color: #1f6b3a;
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useEffect, useId, useState } from "react";
import {
getSpecialBusinesses,
saveSpecialBusinesses,
type SpecialCarousel,
} from "@/services/special-businesses";
import type { MarketplaceBusiness } from "@/types/business";
import styles from "./MakeSpecialModal.module.css";
export default function MakeSpecialModal({
business,
open,
onClose,
onSaved,
}: {
business: MarketplaceBusiness | null;
open: boolean;
onClose: () => void;
onSaved?: () => void;
}) {
const titleId = useId();
const [carousels, setCarousels] = useState<SpecialCarousel[]>([]);
const [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const [error, setError] = useState("");
const [status, setStatus] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
setStatus("");
try {
const config = await getSpecialBusinesses();
if (!cancelled) setCarousels(config.carousels);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "Could not load carousels.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open]);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !savingId) onClose();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open, savingId, onClose]);
if (!open || !business) return null;
const name = business.name || business.slug;
async function pickCarousel(carousel: SpecialCarousel) {
if (!business) return;
setSavingId(carousel.id);
setError("");
setStatus("");
try {
const config = await getSpecialBusinesses();
const nextCarousels = config.carousels.map((item) => {
if (item.id !== carousel.id) return item;
if (item.businessIds.includes(business.id)) return item;
return {
...item,
businessIds: [...item.businessIds, business.id],
};
});
await saveSpecialBusinesses({ carousels: nextCarousels });
setStatus(`Added to ${carousel.title}`);
onSaved?.();
window.setTimeout(() => onClose(), 500);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not add business to carousel.",
);
} finally {
setSavingId(null);
}
}
return (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !savingId) onClose();
}}
>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className={styles.head}>
<div>
<p className={styles.kicker}>Make special</p>
<h2 id={titleId} className={styles.title}>
Choose a carousel
</h2>
<p className={styles.hint}>Add {name} to one of the homepage carousels.</p>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
disabled={Boolean(savingId)}
onClick={onClose}
>
×
</button>
</div>
{loading ? (
<p className={styles.empty}>Loading carousels…</p>
) : error && !carousels.length ? (
<p className={styles.error} role="alert">
{error}
</p>
) : carousels.length ? (
<ul className={styles.list}>
{carousels.map((carousel) => {
const alreadyIn = carousel.businessIds.includes(business.id);
return (
<li key={carousel.id}>
<button
type="button"
className={styles.item}
disabled={Boolean(savingId) || alreadyIn}
onClick={() => void pickCarousel(carousel)}
>
<span className={styles.itemMain}>
<span className={styles.itemTitle}>
{carousel.title || "Untitled"}
</span>
{carousel.subtext ? (
<span className={styles.itemSubtext}>
{carousel.subtext}
</span>
) : null}
</span>
<span className={styles.itemMeta}>
{savingId === carousel.id
? "Adding…"
: alreadyIn
? "Already in"
: `${carousel.businessIds.length} businesses`}
</span>
</button>
</li>
);
})}
</ul>
) : (
<p className={styles.empty}>
No carousels yet. Create one under Website → Carousels.
</p>
)}
{error && carousels.length ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
{status ? <p className={styles.status}>{status}</p> : null}
</div>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
.wrap {
display: flex;
flex-direction: column;
gap: 8px;
}
.head {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.head:has(.label) {
justify-content: space-between;
}
.label {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
line-height: 1;
}
.tools {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.toolSep {
width: 1px;
height: 18px;
margin: 0 2px;
background: #e8e8e8;
}
.tool {
box-sizing: border-box;
min-width: 32px;
height: 28px;
min-height: 28px;
padding: 0 8px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font-size: 11px;
font-weight: 500;
line-height: 26px;
color: var(--color-muted);
transition:
border-color 0.35s ease,
color 0.35s ease,
background-color 0.35s ease,
box-shadow 0.35s ease;
}
.tool:hover,
.tool:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.aiTool:hover:not(:disabled),
.aiTool:focus-visible:not(:disabled) {
border-color: transparent;
color: #fff;
}
.toolActive {
border-color: var(--color-black);
color: var(--color-black);
background: #f4f4f4;
}
.aiTool {
min-width: 36px;
letter-spacing: 0.08em;
}
.toolEnd {
display: inline-flex;
align-items: center;
margin-inline-start: 10px;
padding-inline-start: 10px;
border-inline-start: 1px solid #e4e4e4;
}
.tool:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolSelectWrap {
display: inline-flex;
align-items: center;
height: 28px;
max-height: 28px;
}
.toolSelect {
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
display: block;
width: auto;
height: 28px;
min-height: 28px;
max-height: 28px;
margin: 0;
border: 1px solid #ececec;
border-radius: 3px;
background-color: #fff;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%238e8e8e' d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px 6px;
padding: 0 22px 0 8px;
font: inherit;
font-size: 11px;
font-weight: 500;
line-height: 26px;
color: var(--color-muted);
outline: none;
cursor: pointer;
vertical-align: middle;
transition: border-color 0.35s ease, color 0.35s ease;
}
.toolSelect:hover,
.toolSelect:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.toolSelect:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.editor {
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 14px 16px;
font-size: 14px;
font-weight: 400;
line-height: 1.65;
color: var(--color-black);
outline: none;
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
.editor:hover {
border-color: #d8d8d8;
}
.editor:focus {
border-color: var(--color-black);
}
.editor :global(ul) {
margin: 0.4em 0 0.4em 1.2em;
}
.editor :global(p) {
margin: 0 0 0.6em;
}
.editor :global(img) {
display: block;
max-width: 100%;
height: auto;
margin: 0.7em 0;
border-radius: 4px;
}
.imageError {
margin: 0;
font-size: 12px;
font-weight: 400;
color: #b42318;
}
.fileInput {
display: none;
}
.editor :global(font[size="2"]) {
font-size: 13px;
}
.editor :global(font[size="3"]) {
font-size: 14px;
}
.editor :global(font[size="4"]) {
font-size: 16px;
}
.editor :global(font[size="5"]) {
font-size: 20px;
}
.editor:empty::before {
content: "Write here…";
color: var(--color-body);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import aiStyles from "@/styles/ai.module.css";
import styles from "./RichTextArea.module.css";
const FONT_SIZES = [
{ value: "2", label: "S" },
{ value: "3", label: "M" },
{ value: "4", label: "L" },
{ value: "5", label: "XL" },
] as const;
function escapeAttr(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
export default function RichTextArea({
label,
value,
onChange,
disabled,
minHeight = 200,
toolbarEnd,
aiWorking = false,
onUploadImage,
}: {
label: string;
value: string;
onChange: (html: string) => void;
disabled?: boolean;
minHeight?: number;
toolbarEnd?: ReactNode;
aiWorking?: boolean;
onUploadImage?: (file: File) => Promise<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
const [fontSize, setFontSize] = useState("3");
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState("");
useEffect(() => {
const node = ref.current;
if (!node) return;
if (node.innerHTML !== value) {
node.innerHTML = value || "";
}
}, [value]);
function emit() {
onChange(ref.current?.innerHTML ?? "");
}
function exec(command: string, commandValue?: string) {
if (disabled) return;
ref.current?.focus();
document.execCommand(command, false, commandValue);
emit();
}
function applyFontSize(size: string) {
setFontSize(size);
exec("fontSize", size);
}
function applyDirection(next: "ltr" | "rtl") {
setDirection(next);
const node = ref.current;
if (!node || disabled) return;
node.setAttribute("dir", next);
node.focus();
emit();
}
async function insertImage(file: File) {
if (!onUploadImage || disabled) return;
setUploadingImage(true);
setImageError("");
try {
const url = await onUploadImage(file);
if (
!url.startsWith("/") &&
!url.startsWith("http://") &&
!url.startsWith("https://")
) {
throw new Error("Could not add image.");
}
ref.current?.focus();
document.execCommand(
"insertHTML",
false,
`<img src="${escapeAttr(url)}" alt="" />`,
);
emit();
} catch (err) {
setImageError(
err instanceof Error ? err.message : "Could not add image.",
);
} finally {
setUploadingImage(false);
}
}
return (
<div className={styles.wrap}>
<div className={styles.head}>
{label ? <span className={styles.label}>{label}</span> : null}
<div className={styles.tools}>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Bold"
onClick={() => exec("bold")}
>
B
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Italic"
onClick={() => exec("italic")}
>
I
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Bullet list"
onClick={() => exec("insertUnorderedList")}
>
• List
</button>
{onUploadImage ? (
<button
type="button"
className={styles.tool}
disabled={disabled || uploadingImage}
title="Insert image"
onClick={() => imageInputRef.current?.click()}
>
{uploadingImage ? "…" : "Image"}
</button>
) : null}
<span className={styles.toolSep} aria-hidden />
<label className={styles.toolSelectWrap}>
<span className={styles.srOnly}>Font size</span>
<select
className={styles.toolSelect}
value={fontSize}
disabled={disabled}
title="Font size"
onChange={(event) => applyFontSize(event.target.value)}
>
{FONT_SIZES.map((size) => (
<option key={size.value} value={size.value}>
Size {size.label}
</option>
))}
</select>
</label>
<span className={styles.toolSep} aria-hidden />
<button
type="button"
className={`${styles.tool} ${direction === "ltr" ? styles.toolActive : ""}`}
disabled={disabled}
title="Left to right"
onClick={() => applyDirection("ltr")}
>
LTR
</button>
<button
type="button"
className={`${styles.tool} ${direction === "rtl" ? styles.toolActive : ""}`}
disabled={disabled}
title="Right to left"
onClick={() => applyDirection("rtl")}
>
RTL
</button>
<span className={styles.toolSep} aria-hidden />
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Align left"
onClick={() => exec("justifyLeft")}
>
Left
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Align center"
onClick={() => exec("justifyCenter")}
>
Center
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Align right"
onClick={() => exec("justifyRight")}
>
Right
</button>
<button
type="button"
className={styles.tool}
disabled={disabled}
title="Justify"
onClick={() => exec("justifyFull")}
>
Justify
</button>
{toolbarEnd ? (
<div className={styles.toolEnd}>{toolbarEnd}</div>
) : null}
</div>
</div>
{imageError ? <p className={styles.imageError}>{imageError}</p> : null}
<div
ref={ref}
className={`${styles.editor}${aiWorking ? ` ${aiStyles.aiWorkingBorder}` : ""}`}
style={{ minHeight }}
contentEditable={!disabled}
suppressContentEditableWarning
dir={direction}
onInput={emit}
onBlur={emit}
/>
{onUploadImage ? (
<input
ref={imageInputRef}
type="file"
accept="image/*"
className={styles.fileInput}
disabled={disabled || uploadingImage}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void insertImage(file);
}}
/>
) : null}
</div>
);
}
+479
View File
@@ -0,0 +1,479 @@
.page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
padding-bottom: 40px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 28px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.title {
margin: 0;
font-size: 18px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.lead {
margin: 6px 0 0;
font-size: 13px;
font-weight: 400;
color: var(--color-soft);
}
.headActions {
display: flex;
align-items: center;
gap: 10px;
}
.saveBtn,
.addBtn {
min-height: 38px;
padding: 0 16px;
font-size: 11px;
letter-spacing: 0.16em;
}
.saveBtn {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.saveBtn::before {
background: #fff;
}
.saveBtn:hover,
.saveBtn:focus-visible {
color: var(--color-black);
}
.card {
background: var(--color-white);
border-radius: 5px;
padding: 20px 22px 24px;
display: flex;
flex-direction: column;
gap: 18px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tab {
min-height: 36px;
padding: 0 14px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.tab:hover,
.tab:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.tabActive {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.tab.tabActive:hover,
.tab.tabActive:focus-visible {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.activeMeta {
display: flex;
flex-direction: column;
gap: 4px;
}
.activeTitle {
margin: 0;
font-size: 15px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.activeSubtext {
margin: 0;
font-size: 13px;
color: var(--color-soft);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-soft);
}
.field input {
width: 100%;
min-height: 38px;
padding: 0 12px;
border: 1px solid #ececec;
border-radius: 3px;
background: #fff;
font: inherit;
font-size: 13px;
letter-spacing: 0;
text-transform: none;
color: var(--color-black);
}
.field input:focus {
outline: none;
border-color: #bbb;
}
.linkBtn {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
min-height: 38px;
}
.linkBtn:hover,
.linkBtn:focus-visible {
color: var(--color-black);
}
.searchRow {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
align-items: end;
}
.results {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 5px;
padding: 8px;
}
.resultRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 3px;
border: 1px solid transparent;
background: #fafafa;
}
.resultRow:hover {
border-color: #ddd;
background: #fff;
}
.inlineAddBtn {
min-height: 32px;
padding: 0 12px;
font-size: 10px;
letter-spacing: 0.14em;
}
.listHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
}
.listHeading {
margin: 0;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-soft);
}
.listCount {
font-size: 11px;
letter-spacing: 0.12em;
color: var(--color-soft);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.listItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fafafa;
}
.identity {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.logo {
width: 72px;
height: 44px;
object-fit: contain;
object-position: center;
flex-shrink: 0;
padding: 4px;
box-sizing: border-box;
border-radius: 4px;
background: transparent;
}
.logoMark {
width: 72px;
height: 44px;
flex-shrink: 0;
display: grid;
place-items: center;
box-sizing: border-box;
padding: 4px;
border-radius: 4px;
background: #fff;
border: 1px solid #f0f0f0;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.08em;
color: var(--color-muted);
}
.listMain {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.listName {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.listSubtext,
.listSlug {
font-size: 12px;
color: var(--color-soft);
}
.listSubtext {
letter-spacing: 0.02em;
text-transform: none;
}
.empty {
padding: 28px 16px;
text-align: center;
font-size: 12px;
letter-spacing: 0.08em;
color: var(--color-soft);
border: 1px dashed #e4e4e4;
border-radius: 5px;
}
.status {
font-size: 12px;
color: var(--color-soft);
}
.statusOk {
color: #1f6b3a;
}
.statusError {
color: #8a1f11;
}
.loading {
min-height: 220px;
display: grid;
place-items: center;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-soft);
}
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px var(--page-pad);
background: rgba(0, 0, 0, 0.28);
}
.modal {
width: min(100%, 480px);
max-height: min(90vh, 720px);
overflow: auto;
border-radius: 5px;
background: var(--color-white);
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
padding: 28px 28px 24px;
}
.modalHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.kicker {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
margin-bottom: 8px;
}
.modalTitle {
margin: 0;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.closeBtn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 5px;
font-size: 22px;
line-height: 1;
color: var(--color-soft);
}
.closeBtn:hover,
.closeBtn:focus-visible {
background: #f6f6f6;
color: var(--color-black);
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.formError {
margin: 0;
font-size: 12px;
color: #8a1f11;
}
.modalActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
}
.modalActionsRight {
display: flex;
align-items: center;
gap: 10px;
}
.dangerBtn {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8a1f11;
min-height: 38px;
}
.dangerBtn:hover,
.dangerBtn:focus-visible {
color: #5c120a;
}
@media (max-width: 900px) {
.head,
.searchRow {
grid-template-columns: 1fr;
}
.head {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.modalActions {
flex-direction: column;
align-items: stretch;
}
.modalActionsRight {
justify-content: flex-end;
}
}
+514
View File
@@ -0,0 +1,514 @@
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { listBusinesses } from "@/services/marketplace";
import {
getSpecialBusinesses,
newCarousel,
saveSpecialBusinesses,
slugFromTitle,
type SpecialBusinessesConfig,
type SpecialCarousel,
} from "@/services/special-businesses";
import type { MarketplaceBusiness } from "@/types/business";
import {
getBusinessProfile,
getMeshkeeBusiness,
} from "@/services/meshkee-businesses";
import styles from "./SpecialBusinessesPage.module.css";
type EnrichedBusiness = MarketplaceBusiness & {
displayName: string;
subtext: string;
};
type CarouselFormMode = "create" | "edit";
async function enrichBusinesses(
items: MarketplaceBusiness[],
): Promise<EnrichedBusiness[]> {
return Promise.all(
items.map(async (item) => {
try {
const id = String(item.meshkeeBusinessId);
const [meshkee, profile] = await Promise.all([
getMeshkeeBusiness(id),
getBusinessProfile(id),
]);
return {
...item,
name: meshkee.name,
displayName:
profile.profile.nameEn ||
profile.profile.nameFa ||
meshkee.name ||
item.slug,
subtext: (profile.profile.title || "").trim(),
logoUrl: profile.profile.logoUrl ?? meshkee.logoUrl ?? undefined,
};
} catch {
return {
...item,
displayName: item.name || item.slug,
subtext: "",
};
}
}),
);
}
function BusinessIdentity({ item }: { item: EnrichedBusiness }) {
return (
<div className={styles.identity}>
{item.logoUrl ? (
<img className={styles.logo} src={item.logoUrl} alt="" height={44} />
) : (
<span className={styles.logoMark} aria-hidden>
{item.displayName.slice(0, 1).toUpperCase()}
</span>
)}
<div className={styles.listMain}>
<span className={styles.listName}>{item.displayName}</span>
{item.subtext ? (
<span className={styles.listSubtext}>{item.subtext}</span>
) : (
<span className={styles.listSlug}>/{item.slug}</span>
)}
</div>
</div>
);
}
export default function SpecialBusinessesPage() {
const [config, setConfig] = useState<SpecialBusinessesConfig | null>(null);
const [activeId, setActiveId] = useState("");
const [allBusinesses, setAllBusinesses] = useState<EnrichedBusiness[]>([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [formMode, setFormMode] = useState<CarouselFormMode | null>(null);
const [formName, setFormName] = useState("");
const [formSubtext, setFormSubtext] = useState("");
const [formError, setFormError] = useState("");
const formTitleId = useId();
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
try {
const [special, listings] = await Promise.all([
getSpecialBusinesses(),
listBusinesses({ status: "all", limit: 200 }),
]);
const enriched = await enrichBusinesses(listings.items);
if (cancelled) return;
setConfig(special);
setActiveId(special.carousels[0]?.id ?? "");
setAllBusinesses(enriched);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "Could not load special businesses.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const active = useMemo(
() => config?.carousels.find((item) => item.id === activeId) ?? null,
[config, activeId],
);
const byId = useMemo(() => {
const map = new Map<string, EnrichedBusiness>();
for (const item of allBusinesses) map.set(item.id, item);
return map;
}, [allBusinesses]);
const selectedBusinesses = useMemo(() => {
if (!active) return [];
return active.businessIds
.map((id) => byId.get(id))
.filter(Boolean) as EnrichedBusiness[];
}, [active, byId]);
const searchResults = useMemo(() => {
if (!active) return [];
const q = query.trim().toLowerCase();
if (q.length <= 2) return [];
const selected = new Set(active.businessIds);
return allBusinesses
.filter((item) => !selected.has(item.id))
.filter(
(item) =>
item.displayName.toLowerCase().includes(q) ||
item.subtext.toLowerCase().includes(q) ||
item.slug.toLowerCase().includes(q) ||
item.id.includes(q),
)
.slice(0, 12);
}, [active, allBusinesses, query]);
function updateCarousel(id: string, patch: Partial<SpecialCarousel>) {
setConfig((prev) => {
if (!prev) return prev;
return {
carousels: prev.carousels.map((item) =>
item.id === id ? { ...item, ...patch } : item,
),
};
});
setStatus("");
}
function openCreateModal() {
setFormMode("create");
setFormName("");
setFormSubtext("");
setFormError("");
}
function openEditModal() {
if (!active) return;
setFormMode("edit");
setFormName(active.title);
setFormSubtext(active.subtext || "");
setFormError("");
}
function closeFormModal() {
setFormMode(null);
setFormError("");
}
function submitFormModal() {
const title = formName.trim();
if (!title) {
setFormError("Name is required.");
return;
}
const subtext = formSubtext.trim();
if (formMode === "create") {
const next = newCarousel(title, subtext);
setConfig((prev) =>
prev
? { carousels: [...prev.carousels, next] }
: { carousels: [next] },
);
setActiveId(next.id);
setStatus("");
closeFormModal();
return;
}
if (formMode === "edit" && active) {
updateCarousel(active.id, {
title,
subtext,
slug: slugFromTitle(title),
});
closeFormModal();
}
}
function removeCarousel(id: string) {
setConfig((prev) => {
if (!prev || prev.carousels.length <= 1) return prev;
const carousels = prev.carousels.filter((item) => item.id !== id);
if (activeId === id) setActiveId(carousels[0]?.id ?? "");
return { carousels };
});
setStatus("");
closeFormModal();
}
function addBusiness(businessId: string) {
if (!active) return;
if (active.businessIds.includes(businessId)) return;
updateCarousel(active.id, {
businessIds: [...active.businessIds, businessId],
});
setQuery("");
}
function removeBusiness(businessId: string) {
if (!active) return;
updateCarousel(active.id, {
businessIds: active.businessIds.filter((id) => id !== businessId),
});
}
async function onSave() {
if (!config) return;
setSaving(true);
setStatus("");
setError("");
try {
const saved = await saveSpecialBusinesses(config);
setConfig(saved);
if (!saved.carousels.some((item) => item.id === activeId)) {
setActiveId(saved.carousels[0]?.id ?? "");
}
setStatus("Saved");
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save.");
} finally {
setSaving(false);
}
}
if (loading) {
return <div className={styles.loading}>Loading special businesses…</div>;
}
if (!config || !active) {
return (
<div className={styles.loading} role="alert">
{error || "Could not load special businesses."}
</div>
);
}
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>Carousels</h1>
<p className={styles.lead}>
Homepage carousels as tabs — each tab holds multiple businesses.
</p>
</div>
<div className={styles.headActions}>
{status ? (
<span className={`${styles.status} ${styles.statusOk}`}>{status}</span>
) : null}
{error ? (
<span className={`${styles.status} ${styles.statusError}`}>
{error}
</span>
) : null}
<button
type="button"
className={`outline-btn ${styles.addBtn}`}
onClick={openEditModal}
>
Edit
</button>
<button
type="button"
className={`outline-btn ${styles.addBtn}`}
onClick={openCreateModal}
>
Add carousel
</button>
<button
type="button"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
onClick={() => void onSave()}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</header>
<section className={styles.card}>
<div className={styles.tabs} role="tablist" aria-label="Carousels">
{config.carousels.map((carousel) => (
<button
key={carousel.id}
type="button"
role="tab"
aria-selected={carousel.id === activeId}
className={`${styles.tab} ${
carousel.id === activeId ? styles.tabActive : ""
}`}
onClick={() => setActiveId(carousel.id)}
>
{carousel.title || "Untitled"}
</button>
))}
</div>
<div className={styles.panel} role="tabpanel">
<div className={styles.activeMeta}>
<h2 className={styles.activeTitle}>{active.title}</h2>
{active.subtext ? (
<p className={styles.activeSubtext}>{active.subtext}</p>
) : null}
</div>
<div className={styles.searchRow}>
<label className={styles.field}>
<span>Search businesses</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by name or slug"
/>
</label>
</div>
{query.trim().length > 2 ? (
searchResults.length ? (
<div className={styles.results}>
{searchResults.map((item) => (
<div key={item.id} className={styles.resultRow}>
<BusinessIdentity item={item} />
<button
type="button"
className={`outline-btn ${styles.inlineAddBtn}`}
onClick={() => addBusiness(item.id)}
>
Add
</button>
</div>
))}
</div>
) : (
<div className={styles.empty}>No matching businesses to add.</div>
)
) : null}
<div className={styles.listHead}>
<h3 className={styles.listHeading}>
Businesses in this carousel
</h3>
<span className={styles.listCount}>
{selectedBusinesses.length}
</span>
</div>
<div className={styles.list}>
{selectedBusinesses.length ? (
selectedBusinesses.map((item) => (
<div key={item.id} className={styles.listItem}>
<BusinessIdentity item={item} />
<button
type="button"
className={styles.linkBtn}
onClick={() => removeBusiness(item.id)}
>
Remove
</button>
</div>
))
) : (
<div className={styles.empty}>
No businesses in this carousel yet. Search above to add some.
</div>
)}
</div>
</div>
</section>
{formMode ? (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeFormModal();
}}
>
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-labelledby={formTitleId}
>
<div className={styles.modalHead}>
<div>
<p className={styles.kicker}>Carousels</p>
<h2 id={formTitleId} className={styles.modalTitle}>
{formMode === "create" ? "Add carousel" : "Edit carousel"}
</h2>
</div>
<button
type="button"
className={styles.closeBtn}
aria-label="Close"
onClick={closeFormModal}
>
×
</button>
</div>
<form
className={styles.form}
onSubmit={(event) => {
event.preventDefault();
submitFormModal();
}}
>
<label className={styles.field}>
<span>Name</span>
<input
value={formName}
onChange={(event) => setFormName(event.target.value)}
placeholder="Featured producers"
autoFocus
/>
</label>
<label className={styles.field}>
<span>Subtext</span>
<input
value={formSubtext}
onChange={(event) => setFormSubtext(event.target.value)}
placeholder="Short supporting line"
/>
</label>
{formError ? (
<p className={styles.formError} role="alert">
{formError}
</p>
) : null}
<div className={styles.modalActions}>
{formMode === "edit" && config.carousels.length > 1 ? (
<button
type="button"
className={styles.dangerBtn}
onClick={() => removeCarousel(active.id)}
>
Remove carousel
</button>
) : (
<span />
)}
<div className={styles.modalActionsRight}>
<button
type="button"
className={`outline-btn ${styles.addBtn}`}
onClick={closeFormModal}
>
Cancel
</button>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
>
{formMode === "create" ? "Add" : "Update"}
</button>
</div>
</div>
</form>
</div>
</div>
) : null}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
.beside {
display: grid;
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
@media (max-width: 860px) {
.beside {
grid-template-columns: 1fr;
}
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
import {
FeaturedImageField,
TagsField,
type LocalImage,
} from "@/components/catalog/CatalogFormFields";
import RichTextArea from "@/components/RichTextArea";
import type { MeshkeeCategory } from "@/services/meshkee-categories";
import { uploadWebsiteMedia } from "@/services/website-media";
import {
createWebsiteBlogPost,
listWebsiteBlogCategories,
type WebsiteBlogCategory,
} from "@/services/website-blog";
import styles from "@/components/catalog/CatalogForm.module.css";
import local from "./WebsiteBlogForm.module.css";
export default function WebsiteBlogForm() {
const router = useRouter();
const [categories, setCategories] = useState<WebsiteBlogCategory[]>([]);
const [categoryId, setCategoryId] = useState("");
const [title, setTitle] = useState("");
const [abstract, setAbstract] = useState("");
const [mainTextHtml, setMainTextHtml] = useState("");
const [image, setImage] = useState<LocalImage | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const items = await listWebsiteBlogCategories();
if (!cancelled) setCategories(items);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
}
}
})();
return () => {
cancelled = true;
};
}, []);
async function onSubmit(event?: React.FormEvent) {
event?.preventDefault();
if (!title.trim()) {
setError("Enter a title.");
return;
}
setSaving(true);
setError("");
try {
if (!categoryId) {
setError("Choose a category.");
setSaving(false);
return;
}
let imageUrl: string | undefined;
if (image?.file) {
imageUrl = await uploadWebsiteMedia(image.file);
}
await createWebsiteBlogPost({
title: title.trim(),
categoryId,
abstract: abstract.trim() || undefined,
mainTextHtml: mainTextHtml || undefined,
imageUrl,
tags,
});
router.push("/website/blog");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not create post.");
} finally {
setSaving(false);
}
}
const busy = saving;
const treeCategories: MeshkeeCategory[] = categories.map((item) => ({
id: item.id,
parentId: item.parentId,
name: item.name,
nameFa: null,
slug: "",
description: null,
icon: null,
sortOrder: 0,
tags: [],
}));
return (
<form className={styles.page} onSubmit={onSubmit}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<Link href="/">Home</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<Link href="/website/blog">Blog</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Add</span>
</nav>
<h1 className={styles.title}>Add blog post</h1>
<p className={styles.lead}>
Publish a story on the IranBay website.
</p>
</div>
<div className={styles.headActions}>
<Link href="/website/blog" className={`outline-btn ${styles.ghostBtn}`}>
Back
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={busy}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</header>
{error ? (
<div className={styles.errorBox} role="alert">
{error}
</div>
) : null}
<div className={styles.layout}>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Post</h2>
<div className={local.beside}>
<FeaturedImageField
label="Image"
value={image}
aspect={3 / 2}
disabled={busy}
onChange={setImage}
/>
<div className={styles.stack}>
<CategoryTreeSelect
categories={treeCategories}
value={categoryId ? [categoryId] : []}
selection="single"
label="Category"
placeholder="Select category"
disabled={busy}
onChange={(ids) => setCategoryId(ids[0] ?? "")}
/>
<label className={styles.field}>
<span>Title</span>
<input
value={title}
disabled={busy}
required
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className={styles.field}>
<span>Abstract</span>
<textarea
rows={3}
value={abstract}
disabled={busy}
onChange={(event) => setAbstract(event.target.value)}
/>
</label>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Content</h2>
<div className={styles.stack}>
<div className={styles.richField}>
<RichTextArea
label="Main text"
value={mainTextHtml}
onChange={setMainTextHtml}
disabled={busy}
minHeight={280}
onUploadImage={uploadWebsiteMedia}
/>
</div>
<TagsField tags={tags} disabled={busy} onChange={setTags} />
</div>
</section>
<div className={styles.footerActions}>
<Link href="/website/blog" className={`outline-btn ${styles.ghostBtn}`}>
Cancel
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={busy}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</form>
);
}
+254
View File
@@ -0,0 +1,254 @@
.page {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.breadcrumb a {
color: inherit;
transition: color 0.2s ease;
}
.breadcrumb a:hover,
.breadcrumb a:focus-visible {
color: var(--color-black);
}
.breadcrumbSep {
opacity: 0.45;
}
.breadcrumbCurrent {
color: var(--color-muted);
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
}
.lead {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.headActions {
display: flex;
align-items: center;
gap: 16px;
}
.count {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-soft);
white-space: nowrap;
}
.addBtn {
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
.error {
margin: 0;
padding: 14px 16px;
border-radius: 5px;
background: #fff5f5;
color: #b42318;
font-size: 13px;
}
.hint {
margin: 0;
text-align: center;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-soft);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.card {
min-width: 0;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 5px;
overflow: hidden;
}
.media {
aspect-ratio: 3 / 2;
background: #f3f3f3;
overflow: hidden;
}
.media img,
.fallback {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fallback {
background: #ececec;
}
.foot {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
padding: 14px 16px 0;
}
.card:hover .foot:global(.tile-hover)::before,
.card:focus-within .foot:global(.tile-hover)::before {
clip-path: inset(0 0 0 0);
}
.body {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 14px;
}
.category {
margin: 0;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
}
.body h2 {
margin: 0;
font-size: 15px;
font-weight: 400;
letter-spacing: 0.02em;
line-height: 1.35;
}
.body p {
margin: 0;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
color: var(--color-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
margin-top: 4px;
font-size: 11px;
color: var(--color-soft);
}
.controls {
position: relative;
z-index: 2;
margin-top: auto;
padding: 12px 0 14px;
border-top: 1px solid #f0f0f0;
}
.remove {
appearance: none;
border: 0;
background: transparent;
padding: 0;
font: inherit;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-soft);
cursor: pointer;
}
.remove:hover,
.remove:focus-visible {
color: #b42318;
}
.card:hover .controls {
border-top-color: rgba(255, 255, 255, 0.2);
}
.card:hover .remove:hover,
.card:focus-within .remove:hover {
color: #ffb4a8;
}
@media (max-width: 1100px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.head {
flex-direction: column;
align-items: stretch;
padding: 22px 18px;
}
.headActions {
justify-content: space-between;
}
.grid {
grid-template-columns: 1fr;
}
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import { excerpt, formatDate } from "@/components/catalog/utils";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
deleteWebsiteBlogPost,
listWebsiteBlogPosts,
type WebsiteBlogPost,
} from "@/services/website-blog";
import styles from "./WebsiteBlogListPage.module.css";
export default function WebsiteBlogListPage() {
const [items, setItems] = useState<WebsiteBlogPost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [removing, setRemoving] = useState<WebsiteBlogPost | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
setError("");
try {
const data = await listWebsiteBlogPosts();
if (!cancelled) setItems(data.items);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load blog posts.",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteWebsiteBlogPost(removing.id);
setItems((prev) => prev.filter((item) => item.id !== removing.id));
setRemoving(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not remove blog post.",
);
setRemoving(null);
} finally {
setRemoveBusy(false);
}
}
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<Link href="/">Home</Link>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Blog</span>
</nav>
<h1 className={styles.title}>Blog</h1>
<p className={styles.lead}>
Stories published on the IranBay website.
</p>
</div>
<div className={styles.headActions}>
<p className={styles.count}>
{loading ? "Loading…" : `${items.length} posts`}
</p>
<Link href="/website/blog/new" className={`outline-btn ${styles.addBtn}`}>
Add
</Link>
</div>
</header>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
{loading && items.length === 0 ? (
<p className={styles.hint}>Loading blog…</p>
) : null}
{!loading && items.length === 0 ? (
<CatalogEmptyState
icon="/images/icons/gallery.svg"
title="No blog posts yet"
lead="Add the first story for the website."
actionHref="/website/blog/new"
actionLabel="Add"
/>
) : null}
{items.length ? (
<div className={styles.grid}>
{items.map((post) => {
const text = post.abstract ? excerpt(post.abstract, 160) : "";
return (
<article key={post.id} className={styles.card}>
<div className={styles.media}>
{post.imageUrl ? (
<img src={post.imageUrl} alt="" />
) : (
<div className={styles.fallback} aria-hidden />
)}
</div>
<div className={`tile-hover ${styles.foot}`}>
<div className={styles.body}>
<p className={styles.category}>{post.categoryName}</p>
<h2 dir="auto">{post.title}</h2>
{text ? <p dir="auto">{text}</p> : null}
{post.createdAt ? (
<span className={styles.meta}>
{formatDate(post.createdAt)}
</span>
) : null}
</div>
<div className={styles.controls}>
<button
type="button"
className={styles.remove}
onClick={() => setRemoving(post)}
>
Remove
</button>
</div>
</div>
</article>
);
})}
</div>
) : null}
<ConfirmDialog
open={Boolean(removing)}
danger
busy={removeBusy}
kicker="Blog"
title="Remove post"
message="This deletes the blog post. This cannot be undone."
confirmLabel="Remove"
onConfirm={() => void confirmRemove()}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
/>
</div>
);
}

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