Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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 |
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function BusinessIndexPage() {
|
||||
redirect("/profile");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function WebsiteImagesRedirect() {
|
||||
redirect("/website/images");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SpecialBusinessesRedirect() {
|
||||
redirect("/website/carousels");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}/`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user