commit 42aff5aee9e574504816f2e646afc715b29ab4b2 Author: Alireza Hassani Date: Sun Oct 4 13:21:51 2026 +0330 Update carousels, card hover, and IranBay branding. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a73d377 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +NEXT_PUBLIC_DASHBOARD_URL=http://localhost:3002 +NEXT_PUBLIC_ADMIN_DASHBOARD_URL=http://localhost:3002 +NEXT_PUBLIC_BUSINESS_DASHBOARD_URL=http://business.localhost:3002 +NEXT_PUBLIC_WEBSITE_URL=http://localhost:3001 +NEXT_PUBLIC_CUSTOMER_DASHBOARD_URL=http://customer.localhost:3002 +NEXT_PUBLIC_ROOT_DOMAIN=localhost +# Meshkee — always remote (do not point at a local Meshkee) +NEXT_PUBLIC_MESHKEE_API_URL=https://api.meshkee.com/api/v1 + +# Meshkee (used by /api/meshkee/categories) +MESHKEE_API_URL=https://api.meshkee.com/api/v1 +# Prefer cell + password (access tokens expire quickly). Optional static token: +# MESHKEE_API_TOKEN= +# MESHKEE_SUPERADMIN_CELL= +# MESHKEE_SUPERADMIN_PASSWORD= +NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN=iranbay.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b8da95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..38446c7 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# IranBay Website + +Public Next.js site. From this folder: + +```bash +cp .env.example .env.local +npm install +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +Account login is on the dashboard app (`NEXT_PUBLIC_DASHBOARD_URL`, default `http://localhost:3002`). Business registration stays at `/register`. diff --git a/app/about/about.module.css b/app/about/about.module.css new file mode 100644 index 0000000..9b25a20 --- /dev/null +++ b/app/about/about.module.css @@ -0,0 +1,369 @@ +.page { + background: var(--color-white); +} + +.container { + max-width: var(--container-width); + margin: 0 auto; + padding: 0 var(--page-pad); +} + +.wide { + max-width: 1512px; + margin: 0 auto; + padding: 0 var(--page-pad); +} + +.intro { + display: grid; + grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.15fr); + gap: 48px; + align-items: center; + padding: 56px 0 72px; +} + +.visual { + position: relative; + width: min(100%, 380px); + aspect-ratio: 466 / 634; + margin-inline: auto 0; +} + +.visualBack { + position: absolute; + inset: 0 18% 12% 0; + background: var(--color-card); + border-radius: 32px; +} + +.visualCard { + position: absolute; + left: 14%; + top: 12%; + width: 86%; + height: 88%; + border-radius: 32px; + object-fit: cover; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.08); +} + +.kicker { + font-size: clamp(22px, 2.2vw, 35px); + font-weight: 300; + color: #434343; + margin-bottom: 8px; +} + +.title { + font-size: clamp(34px, 3.4vw, 55px); + font-weight: 700; + line-height: 1.1; + margin-bottom: 28px; +} + +.title span { + display: block; +} + +.copy { + font-size: 16px; + font-weight: 400; + line-height: 1.45; + color: #bababa; + margin-bottom: 18px; + max-width: 650px; + text-align: justify; +} + +.cta { + margin-top: 18px; + height: 34px; + padding: 0 16px; + min-width: 207px; + font-size: 12px; + letter-spacing: 2.4px; +} + +.clients { + padding: 24px 0 56px; +} + +.clientBar { + margin-top: 36px; + background: #f8f8f8; + border-radius: 5px; + min-height: 160px; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: center; + gap: 24px; + padding: 24px 40px; +} + +.clientLogo { + width: 100%; + max-height: 96px; + object-fit: contain; + margin: 0 auto; +} + +.story { + display: grid; + grid-template-columns: minmax(240px, 0.85fr) minmax(0, 1.15fr); + gap: 40px; + padding: 40px 0 72px; + align-items: start; +} + +.story .cta { + margin-top: 24px; +} + +.comments { + padding: 24px 0 72px; + text-align: center; +} + +.commentHead { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 16px; + margin-bottom: 8px; +} + +.commentHead .sectionHead { + margin: 0; +} + +.arrows { + display: flex; + gap: 6px; +} + +.arrowBtn { + width: 38px; + height: 36px; + background: var(--color-card); + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; +} + +.arrowBtn img { + width: 8px; + height: 14px; + object-fit: contain; +} + +.arrowPrev { + transform: rotate(180deg); +} + +.commentCount { + font-size: 13px; + letter-spacing: 1px; + color: var(--color-soft); + min-width: 48px; + text-align: right; +} + +.commentTrack { + animation: commentIn 0.35s ease; +} + +@keyframes commentIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.quote { + max-width: 900px; + margin: 28px auto 18px; + font-size: 18px; + line-height: 1.45; + color: #bababa; +} + +.meta { + font-size: 18px; + color: #5f5f5f; +} + +.dots { + margin-top: 28px; + display: flex; + justify-content: center; + gap: 8px; +} + +.dot, +.dotActive { + width: 8px; + height: 8px; + border-radius: 50%; + background: #d8d8d8; + padding: 0; +} + +.dotActive { + background: var(--color-black); +} + +.services { + padding: 24px 0 96px; +} + +.serviceGrid { + margin-top: 48px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 28px; + align-items: end; +} + +.serviceCard { + position: relative; + min-height: 190px; + display: grid; + grid-template-columns: auto 1fr; + align-items: end; + gap: 8px; + padding: 24px 16px 18px 0; +} + +.serviceCard::before { + content: ""; + position: absolute; + left: 52px; + right: 0; + bottom: 18px; + height: 135px; + background: #f8f8f8; + border-radius: 25px; + z-index: 0; +} + +.serviceCard img { + position: relative; + z-index: 1; + display: block; + width: auto; + height: 160px; + max-width: 150px; + object-fit: contain; + margin-left: 0; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.12)); +} + +.serviceCopy { + position: relative; + z-index: 1; + align-self: center; + padding: 8px 12px 8px 4px; + text-align: left; +} + +.serviceCopy h3 { + font-size: clamp(20px, 1.8vw, 30px); + font-weight: 400; + letter-spacing: 1px; + text-transform: uppercase; + line-height: 1.1; +} + +.serviceCopy p { + margin-top: 6px; + font-size: 11px; + font-weight: 300; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--color-black); +} + +.support .serviceCopy h3 { + color: #575f50; +} + +.register .serviceCopy h3 { + color: #b68968; +} + +.responsive .serviceCopy h3 { + color: #474039; +} + +.sectionHead { + text-align: center; +} + +.sectionTitle { + font-size: clamp(24px, 2.2vw, 35px); + font-weight: 400; + letter-spacing: 14px; + text-transform: uppercase; +} + +.sectionSub { + margin-top: 8px; + font-size: 12px; + font-weight: 300; + letter-spacing: 4.02px; + text-transform: uppercase; + color: var(--color-soft); +} + +@media (max-width: 980px) { + .intro, + .story { + grid-template-columns: 1fr; + } + + .visual { + margin: 0 auto; + } + + .commentHead { + grid-template-columns: 1fr; + justify-items: center; + gap: 14px; + } + + .commentCount { + text-align: center; + } + + .serviceGrid { + grid-template-columns: 1fr; + max-width: 420px; + margin-left: auto; + margin-right: auto; + } + + .clientBar { + grid-template-columns: repeat(3, 1fr); + min-height: auto; + } +} + +@media (max-width: 720px) { + .clientBar { + grid-template-columns: repeat(2, 1fr); + padding: 20px; + } + + .sectionTitle { + letter-spacing: 6px; + } + + .serviceCopy { + padding-left: 4px; + } +} diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..8ff689e --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,117 @@ +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import CommentCarousel from "@/components/CommentCarousel"; +import { aboutCopy, clients, services } from "@/data/pages"; +import styles from "./about.module.css"; + +export const metadata = { + title: "About Us | IranBay", +}; + +export default function AboutPage() { + return ( +
+
+
+
+
+
+
+ IranBay +
+
+

{aboutCopy.kicker}

+

+ {aboutCopy.title.map((line) => ( + {line} + ))} +

+ {aboutCopy.paragraphs.map((p) => ( +

+ {p} +

+ ))} + + Contact us + +
+
+
+ +
+
+
+

All clinets

+

+ some of our most crowded categories +

+
+
+ {clients.map((client) => ( + {client.name} + ))} +
+
+
+ +
+
+
+

{aboutCopy.kicker}

+

+ {aboutCopy.title.map((line) => ( + {line} + ))} +

+
+
+

{aboutCopy.paragraphs[0]}

+ + Contact us + +
+
+
+ +
+ +
+ +
+
+
+

Our services

+

+ some of our most crowded categories +

+
+
+ {services.map((item) => ( +
+ +
+

{item.title}

+

{item.subtitle}

+
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/app/api/meshkee/categories/route.ts b/app/api/meshkee/categories/route.ts new file mode 100644 index 0000000..1558460 --- /dev/null +++ b/app/api/meshkee/categories/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; + +const MESHKEE_API_URL = + process.env.MESHKEE_API_URL?.replace(/\/$/, "") || + process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") || + "https://api.meshkee.com/api/v1"; + +type CachedToken = { token: string; expiresAt: number }; + +let cachedToken: CachedToken | null = null; + +function tokenExpiry(token: string) { + try { + const payload = JSON.parse( + Buffer.from(token.split(".")[1] ?? "", "base64url").toString(), + ) as { exp?: number }; + if (typeof payload.exp === "number") return payload.exp * 1000; + } catch { + /* ignore malformed tokens */ + } + return 0; +} + +function usableStaticToken() { + const token = process.env.MESHKEE_API_TOKEN?.trim(); + if (!token) return null; + const expiresAt = tokenExpiry(token); + if (expiresAt && expiresAt < Date.now() + 15_000) return null; + return token; +} + +async function serviceToken(): Promise { + const now = Date.now(); + if (cachedToken && cachedToken.expiresAt > now + 15_000) { + return cachedToken.token; + } + + const staticToken = usableStaticToken(); + if (staticToken) return staticToken; + + const cellNumber = process.env.MESHKEE_SUPERADMIN_CELL?.trim(); + const password = process.env.MESHKEE_SUPERADMIN_PASSWORD?.trim(); + if (!cellNumber || !password) return null; + + const response = await fetch(`${MESHKEE_API_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cellNumber, password }), + cache: "no-store", + }); + if (!response.ok) return null; + + const payload = (await response.json().catch(() => null)) as { + accessToken?: string; + } | null; + const token = payload?.accessToken?.trim(); + if (!token) return null; + + cachedToken = { + token, + expiresAt: tokenExpiry(token) || now + 10 * 60 * 1000, + }; + return token; +} + +async function authHeader(request: NextRequest): Promise { + const incoming = request.headers.get("authorization")?.trim(); + if (incoming) return incoming; + + const token = await serviceToken(); + return token ? `Bearer ${token}` : null; +} + +export async function GET(request: NextRequest) { + const auth = await authHeader(request); + if (!auth) { + return NextResponse.json( + { + message: + "Meshkee categories need auth. Set MESHKEE_API_TOKEN or sign in on the dashboard first.", + }, + { status: 503 }, + ); + } + + const q = request.nextUrl.searchParams.get("q")?.trim() ?? ""; + const params = new URLSearchParams(); + if (q) params.set("q", q); + + const response = await fetch( + `${MESHKEE_API_URL}/business-categories?${params}`, + { + headers: { Authorization: auth }, + cache: "no-store", + }, + ); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + return NextResponse.json( + payload ?? { message: `Meshkee error (${response.status})` }, + { status: response.status }, + ); + } + + return NextResponse.json(payload); +} diff --git a/app/auth/handoff/layout.tsx b/app/auth/handoff/layout.tsx new file mode 100644 index 0000000..14d7c04 --- /dev/null +++ b/app/auth/handoff/layout.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +/** + * Install Meshkee session from the URL hash before the register page reads tokens. + * Used after customer-dashboard login with ?next=https://…/register + */ +const HANDOFF_BOOT = ` +(function () { + try { + var raw = location.hash ? location.hash.replace(/^#/, "") : ""; + if (!raw) return; + var p = new URLSearchParams(raw); + var access = p.get("access"); + var refresh = p.get("refresh"); + var next = p.get("next") || "/register"; + if (!access || !refresh) return; + + localStorage.setItem("marketplace_access_token", access); + localStorage.setItem("marketplace_refresh_token", refresh); + + var root = "localhost"; + try { + document.cookie = + "marketplace_access_token=" + + encodeURIComponent(access) + + "; Path=/; Max-Age=1209600; SameSite=Lax; Domain=" + + root; + document.cookie = + "marketplace_refresh_token=" + + encodeURIComponent(refresh) + + "; Path=/; Max-Age=1209600; SameSite=Lax; Domain=" + + root; + } catch (e) {} + + var target = next.charAt(0) === "/" ? next : "/" + next; + location.replace(target); + } catch (e) {} +})(); +`; + +export default function AuthHandoffLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + <> +