Initial commit: Ali Alemi storefront (Next.js, Meshkee API integration)
Farsi RTL Next.js 16 storefront for استاد علی عالمی, cloned from the original Meshkee web-builder site and rebuilt with Tailwind 4, live Meshkee API integration (products, store items, portfolios, blog, workshops, comments, cart, customer auth), and standalone production output for the shared-VM deploy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ali-alemi",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.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,147 @@
|
||||
# Ali Alemi Website Context
|
||||
|
||||
> Keep this file current. Whenever a prompt changes scope, content, domain, or conventions, update the relevant section below in the same turn.
|
||||
|
||||
## Project
|
||||
|
||||
- Farsi RTL storefront for استاد علی عالمی (private math/calculus tutor for the Iranian کنکور).
|
||||
- Next.js 16 App Router, React 19, TypeScript, Tailwind 4 (`@theme inline`, no `tailwind.config.js`), `src/app`.
|
||||
- `lang="fa" dir="rtl"` set in `src/app/layout.tsx`.
|
||||
- Apex domain: **`alialemi.com`** (`src/lib/site.ts` → `SITE_DOMAIN`) — confirmed by the user. `SITE_URL`, `CUSTOMER_URL` (`customer.alialemi.com`), `BUSINESS_URL` (`business.alialemi.com`), the sitemap-config fallback, and metadata all derive from it.
|
||||
- The live Meshkee tenant for this domain does not exist yet on `api.meshkee.com` (every API call currently 404s) — every fetch on the site is wrapped in `safe()` (`src/lib/meshkee/client.ts`) so pages render a friendly empty state instead of crashing. This is expected and will resolve itself once the business is provisioned on the backend; no code change needed then.
|
||||
- Carousels use **Swiper** (`swiper/react`, `swiper/modules`) per explicit instruction — see `src/components/home/Testimonials.tsx` and `src/components/home/FeatureCards.tsx`.
|
||||
|
||||
## Homepage layout fidelity
|
||||
|
||||
The user asked for the homepage's structure/layout to closely match the live original (`http://ali-alemi.local:8000`, a local Meshkee web-builder preview blocked by their VPN from this environment — they instead pasted screenshots directly). Corrections made after comparing against those screenshots:
|
||||
|
||||
- **Header**: solid `bg-secondary` (navy) bar, not white/blurred. Logo sits first in DOM (renders on the **right** in RTL — do not add `order-*` overrides here again, that was tried and was wrong), nav links, then phone/login actions, matching the real desktop header exactly.
|
||||
- **Hero** (`HeroSection.tsx`): character + a large dotted ring backdrop (`public/images/hero/ring.png` — the accent dots are baked into that asset, don't draw separate circle divs), a second smaller student avatar peeking from behind the character, floating grad-cap/paper-plane/shape decorations. No CTA buttons in the hero — the original doesn't have any there. Two-column grid switches at **`md:`** (768px), not `lg:` — at `lg:` the in-between tablet/small-laptop range stacked to a single column and looked broken (large blank gap), since the image side has fixed-height containers.
|
||||
- **Feature cards** (`FeatureCards.tsx`): a **Swiper carousel** (prev/next arrow buttons, not pagination dots) in a two-column row — cards on the left (wide column), a "ویژگیهای ما" heading + short paragraph + the arrow buttons in a narrow **280px right-hand column**. This grid must be `md:grid-cols-[280px_minmax(0,1fr)]` — **not** `[280px_1fr]`: a bare `1fr` track let Swiper's internal width calculation blow the column out to ~16.7M px (classic CSS Grid blowout; Swiper needs `minmax(0,1fr)` to be constrained by its parent). The Swiper's own grid-item wrapper also needs `min-w-0` directly (see the `Reveal` around it) — that's the part that matters **below** `md`, where there's no explicit `grid-template-columns` at all but the item is still a grid child with `min-width: auto` by default, so the same blowout happened on mobile too until that class was added. Any future Swiper placed inside a `grid` or `flex` container needs this same `min-w-0` treatment regardless of breakpoint. Also must NOT have negative top margin overlapping the hero — that was tried first and the user flagged it as a bug ("باید با هیرو فاصله داشته باشه"), plain `py-16 md:py-24` is correct.
|
||||
- **Quick-links row** ("چرا استاد علی عالمی؟", `QuickLinks.tsx`): real icon assets (`icons/play.png`, `exam.png`, `price.png`, `support.png`) in `sm:grid-cols-2 lg:grid-cols-4` — 4-in-a-row on real desktop widths, 2×2 on tablet. Ends with a "درباره ما بیشتر بدانید" CTA linking `/about-us` (that's the original frame's own link field, not invented).
|
||||
- **"چرا مارا انتخاب کنید؟"** (`WhyChooseUs.tsx`): image on the **left**, text+CTA on the **right** (DOM order text-first renders right in RTL grid). Stat badges (آموزش با کیفیت / نظارت دقیق و ارزیابی عملکرد / بیش از ۱۵۰ ساعت آموزش / ۱۴,۵۰۰ نفر دانشجو+avatars) are **floating cards scattered over the image's corners** (absolute-positioned with physical `left-*`/`right-*`, not logical `start`/`end` — this composition doesn't flip with page direction), not a plain bulleted list in the text column.
|
||||
- **Testimonials**: multi-per-view on desktop (`breakpoints: {768:2, 1024:3}`), pagination dots (not arrows) — opposite of the feature carousel.
|
||||
- **Stats bar** (`StatsBar.tsx`): light/white background with **teal** numbers — not a navy full-bleed banner (that was tried first and was wrong).
|
||||
- Scroll-reveal entrance animation on every section via `src/components/Reveal.tsx` (fade + translateY on `IntersectionObserver`, stands in for the original's WOW.js + animate.css) — requested explicitly ("انیمیشنها ... باید دقیقا شبیه سایت اصلی باشه").
|
||||
- **General lesson**: any two-column `image + text` grid using a fixed-height image container should switch at `md:` not `lg:`, or verify the `lg:` in-between range doesn't leave a tall single-column stack. Applies to `WhyChooseUs.tsx`, `about/BioIntro.tsx`, `contact/ContactInfo.tsx` (already fixed) — check any *new* two-column section against this before shipping.
|
||||
|
||||
## Scope (current)
|
||||
|
||||
Static pages: `/` (home), `/about-us`, `/contact-us` — content from `old-site` (see "Source clone" below).
|
||||
|
||||
Live-API-backed catalog pages (built against `api.meshkee.com`, real tenant not provisioned yet so every list/detail page currently shows its empty state — see "Meshkee API" below):
|
||||
|
||||
| Module | List | Detail | Notes |
|
||||
|---|---|---|---|
|
||||
| Products | `/products` | `/products/{id}/{nameFaSlug}` | description / technical-data / comments tabs, "محصولات مشابه" row. No cart button (informational catalog). |
|
||||
| Store items | `/store` | `/store/{id}/{nameFaSlug}` | same tabs **plus** add-to-cart with quantity. `{id}` is the **product id**, not a variant id — see "Store items architecture" below. |
|
||||
| Product category | — | `/products/category/{id}/{nameFaSlug}` | lists products in that category. |
|
||||
| Workshops | `/workshops` | `/workshops/{slug}` | no confirmed backend endpoint yet — see "Workshops" below. |
|
||||
| Portfolios | `/portfolios` | `/portfolios/{id}/{titleFaSlug}` | lightbox gallery, "نمونه کارهای مشابه" row. |
|
||||
| Blog | `/blog` | `/blog/{id}/{titleSlug}` | cover image, HTML content, comments. |
|
||||
|
||||
There is **no** `درمانگاران` page — it was in an earlier draft of the brief and the user corrected that mid-session.
|
||||
|
||||
## Source clone
|
||||
|
||||
- Original site: a Meshkee web-builder (Blade/PHP) export that was temporarily copied into `old-site/` to port content/assets from, then **deleted per explicit instruction** once the rebuild below was complete and verified against it — it no longer exists in this repo or on disk. Everything it contained is now captured either as real content in the pages/components listed below or as extracted files under `public/`; there is no separate archive to fall back on, so treat this file's notes as the record of what came from where. (Its original structure, for history: `old-site/custom/fa/pages/*/main.php` listed each page's frame stack; `old-site/custom/fa/frames/*.php` and `old-site/frames/**/content.blade.php` held the actual field data and markup per frame.)
|
||||
- `old-site/settings.php` gave the brand palette (`primaryColor #008081`, `secondaryColor #324264`, `tertiaryColor #E7E7E7`) and default font (`iran-yekan`) — both carried over as Tailwind tokens (`src/app/globals.css`) and the self-hosted font (`src/app/fonts.ts`).
|
||||
- **All page copy is the real content from `old-site`**, including the lorem-ipsum placeholder paragraphs that were literally in the source (per instruction: "all data is valid"). One exception: the About page FAQ (`old-site/custom/fa/frames/public-faq-yOxMt.php`) contained a broken, irrelevant template Q&A ("چگونه میتوانم محصول شما را با PayPal ادغام کنم؟" on a math-tutor site) — that was replaced with generic, non-fabricated tutoring FAQs (`src/components/about/Faq.tsx`) rather than keeping obviously mismatched boilerplate.
|
||||
- **Real, non-lorem content that was found and is now used prominently:**
|
||||
- Bio/credentials (`old-site` footer frame `footer-footer-hCR1y.php`, field `text`): born 1366/12/22, mechanical engineering (Iran University of Science & Technology), aerospace M.Sc. (same university, aerospace structures), rank 243 in the national Konkur, 13 years teaching math/calculus, worked with top Konkur academies. Used in the footer bio list **and** as the About page's `Resume` section (`src/components/about/Resume.tsx`) — this is the standout real content on the site, feature it, don't bury it.
|
||||
- Contact info (`public-info_1-ZWXoP.php`): address "قم، میدان مفید، ساختمان پاسارگاد، طبقه سوم", phone `0912 607 3354`, Instagram `@Alialemiteacher`. Lives in `src/lib/site.ts` → `CONTACT`.
|
||||
|
||||
### Asset provenance (`public/images`, `public/fonts`, `public/brand`)
|
||||
|
||||
- `public/fonts/iranyekanweb{light,bold}fanum.woff` — copied from the sibling Meshkee project `../moallem/public/fonts` (same font, already cleared for use across the Meshkee site fleet). `cdn.meshkee.com` (the original font CDN) was unreachable from this environment (TLS handshake blocked) — if that changes and more weights are wanted, fetch `https://cdn.meshkee.com/web-builder/fonts/iran-yekan/iran-yekan.css`.
|
||||
- `public/images/logo.png` ← `old-site/assets/files/E9jXiAxciH.png` (teal "ALI ALEMI" wordmark; footer's `mk7iCwJnYx.png` is byte-identical). There's no separate white variant, so `Header.tsx` applies `brightness-0 invert` to render it white on the navy bar; `Footer.tsx` uses the original teal color on its white background.
|
||||
- `public/favicon.png` / `public/images/favicon-mark.png` ← `old-site/assets/files/9B5ryAVpXy.png` (the infinity mark from the logo).
|
||||
- `public/images/hero/character.png` ← `XrfpuKYbWv.png` — the 3D professor illustration (used in the hero, `WhyChooseUs`, and About `BioIntro`).
|
||||
- `public/images/hero/{ring,sketch,grad-cap,paper-plane,shape-1..5,avatar-1,avatar-2}.png` ← decorative floaters from the same slider frame (`RHLYjNnYj6`, `JtkpbKjrLA`, `8d3ZPngaUQ`, `jWgzrcRljG`, `QpKmZ80xX1`, `hiDCqXhOI6`, `f8rjoyAIi9`, `wYNzYMZHz2`, `H11Q6Ni7hV`, `QeqN7XLmJY`, `yVeuSypizt`).
|
||||
- `public/images/icons/{play,exam,price,support}.png` ← `YYrcDvSrtC`, `JGmzMzVo2I`, `wEkbnOuKHg`, `7HZemBIE7S` — the real gradient icons for the "چرا استاد علی عالمی؟" quick-links row.
|
||||
- `public/images/icons/quote.png` ← `j6FWn5p4aw.png`; `public/images/testimonial/avatar.png` ← `IRj6Zp6N2J.png` (same avatar the original reused across all three testimonial cards — that's the real data, not a shortcut).
|
||||
- `public/images/contact-photo.jpg` ← `old-site/assets/img/contact/01.jpg` (generic stock photo bundled with the original theme).
|
||||
- `public/brand/meshkee-logo-white.png` — downloaded directly from `https://safeteb.com/images/footer/MeshkeeLogo-White.png` (see Footer credit below).
|
||||
- **Substituted with custom inline SVG icons (`src/components/icons.tsx`) instead of a missing asset:** the four "ویژگیهای ما" feature-card icons, the three small badge icons (pencil/search/check), contact icons (location/phone/instagram/whatsapp/telegram), and the About-page Resume icons. Their source files (e.g. `files/vLZSFuYnkv.png`, `files/otHFQ7PVUQ.jpg`, `files/IyvhvDu45j.jpg`, `files/TXzEEuVLAR.png`, `files/6aCyDwfHgF.png`, `files/6r1iyxcLfi.png`) were referenced in the Blade config but **not present** in `old-site/assets/files` (never actually uploaded on the live site) — rather than inventing stock photography, those slots became flat gradient backgrounds (`StatsBar`, `PageHeader`, `CustomerClubBanner` all use a teal→navy gradient + dotted pattern instead of a missing photo) or the custom icon set.
|
||||
|
||||
## Footer Meshkee credit
|
||||
|
||||
Matches `https://safeteb.com/` exactly, per instruction — markup, hover-frame animation, and CSS copied over in `src/components/Footer.tsx` / `src/app/globals.css` (`.meshkee-credit*`, `.footer-bottom*` classes). This is the same pattern already used on the sibling `khiyal-studio` project; keep the three in sync if the reference site's credit design ever changes.
|
||||
|
||||
## Meshkee API layer (`src/lib/meshkee/`)
|
||||
|
||||
- `client.ts` — `meshkeeFetch`/`tenantFetch` (adds `/tenants/{SITE_DOMAIN}` prefix) wrap `fetch` with Next.js data-cache `revalidate` (default 60s; auth/mutating calls pass `revalidate: false`). `safe(promise, fallback)` swallows any error/404 and returns the fallback — **every** list/detail fetch on a page goes through `safe()` so a missing tenant or disabled module renders an empty state, never a crash. `MeshkeeApiError` carries the HTTP status if a caller needs to branch on it (nothing does yet).
|
||||
- `types.ts` — deliberately loose interfaces. **The public OpenAPI/Postman docs document endpoint shapes but not exact field names for `product`, `store-item`/variant, `category`, or `comment` objects** (confirmed by querying both docs directly this session). Fields like `nameFa`/`name`/`title`, `titleImageUrl`/`imageUrl`, `discountedPrice`/`price` are best-effort guesses at Meshkee's conventions (matching what other modules — blogs, portfolios, user-products — *do* document). **Once a real tenant exists, fetch one live response per entity and reconcile `types.ts` + the `pickText()`/`normalize*()` accessor chains in `format.ts` against the actual field names** — don't assume the current guesses are correct.
|
||||
- `format.ts` — `pickText(...)` tries field-name candidates in order; `normalizeImage`/`normalizeGallery` handle both bare URL strings and `{url}` objects (unconfirmed which shape the API actually returns); `slugify()` builds canonical-URL slugs (Farsi letters/numbers kept, spaces → `-`, other punctuation stripped) — **always build links with `slugify(name)`, never a raw `item.slug` field** (the legacy DB slug must not appear in public URLs, per instruction); `formatToman`, `formatDate`.
|
||||
- One file per module: `tenant.ts`, `products.ts`, `store-items.ts`, `portfolios.ts`, `blogs.ts`, `workshops.ts`, `categories.ts`, `comments.ts`, `contact.ts`.
|
||||
|
||||
## Canonical URLs
|
||||
|
||||
Per the current brief (this supersedes anything the earlier AI_PROMPT.md fetch said about slug-only blog/portfolio routes):
|
||||
|
||||
- Product: `/products/{id}/{nameFaSlug}` → resolve via `GET /tenants/{domain}/products/by-id/{id}`.
|
||||
- Product category: `/products/category/{id}/{nameFaSlug}` → `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
|
||||
- Blog: `/blog/{id}/{titleSlug}` → `GET /tenants/{domain}/blogs/by-id/{id}`.
|
||||
- Portfolio: `/portfolios/{id}/{titleFaSlug}` → `GET /tenants/{domain}/portfolios/by-id/{id}`.
|
||||
- Store item: `/store/{id}/{nameFaSlug}` — **not** an official Meshkee canonical path (none exists for store-items in the docs); chosen to match the product pattern. `{id}` is the product id (see "Store items architecture").
|
||||
- Workshop: `/workshops/{slug}` — no `by-id` resolve exists yet (no endpoint at all — see "Workshops"); kept slug-only since there's nothing to redirect against.
|
||||
|
||||
Every `{id}/{slug}` detail page fetches by `id`, computes `slugify(name)`, and `redirect()`s to the canonical slug if the URL's slug segment doesn't match (same pattern on all four: products, store, portfolios, blog).
|
||||
|
||||
## Store items architecture (read before touching `/store`)
|
||||
|
||||
Meshkee's own data model treats `store-items` as **the sellable variant layer under a product**, not an independent content catalog — `GET /store-items/by-product/{productId}` is the only store-item lookup AI_PROMPT.md itself documents, specifically for the mini-cart flow. There is no dedicated "rich content" (gallery/description/technical-info) on a variant; that lives on the **product**.
|
||||
|
||||
So `/store/{id}/{slug}` resolves `{id}` as a **product id**: it fetches the product (`getProductById`, for gallery/description/technical-info) **and** `getStoreItemByProduct(id)` (for sellable variants/pricing) in parallel, then renders one page combining both. `/store` (the list) calls `GET /store-items` directly and reads `item.product` for display fields — **this nested `product` field on a store-item list response is unconfirmed** (see types.ts note above); verify against a live tenant.
|
||||
|
||||
## Workshops — no confirmed backend endpoint
|
||||
|
||||
Checked both `openapi.json` and the Postman collection directly this session: neither documents any workshops CRUD route — only a `/tenants/{domain}/sitemap-workshops.xml` placeholder exists (tag: SEO). `src/lib/meshkee/workshops.ts` still implements `getWorkshops`/`getWorkshopBySlug`/`getWorkshopById` following Meshkee's uniform module shape (same as products/portfolios/blogs) so it's a drop-in once the backend exposes it; until then every call 404s and `safe()` renders the "دورهای برای ثبتنام باز نیست" empty state — this is expected, not a bug.
|
||||
|
||||
**"Registration row"**: a workshop's `productId` (falls back to the workshop's own `id`) is looked up via `getStoreItemByProduct()`, and if that returns in-stock variants, the *same* `AddToCartBar` used on `/store` renders as the registration control ("ثبتنام" = adding the workshop's product variant to the cart, then continuing to the customer-dashboard checkout — there's no separate "enrollment" API). If no store item exists for it, a "برای ثبتنام تماس بگیرید" + phone-number fallback renders instead.
|
||||
|
||||
## Auth (customer session)
|
||||
|
||||
- `src/lib/auth.ts` — server-only (`next/headers` `cookies()`). Reads `meshkee_customer_access_token`, including the **chunked** form (`{name}_n` count + `{name}_0`, `{name}_1`, ...) per AI_PROMPT.md's cookie contract. `getSession()` calls `GET /auth/me` with that token and returns `{ user, token } | null`; called once in `src/app/layout.tsx` (root layout is now `async`) and passed down as props — **no client component calls `/auth/me` itself**.
|
||||
- `isAdminUser()` is a **best-effort** check (`user.isAdmin || user.role === "admin" || user.role === "owner" || user.roles?.includes("admin") || user.businessRoles?.length`) — the real shape of the `user` object returned by `/auth/me` isn't documented publicly. Verify against a live logged-in session and trim to whichever field actually indicates admin/owner.
|
||||
- `src/components/UserMenu.tsx`: logged-out → "ورود / ثبتنام" linking to `customerLoginUrl()`. Logged-in → `"{firstName} عزیز"` with a dropdown: `پروفایل من` (`customer.{domain}/profile`), `مدیریت وبسایت` (`business.{domain}`, **only when `isAdmin`**), `خروج` (`customer.{domain}/logout`). All three are plain cross-origin `<a>` tags (not `next/link`) — deliberately, they leave the site.
|
||||
- No local login/register/OTP UI anywhere, per the hard rule — everything auth-related redirects to `customer.{domain}`.
|
||||
|
||||
## Cart (guest cart, local-only)
|
||||
|
||||
- `src/lib/cart.ts` implements AI_PROMPT.md's guest-cart spec **exactly**: `localStorage` key `meshkee-guest-cart`, mirrored to a cookie (`Domain=.{SITE_DOMAIN}`, `Path=/`, `SameSite=Lax`, ~30 days, skipped if the encoded JSON exceeds ~3500 chars), `encodeGuestCart()` is the identical base64url encoder the customer dashboard uses. `CartLine.id` is **always** a `storeItemVariantId` — never a product id or store-item id.
|
||||
- `src/components/providers/CartProvider.tsx` (client context, wraps the app in `layout.tsx`) holds cart state; reads `localStorage` in a `useEffect` deferred via `queueMicrotask` (avoids both a hydration mismatch *and* the `react-hooks/set-state-in-effect` lint error — same pattern as the sibling `moallem` project's `CartProvider`, keep them consistent if either changes) and writes back to storage on every change.
|
||||
- `src/components/cart/CartButton.tsx` (header icon + quantity badge) and `MiniCart.tsx` (slide-over popup: line items, qty stepper, remove, total, **Continue**) — `Continue` uses `continueToCheckoutUrl()`: if the access-token cookie is present it links to `customer.{domain}/checkout/cart?guestCart=...`, otherwise to `customer.{domain}/login?redirect=/checkout/cart?guestCart=...`. No server cart/checkout code exists on this site at all, per the hard rule.
|
||||
- `AddToCartBar.tsx` (`src/components/catalog/`) is the shared add-to-cart control used on `/store/{id}/{slug}` and workshop registration: filters variants to in-stock (`stockQuantity === null` or `> 0`), shows a `<select>` only when more than one in-stock variant exists, quantity stepper, "این محصول در حال حاضر موجود نیست" when none are in stock.
|
||||
|
||||
## Comments
|
||||
|
||||
- `GET/POST /tenants/{domain}/comments?entityType=...&entityId=...` — the public docs list `product|blog|portfolio|video` as valid `entityType` values; `workshop` and `store_item` are passed the same way as a best-effort extension (**unconfirmed** — verify once live, `blog`/`portfolio`/`product` are the safe ones). Blog and portfolio also have entity-specific `/blogs/{id}/comments` routes but the generic one is used everywhere for one code path.
|
||||
- **The API itself does not require auth to POST a comment** (no security scheme on that path in the OpenAPI doc) — but the user explicitly wants comments **gated to authenticated users in the UI**: `src/components/catalog/CommentsPanel.tsx` shows the comment form only when `user` is non-null (passed from the server page's `getSession()`), otherwise a "برای ثبت نظر ابتدا وارد حساب کاربری خود شوید" box linking to `customerLoginUrl(pathname)`. When posting, `authorName` is built from the session's `firstName`/`lastName` and the access token is sent as `Authorization: Bearer` too (harmless if the API ignores it, useful if it actually does attribute comments server-side).
|
||||
|
||||
## SEO / sitemap
|
||||
|
||||
- No `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts`, or `app/robots.ts` — Meshkee nginx proxies `/sitemap.xml` (index) → `/sitemap-main.xml` + `/sitemap-products.xml` (+ other module sitemaps once enabled).
|
||||
- `src/app/meshkee/sitemap-config.json/route.ts` scans `src/app/**/page.tsx` at request time (build-time-equivalent, since it's a dynamic route) and lists every **static** route, excluding dynamic `[...]` segments automatically — so `/products/category/[id]/[slug]` etc. never appear here; Meshkee's CMS fills those in from its own data per the canonical templates above. After deploy: business dashboard → Website → Settings → Sync sitemap config.
|
||||
- Every page (static and dynamic) sets a unique `<title>`/description via `metadata`/`generateMetadata`, preferring `seoMetaTitle`/`seoMetaDescription` when the API provides them. Exactly one `<h1>` per page: `PageHeader` owns it; detail-page body sections that repeat the item name use `<h2>` (fixed a duplicate-H1 bug on the products/store/workshop detail pages this session — if you add a new detail page, don't reintroduce it). All content images carry a real `alt` (item name, never empty, decorative floaters use `aria-hidden` + empty alt instead).
|
||||
- `src/app/meshkee/static-image-slots/route.ts` still returns `STATIC_IMAGE_SLOT_CATALOG` from `src/lib/meshkee.ts` (note: different file from the new `src/lib/meshkee/` **directory** — `src/lib/meshkee.ts` predates this session's API layer and only holds that one constant, currently **empty**). Populate it if/when a homepage image becomes dashboard-editable.
|
||||
|
||||
## Contact form
|
||||
|
||||
`src/components/contact/ContactForm.tsx` now POSTs to `POST /tenants/{domain}/contact-submissions` (`src/lib/meshkee/contact.ts`) with `{ title, name, cellNumber, text }` (the `title` field is a fixed "پیام از فرم تماس با ما" — the endpoint appears to want a subject line, unconfirmed). On success: a toast (`useToast()`), an inline success banner inside the form for ~4s, and the form resets so the visitor can send another message without leaving the page. On failure: an error toast, fields are preserved (no reset) so nothing is lost.
|
||||
|
||||
## Next steps
|
||||
|
||||
1. Once the tenant exists on the backend: fetch one real response per entity (product, store-item, category, comment, `/auth/me` user) and reconcile field names in `src/lib/meshkee/types.ts` + the `pickText()`/`normalize*()` chains — several are educated guesses (see "Meshkee API layer" above).
|
||||
2. Confirm `entityType` values `workshop`/`store_item` actually work against `/comments`, or find/request the real ones.
|
||||
3. Confirm whether a `/workshops` endpoint ever ships; if it lands under a different shape (e.g. folded into `instructions` or `videos` instead of its own module), update `src/lib/meshkee/workshops.ts` and the "registration row" fallback logic accordingly.
|
||||
4. Resolve the tenant (`GET /tenants/{domain}`) for `businessId` and site-wide Organization JSON-LD (`business-info.schema.jsonLd`) in the root layout — not done yet, only favicon is fetched there.
|
||||
5. Decide which homepage/about sections should become dashboard-editable and populate `STATIC_IMAGE_SLOT_CATALOG` (`src/lib/meshkee.ts`) accordingly.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run lint` and `npm run build` (standalone output at `.next/standalone/server.js`).
|
||||
- Visually check every page in desktop and mobile widths; RTL layout, Swiper carousels, mobile nav toggle, cart badge/popup, user menu (logged-out state at minimum — no test account exists yet), contact form success + reset.
|
||||
- Every catalog list/detail page should render its **empty state**, not crash, against the current (unprovisioned) tenant — that's the expected state until the backend is live.
|
||||
- `/meshkee/sitemap-config.json` should list all static routes (`/`, `/workshops`, `/products`, `/store`, `/portfolios`, `/blog`, `/about-us`, `/contact-us`) and none of the `[id]/[slug]` dynamic ones.
|
||||
- `/meshkee/static-image-slots` should return `{ "slots": [] }` until slots are added.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ali-alemi
|
||||
|
||||
Next.js (App Router) storefront for استاد علی عالمی — Persian, RTL. See [CONTEXT.md](./CONTEXT.md) for the full project brief (scope, content sources, Meshkee API conventions, next steps).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
`next.config.ts` sets `output: "standalone"` — required by Meshkee's shared-VM deploy.
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "ali-alemi",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"swiper": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BioIntro } from "@/components/about/BioIntro";
|
||||
import { Faq } from "@/components/about/Faq";
|
||||
import { FeatureStrip } from "@/components/about/FeatureStrip";
|
||||
import { Resume } from "@/components/about/Resume";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { StatsBar } from "@/components/StatsBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "درباره ما",
|
||||
description:
|
||||
"استاد علی عالمی، کارشناسی ارشد مهندسی هوافضا از دانشگاه علم و صنعت ایران، رتبه ۲۴۳ کنکور سراسری و ۱۳ سال سابقهی تدریس دروس ریاضی و حسابان کنکور.",
|
||||
};
|
||||
|
||||
export default function AboutUsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="درباره ما بیشتر بدانید" current="درباره ما" />
|
||||
<BioIntro />
|
||||
<Resume />
|
||||
<FeatureStrip />
|
||||
<StatsBar
|
||||
items={[
|
||||
{ value: 265, label: "آموزش شیمی" },
|
||||
{ value: 100, label: "آموزش فیزیک" },
|
||||
{ value: 100, label: "آموزش ریاضی" },
|
||||
{ value: 100, label: "آموزش زبان انگلیسی" },
|
||||
]}
|
||||
/>
|
||||
<Faq />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { CommentsPanel } from "@/components/catalog/CommentsPanel";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getComments } from "@/lib/meshkee/comments";
|
||||
import { formatDate, normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { getBlogById } from "@/lib/meshkee/blogs";
|
||||
|
||||
type Params = { id: string; slug: string };
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { blog } = await getBlogById(id);
|
||||
return {
|
||||
title: blog.seoMetaTitle || pickText(blog.titleFa, blog.title, blog.titleEn),
|
||||
description: blog.seoMetaDescription || blog.excerpt || blog.summary || undefined,
|
||||
};
|
||||
} catch {
|
||||
return { title: "مقاله" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }: { params: Promise<Params> }) {
|
||||
const { id, slug } = await params;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getBlogById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { blog } = detail;
|
||||
const canonicalSlug = slugify(pickText(blog.titleFa, blog.title, blog.titleEn));
|
||||
if (canonicalSlug && canonicalSlug !== slug) {
|
||||
redirect(`/blog/${id}/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const [comments, session] = await Promise.all([
|
||||
safe(getComments("blog", blog.id), { items: [] }),
|
||||
getSession(),
|
||||
]);
|
||||
|
||||
const name = pickText(blog.titleFa, blog.title, blog.titleEn);
|
||||
const cover = normalizeImage(blog.coverImageUrl ?? blog.titleImageUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={name} current="مقالات" />
|
||||
<article className="boxed max-w-3xl py-12">
|
||||
{cover && (
|
||||
<div className="relative mb-8 aspect-video w-full overflow-hidden rounded-2xl bg-surface">
|
||||
<Image src={cover} alt={name} fill sizes="768px" className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
{blog.publishedAt && <p className="mb-4 text-xs text-muted">{formatDate(blog.publishedAt)}</p>}
|
||||
|
||||
{blog.contentHtml ? (
|
||||
<div className="prose max-w-none text-sm leading-8 text-body" dangerouslySetInnerHTML={{ __html: blog.contentHtml }} />
|
||||
) : (
|
||||
<p className="text-sm leading-8 text-body">{blog.excerpt || blog.summary}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-12 border-t border-line pt-8">
|
||||
<h2 className="mb-5 text-lg font-bold text-ink">
|
||||
نظرات{comments.items.length ? ` (${comments.items.length.toLocaleString("fa-IR")})` : ""}
|
||||
</h2>
|
||||
<CommentsPanel
|
||||
entityType="blog"
|
||||
entityId={blog.id}
|
||||
initialComments={comments.items}
|
||||
user={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { EmptyState } from "@/components/catalog/EmptyState";
|
||||
import { normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getBlogs } from "@/lib/meshkee/blogs";
|
||||
import type { BlogListResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "مقالات",
|
||||
description: "مقالات آموزشی استاد علی عالمی درباره ریاضی و حسابان کنکور.",
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const { items } = await safe<BlogListResponse>(getBlogs({ pageSize: 24 }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 24,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="مقالات" current="مقالات" />
|
||||
<section className="boxed py-16">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="در حال حاضر مقالهای ثبت نشده است" text="بهزودی مقالات آموزشی در این صفحه منتشر میشود." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((blog) => {
|
||||
const name = pickText(blog.titleFa, blog.title, blog.titleEn);
|
||||
return (
|
||||
<EntityCard
|
||||
key={blog.id}
|
||||
href={`/blog/${blog.id}/${slugify(name)}`}
|
||||
image={normalizeImage(blog.coverImageUrl ?? blog.titleImageUrl)}
|
||||
title={name}
|
||||
subtitle={blog.excerpt || blog.summary}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ContactInfo } from "@/components/contact/ContactInfo";
|
||||
import { CustomerClubBanner } from "@/components/contact/CustomerClubBanner";
|
||||
import { MessageSection } from "@/components/contact/MessageSection";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { CONTACT } from "@/lib/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "تماس با ما",
|
||||
description: `راههای ارتباط با استاد علی عالمی: ${CONTACT.phoneLabel} — ${CONTACT.address}`,
|
||||
};
|
||||
|
||||
export default function ContactUsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="با ما تماس بگیرید" current="تماس با ما" />
|
||||
<ContactInfo />
|
||||
<MessageSection />
|
||||
<CustomerClubBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import localFont from "next/font/local";
|
||||
import { Montserrat } from "next/font/google";
|
||||
|
||||
// IRANYekan is the face the original ali-alemi theme uses (settings.php
|
||||
// defaultFont). Self-hosted so production doesn't depend on an external CDN.
|
||||
export const iranYekan = localFont({
|
||||
src: [
|
||||
{ path: "../../public/fonts/iranyekanweblightfanum.woff", weight: "300", style: "normal" },
|
||||
{ path: "../../public/fonts/iranyekanwebboldfanum.woff", weight: "700", style: "normal" },
|
||||
],
|
||||
variable: "--font-fa",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
// Montserrat pairs with iran-yekan for the small tracked-out Latin labels
|
||||
// (TIP AND TEST, TEST, PRICE...) the original theme uses on section eyebrows.
|
||||
export const montserrat = Montserrat({
|
||||
variable: "--font-en",
|
||||
subsets: ["latin"],
|
||||
weight: ["500", "600", "700"],
|
||||
display: "swap",
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
@import "tailwindcss";
|
||||
@import "swiper/css";
|
||||
@import "swiper/css/pagination";
|
||||
@import "swiper/css/navigation";
|
||||
@import "swiper/css/thumbs";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #2e2e2e;
|
||||
|
||||
/* Brand palette from the original ali-alemi theme (settings.php:
|
||||
primaryColor #008081, secondaryColor #324264, tertiaryColor #E7E7E7). */
|
||||
--color-primary: #008081;
|
||||
--color-primary-dark: #016566;
|
||||
--color-primary-soft: #ebf5f5;
|
||||
--color-secondary: #324264;
|
||||
--color-secondary-dark: #232f49;
|
||||
--color-tertiary: #e7e7e7;
|
||||
--color-ink: #2e2e2e;
|
||||
--color-ink-soft: #4c4d4d;
|
||||
--color-body: #6b6b6b;
|
||||
--color-muted: #9f9f9f;
|
||||
--color-surface: #fafafa;
|
||||
--color-line: #ececec;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-dark: var(--color-primary-dark);
|
||||
--color-primary-soft: var(--color-primary-soft);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-dark: var(--color-secondary-dark);
|
||||
--color-tertiary: var(--color-tertiary);
|
||||
--color-ink: var(--color-ink);
|
||||
--color-ink-soft: var(--color-ink-soft);
|
||||
--color-body: var(--color-body);
|
||||
--color-muted: var(--color-muted);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-line: var(--color-line);
|
||||
|
||||
--font-fa: var(--font-fa);
|
||||
--font-en: var(--font-en);
|
||||
|
||||
--container-boxed: 1220px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-fa), Tahoma, sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
color: var(--color-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
@utility boxed {
|
||||
width: 100%;
|
||||
max-width: var(--container-boxed);
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
/* Small tracked-out Latin eyebrows (TIP AND TEST, TEST, PRICE, SUPPORT). */
|
||||
.eyebrow-en {
|
||||
font-family: var(--font-en), sans-serif;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
/* ---- Scroll reveal (see src/components/Reveal.tsx) ---- */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(28px);
|
||||
transition: opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1), transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightbox-swiper {
|
||||
--swiper-navigation-color: #ffffff;
|
||||
--swiper-navigation-size: 22px;
|
||||
}
|
||||
|
||||
/* ---- Swiper theming ---- */
|
||||
.testimonial-swiper {
|
||||
padding-bottom: 3rem !important;
|
||||
}
|
||||
|
||||
.testimonial-swiper .swiper-pagination-bullet {
|
||||
background: var(--color-tertiary);
|
||||
opacity: 1;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.testimonial-swiper .swiper-pagination-bullet-active {
|
||||
background: var(--color-primary);
|
||||
width: 22px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hero-swiper .swiper-pagination-bullet {
|
||||
background: #ffffff;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero-swiper .swiper-pagination-bullet-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---- Meshkee footer credit (matches https://safeteb.com/) ---- */
|
||||
.footer-bottom-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-bottom-site {
|
||||
color: #9c9c9c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.meshkee-credit {
|
||||
align-items: center;
|
||||
color: #9c9c9c;
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.meshkee-credit__frame {
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.meshkee-credit__line {
|
||||
background: #777;
|
||||
position: absolute;
|
||||
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit__line--l {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
transition-delay: 0s;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--b {
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition-delay: 0.14s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--r {
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: bottom;
|
||||
transition-delay: 0.28s;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.meshkee-credit__line--t {
|
||||
height: 1px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: right;
|
||||
transition-delay: 0.42s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__line--l,
|
||||
.meshkee-credit:hover .meshkee-credit__line--r {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__line--b,
|
||||
.meshkee-credit:hover .meshkee-credit__line--t {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
|
||||
transition-delay: 0.42s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
|
||||
transition-delay: 0.28s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
|
||||
transition-delay: 0.14s;
|
||||
}
|
||||
|
||||
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.meshkee-credit__logo {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
height: 38px;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__logo {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.meshkee-credit__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
line-height: 1.25;
|
||||
min-height: 38px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.meshkee-credit__label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.meshkee-credit__name {
|
||||
color: #cfcfcf;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.meshkee-credit:hover .meshkee-credit__name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.footer-bottom-bar {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MiniCart } from "@/components/cart/MiniCart";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Header } from "@/components/Header";
|
||||
import { CartProvider } from "@/components/providers/CartProvider";
|
||||
import { ToastProvider } from "@/components/providers/ToastProvider";
|
||||
import { isAdminUser, getSession } from "@/lib/auth";
|
||||
import { getFavicon } from "@/lib/meshkee/tenant";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, SITE_URL } from "@/lib/site";
|
||||
import { iranYekan, montserrat } from "./fonts";
|
||||
import "./globals.css";
|
||||
|
||||
const NO_BRAND_ASSETS = { faviconUrl: null, logoUrl: null, logoDarkUrl: null, hasDedicatedFavicon: false };
|
||||
|
||||
// generateMetadata (not a static export) because the tab icon comes from
|
||||
// GET /tenants/{domain}/website/favicon — once the business uploads a real
|
||||
// one in the dashboard this picks it up with no code change. Falls back to
|
||||
// the bundled /favicon.png while that API field is empty or unreachable.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const brand = await safe(getFavicon(), NO_BRAND_ASSETS);
|
||||
|
||||
return {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
icons: [{ url: brand.faviconUrl || "/favicon.png", type: "image/png" }],
|
||||
robots: { index: true, follow: true },
|
||||
openGraph: {
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
url: "/",
|
||||
siteName: SITE_NAME,
|
||||
locale: "fa_IR",
|
||||
type: "website",
|
||||
images: [{ url: "/images/hero/character.png", alt: SITE_NAME }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getSession();
|
||||
const user = session?.user ?? null;
|
||||
const isAdmin = isAdminUser(user);
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable}`}>
|
||||
<body className="flex min-h-screen flex-col antialiased">
|
||||
<ToastProvider>
|
||||
<CartProvider>
|
||||
<Header user={user} isAdmin={isAdmin} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
<MiniCart />
|
||||
</CartProvider>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type StaticPage = {
|
||||
path: string;
|
||||
changefreq: "daily" | "weekly" | "monthly";
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const EXCLUDED_SEGMENTS = new Set([
|
||||
"account",
|
||||
"admin",
|
||||
"api",
|
||||
"auth",
|
||||
"cart",
|
||||
"checkout",
|
||||
"login",
|
||||
"meshkee",
|
||||
"register",
|
||||
]);
|
||||
|
||||
function collectStaticPagePaths(directory: string, routeSegments: string[] = []): string[] {
|
||||
const entries = readdirSync(directory, { withFileTypes: true });
|
||||
const paths: string[] = [];
|
||||
|
||||
if (entries.some((entry) => entry.isFile() && /^page\.[jt]sx?$/.test(entry.name))) {
|
||||
const path = routeSegments.length ? `/${routeSegments.join("/")}` : "/";
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const isRouteGroup = entry.name.startsWith("(") && entry.name.endsWith(")");
|
||||
const isPrivateFolder = entry.name.startsWith("_");
|
||||
const isParallelRoute = entry.name.startsWith("@");
|
||||
const isDynamicRoute = entry.name.includes("[");
|
||||
|
||||
if (isPrivateFolder || isParallelRoute || isDynamicRoute || EXCLUDED_SEGMENTS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.push(
|
||||
...collectStaticPagePaths(
|
||||
join(directory, entry.name),
|
||||
isRouteGroup ? routeSegments : [...routeSegments, entry.name],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function toStaticPage(path: string): StaticPage {
|
||||
if (path === "/") return { path, changefreq: "daily", priority: 1 };
|
||||
if (path === "/workshops") return { path, changefreq: "weekly", priority: 0.8 };
|
||||
if (path === "/products") return { path, changefreq: "weekly", priority: 0.8 };
|
||||
if (path === "/store") return { path, changefreq: "weekly", priority: 0.8 };
|
||||
if (path === "/portfolios") return { path, changefreq: "weekly", priority: 0.7 };
|
||||
if (path === "/blog") return { path, changefreq: "weekly", priority: 0.7 };
|
||||
if (path === "/about-us") return { path, changefreq: "monthly", priority: 0.6 };
|
||||
if (path === "/contact-us") return { path, changefreq: "monthly", priority: 0.5 };
|
||||
return { path, changefreq: "weekly", priority: 0.5 };
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
const baseUrl = (
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
process.env.SITE_URL ||
|
||||
"https://alialemi.com"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
const appDir = join(process.cwd(), "src/app");
|
||||
const staticPages = collectStaticPagePaths(appDir)
|
||||
.sort((first, second) => {
|
||||
if (first === "/") return -1;
|
||||
if (second === "/") return 1;
|
||||
return first.localeCompare(second);
|
||||
})
|
||||
.map(toStaticPage);
|
||||
|
||||
return Response.json(
|
||||
{ baseUrl, staticPages },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/meshkee";
|
||||
|
||||
export function GET() {
|
||||
return Response.json(
|
||||
{ slots: STATIC_IMAGE_SLOT_CATALOG },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { IconArrow } from "@/components/icons";
|
||||
|
||||
export const metadata = { robots: { index: false, follow: false } };
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section className="boxed flex min-h-[60vh] flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-sm font-semibold text-primary">۴۰۴</p>
|
||||
<h1 className="mt-3 text-3xl font-bold text-ink">صفحهی مورد نظر پیدا نشد</h1>
|
||||
<p className="mt-3 max-w-md text-sm leading-7 text-body">
|
||||
ممکن است آدرس اشتباه باشد یا این صفحه هنوز آماده نشده باشد.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
بازگشت به صفحه اصلی
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FeatureCards } from "@/components/home/FeatureCards";
|
||||
import { HeroSection } from "@/components/home/HeroSection";
|
||||
import { QuickLinks } from "@/components/home/QuickLinks";
|
||||
import { Testimonials } from "@/components/home/Testimonials";
|
||||
import { WhyChooseUs } from "@/components/home/WhyChooseUs";
|
||||
import { StatsBar } from "@/components/StatsBar";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<FeatureCards />
|
||||
<QuickLinks />
|
||||
<WhyChooseUs />
|
||||
<Testimonials />
|
||||
<StatsBar
|
||||
items={[
|
||||
{ value: 100, label: "آموزش زبان شیمی" },
|
||||
{ value: 100, label: "آموزش زبان فیزیک" },
|
||||
{ value: 100, label: "آموزش زبان ریاضی" },
|
||||
{ value: 100, label: "آموزش زبان انگلیسی" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Gallery } from "@/components/catalog/Gallery";
|
||||
import { Tabs } from "@/components/catalog/Tabs";
|
||||
import { CommentsPanel } from "@/components/catalog/CommentsPanel";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { SimilarRow } from "@/components/catalog/SimilarRow";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getComments } from "@/lib/meshkee/comments";
|
||||
import { normalizeGallery, normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { getPortfolioById, getPortfolios } from "@/lib/meshkee/portfolios";
|
||||
|
||||
type Params = { id: string; slug: string };
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { portfolio } = await getPortfolioById(id);
|
||||
return { title: pickText(portfolio.titleFa, portfolio.title, portfolio.titleEn), description: portfolio.summary };
|
||||
} catch {
|
||||
return { title: "نمونه کار" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PortfolioDetailPage({ params }: { params: Promise<Params> }) {
|
||||
const { id, slug } = await params;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getPortfolioById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { portfolio } = detail;
|
||||
const name = pickText(portfolio.titleFa, portfolio.title, portfolio.titleEn);
|
||||
const canonicalSlug = slugify(name);
|
||||
if (canonicalSlug && canonicalSlug !== slug) {
|
||||
redirect(`/portfolios/${id}/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const [comments, session, othersResult] = await Promise.all([
|
||||
safe(getComments("portfolio", portfolio.id), { items: [] }),
|
||||
getSession(),
|
||||
safe(getPortfolios({ pageSize: 8 }), { items: [], total: 0, page: 1, pageSize: 8 }),
|
||||
]);
|
||||
|
||||
const images = normalizeGallery(portfolio.gallery ?? []);
|
||||
const mainImage = normalizeImage(portfolio.titleImageUrl);
|
||||
const gallery = mainImage && !images.includes(mainImage) ? [mainImage, ...images] : images;
|
||||
const others = othersResult.items.filter((p) => p.id !== portfolio.id).slice(0, 4);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={name} current="نمونه کارها" />
|
||||
<section className="boxed py-12">
|
||||
<div className="grid gap-10 md:grid-cols-2">
|
||||
<Gallery images={gallery} alt={name} />
|
||||
<div>
|
||||
{portfolio.projectUrl && (
|
||||
<a
|
||||
href={portfolio.projectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm font-semibold text-primary hover:underline"
|
||||
>
|
||||
مشاهده لینک پروژه ←
|
||||
</a>
|
||||
)}
|
||||
{portfolio.summary && <p className="mt-4 text-sm leading-8 text-body">{portfolio.summary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
key: "description",
|
||||
label: "توضیحات",
|
||||
content: portfolio.mainTextHtml ? (
|
||||
<div className="prose max-w-none text-sm leading-8 text-body" dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }} />
|
||||
) : (
|
||||
<p className="text-sm leading-8 text-body">{portfolio.summary || "توضیحاتی ثبت نشده است."}</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: `نظرات${comments.items.length ? ` (${comments.items.length.toLocaleString("fa-IR")})` : ""}`,
|
||||
content: (
|
||||
<CommentsPanel
|
||||
entityType="portfolio"
|
||||
entityId={portfolio.id}
|
||||
initialComments={comments.items}
|
||||
user={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{others.length > 0 && (
|
||||
<SimilarRow title="نمونه کارهای مشابه">
|
||||
{others.map((item) => {
|
||||
const itemName = pickText(item.titleFa, item.title, item.titleEn);
|
||||
return (
|
||||
<EntityCard
|
||||
key={item.id}
|
||||
href={`/portfolios/${item.id}/${slugify(itemName)}`}
|
||||
image={normalizeImage(item.titleImageUrl)}
|
||||
title={itemName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimilarRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { EmptyState } from "@/components/catalog/EmptyState";
|
||||
import { normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getPortfolios } from "@/lib/meshkee/portfolios";
|
||||
import type { PortfolioListResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "نمونه کارها",
|
||||
description: "نمونهی نتایج و افتخارات دانشآموزان استاد علی عالمی.",
|
||||
};
|
||||
|
||||
export default async function PortfoliosPage() {
|
||||
const { items } = await safe<PortfolioListResponse>(getPortfolios({ pageSize: 24 }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 24,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="نمونه کارها" current="نمونه کارها" />
|
||||
<section className="boxed py-16">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="در حال حاضر نمونهکاری ثبت نشده است" text="بهزودی نمونه کارها در این صفحه نمایش داده میشوند." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((portfolio) => {
|
||||
const name = pickText(portfolio.titleFa, portfolio.title, portfolio.titleEn);
|
||||
return (
|
||||
<EntityCard
|
||||
key={portfolio.id}
|
||||
href={`/portfolios/${portfolio.id}/${slugify(name)}`}
|
||||
image={normalizeImage(portfolio.titleImageUrl)}
|
||||
title={name}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Gallery } from "@/components/catalog/Gallery";
|
||||
import { Tabs } from "@/components/catalog/Tabs";
|
||||
import { TechnicalDataTable } from "@/components/catalog/TechnicalDataTable";
|
||||
import { CommentsPanel } from "@/components/catalog/CommentsPanel";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { SimilarRow } from "@/components/catalog/SimilarRow";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getComments } from "@/lib/meshkee/comments";
|
||||
import { normalizeGallery, normalizeImage, pickText, formatToman, slugify } from "@/lib/meshkee/format";
|
||||
import { getProductById, getProductTechnicalInfo } from "@/lib/meshkee/products";
|
||||
|
||||
type Params = { id: string; slug: string };
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { product } = await getProductById(id);
|
||||
const name = pickText(product.nameFa, product.name, product.title);
|
||||
return {
|
||||
title: product.seoMetaTitle || name,
|
||||
description: product.seoMetaDescription || product.summary || undefined,
|
||||
};
|
||||
} catch {
|
||||
return { title: "محصول" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: { params: Promise<Params> }) {
|
||||
const { id, slug } = await params;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getProductById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { product, relatedProducts = [] } = detail;
|
||||
const name = pickText(product.nameFa, product.name, product.title);
|
||||
const canonicalSlug = slugify(name);
|
||||
if (canonicalSlug && canonicalSlug !== slug) {
|
||||
redirect(`/products/${id}/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const [technicalInfo, comments, session] = await Promise.all([
|
||||
safe(getProductTechnicalInfo(id), null),
|
||||
safe(getComments("product", id), { items: [] }),
|
||||
getSession(),
|
||||
]);
|
||||
|
||||
const images = normalizeGallery(product.gallery ?? product.images ?? []);
|
||||
const mainImage = normalizeImage(product.titleImageUrl ?? product.imageUrl);
|
||||
const gallery = mainImage && images.length === 0 ? [mainImage] : images;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={name} current="محصولات" />
|
||||
<section className="boxed py-12">
|
||||
<div className="grid gap-10 md:grid-cols-2">
|
||||
<Gallery images={gallery} alt={name} />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-ink">{name}</h2>
|
||||
{(product.discountedPrice ?? product.price) !== undefined && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{formatToman(product.discountedPrice ?? product.price)}
|
||||
</span>
|
||||
{product.discountedPrice && product.price && (
|
||||
<span className="text-sm text-muted line-through">{formatToman(product.price)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{product.summary && <p className="mt-5 text-sm leading-8 text-body">{product.summary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
key: "description",
|
||||
label: "توضیحات",
|
||||
content: product.descriptionHtml ? (
|
||||
<div className="prose max-w-none text-sm leading-8 text-body" dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
|
||||
) : (
|
||||
<p className="text-sm leading-8 text-body">{product.description || product.summary || "توضیحاتی ثبت نشده است."}</p>
|
||||
),
|
||||
},
|
||||
{ key: "technical", label: "مشخصات فنی", content: <TechnicalDataTable info={technicalInfo} /> },
|
||||
{
|
||||
key: "comments",
|
||||
label: `نظرات${comments.items.length ? ` (${comments.items.length.toLocaleString("fa-IR")})` : ""}`,
|
||||
content: (
|
||||
<CommentsPanel
|
||||
entityType="product"
|
||||
entityId={id}
|
||||
initialComments={comments.items}
|
||||
user={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedProducts.length > 0 && (
|
||||
<SimilarRow title="محصولات مشابه">
|
||||
{relatedProducts.map((related) => {
|
||||
const relatedName = pickText(related.nameFa, related.name, related.title);
|
||||
return (
|
||||
<EntityCard
|
||||
key={related.id}
|
||||
href={`/products/${related.id}/${slugify(relatedName)}`}
|
||||
image={normalizeImage(related.titleImageUrl ?? related.imageUrl)}
|
||||
title={relatedName}
|
||||
price={related.discountedPrice ?? related.price}
|
||||
originalPrice={related.discountedPrice ? related.price : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimilarRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { EmptyState } from "@/components/catalog/EmptyState";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getCategoryById } from "@/lib/meshkee/categories";
|
||||
import { normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { getProducts } from "@/lib/meshkee/products";
|
||||
import type { ProductListResponse } from "@/lib/meshkee/types";
|
||||
|
||||
type Params = { id: string; slug: string };
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { category } = await getCategoryById(id);
|
||||
const name = pickText(category.nameFa, category.name, category.nameEn);
|
||||
return { title: name, description: `محصولات دستهبندی ${name}` };
|
||||
} catch {
|
||||
return { title: "دستهبندی محصولات" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductCategoryPage({ params }: { params: Promise<Params> }) {
|
||||
const { id, slug } = await params;
|
||||
|
||||
let categoryResult;
|
||||
try {
|
||||
categoryResult = await getCategoryById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { category } = categoryResult;
|
||||
const name = pickText(category.nameFa, category.name, category.nameEn);
|
||||
const canonicalSlug = slugify(name);
|
||||
if (canonicalSlug && canonicalSlug !== slug) {
|
||||
redirect(`/products/category/${id}/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const { items } = await safe<ProductListResponse>(getProducts({ categoryId: id, pageSize: 24 }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 24,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={name} current={name} />
|
||||
<section className="boxed py-16">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="محصولی در این دستهبندی ثبت نشده است" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((product) => {
|
||||
const productName = pickText(product.nameFa, product.name, product.title);
|
||||
return (
|
||||
<EntityCard
|
||||
key={product.id}
|
||||
href={`/products/${product.id}/${slugify(productName)}`}
|
||||
image={normalizeImage(product.titleImageUrl ?? product.imageUrl ?? product.images?.[0])}
|
||||
title={productName}
|
||||
price={product.discountedPrice ?? product.price}
|
||||
originalPrice={product.discountedPrice ? product.price : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { EmptyState } from "@/components/catalog/EmptyState";
|
||||
import { normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getProducts } from "@/lib/meshkee/products";
|
||||
import type { ProductListResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "محصولات",
|
||||
description: "محصولات و بستههای آموزشی استاد علی عالمی.",
|
||||
};
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const { items } = await safe<ProductListResponse>(getProducts({ pageSize: 24 }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 24,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="محصولات" current="محصولات" />
|
||||
<section className="boxed py-16">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="در حال حاضر محصولی ثبت نشده است" text="بهزودی محصولات در این صفحه نمایش داده میشوند." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((product) => {
|
||||
const name = pickText(product.nameFa, product.name, product.title);
|
||||
const image = normalizeImage(product.titleImageUrl ?? product.imageUrl ?? product.images?.[0]);
|
||||
return (
|
||||
<EntityCard
|
||||
key={product.id}
|
||||
href={`/products/${product.id}/${slugify(name)}`}
|
||||
image={image}
|
||||
title={name}
|
||||
price={product.discountedPrice ?? product.price}
|
||||
originalPrice={product.discountedPrice ? product.price : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Gallery } from "@/components/catalog/Gallery";
|
||||
import { Tabs } from "@/components/catalog/Tabs";
|
||||
import { TechnicalDataTable } from "@/components/catalog/TechnicalDataTable";
|
||||
import { CommentsPanel } from "@/components/catalog/CommentsPanel";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { SimilarRow } from "@/components/catalog/SimilarRow";
|
||||
import { AddToCartBar } from "@/components/catalog/AddToCartBar";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getComments } from "@/lib/meshkee/comments";
|
||||
import { normalizeGallery, normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { getProductById, getProductTechnicalInfo } from "@/lib/meshkee/products";
|
||||
import { getStoreItemByProduct } from "@/lib/meshkee/store-items";
|
||||
|
||||
type Params = { id: string; slug: string };
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { product } = await getProductById(id);
|
||||
const name = pickText(product.nameFa, product.name, product.title);
|
||||
return { title: product.seoMetaTitle || name, description: product.seoMetaDescription || product.summary || undefined };
|
||||
} catch {
|
||||
return { title: "فروشگاه" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function StoreItemDetailPage({ params }: { params: Promise<Params> }) {
|
||||
const { id, slug } = await params;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getProductById(id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { product, relatedProducts = [] } = detail;
|
||||
const name = pickText(product.nameFa, product.name, product.title);
|
||||
const canonicalSlug = slugify(name);
|
||||
if (canonicalSlug && canonicalSlug !== slug) {
|
||||
redirect(`/store/${id}/${canonicalSlug}`);
|
||||
}
|
||||
|
||||
const [technicalInfo, comments, session, storeItemResult] = await Promise.all([
|
||||
safe(getProductTechnicalInfo(id), null),
|
||||
safe(getComments("store_item", id), { items: [] }),
|
||||
getSession(),
|
||||
safe(getStoreItemByProduct(id), { storeItem: null }),
|
||||
]);
|
||||
|
||||
const images = normalizeGallery(product.gallery ?? product.images ?? []);
|
||||
const mainImage = normalizeImage(product.titleImageUrl ?? product.imageUrl);
|
||||
const gallery = mainImage && images.length === 0 ? [mainImage] : images;
|
||||
const variants = storeItemResult.storeItem?.variants ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={name} current="فروشگاه" />
|
||||
<section className="boxed py-12">
|
||||
<div className="grid gap-10 md:grid-cols-2">
|
||||
<Gallery images={gallery} alt={name} />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-ink">{name}</h2>
|
||||
{product.summary && <p className="mt-4 text-sm leading-8 text-body">{product.summary}</p>}
|
||||
<div className="mt-6">
|
||||
<AddToCartBar variants={variants} productName={name} image={mainImage ?? gallery[0] ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
key: "description",
|
||||
label: "توضیحات",
|
||||
content: product.descriptionHtml ? (
|
||||
<div className="prose max-w-none text-sm leading-8 text-body" dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
|
||||
) : (
|
||||
<p className="text-sm leading-8 text-body">{product.description || product.summary || "توضیحاتی ثبت نشده است."}</p>
|
||||
),
|
||||
},
|
||||
{ key: "technical", label: "مشخصات فنی", content: <TechnicalDataTable info={technicalInfo} /> },
|
||||
{
|
||||
key: "comments",
|
||||
label: `نظرات${comments.items.length ? ` (${comments.items.length.toLocaleString("fa-IR")})` : ""}`,
|
||||
content: (
|
||||
<CommentsPanel
|
||||
entityType="store_item"
|
||||
entityId={id}
|
||||
initialComments={comments.items}
|
||||
user={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{relatedProducts.length > 0 && (
|
||||
<SimilarRow title="محصولات مشابه">
|
||||
{relatedProducts.map((related) => {
|
||||
const relatedName = pickText(related.nameFa, related.name, related.title);
|
||||
return (
|
||||
<EntityCard
|
||||
key={related.id}
|
||||
href={`/store/${related.id}/${slugify(relatedName)}`}
|
||||
image={normalizeImage(related.titleImageUrl ?? related.imageUrl)}
|
||||
title={relatedName}
|
||||
price={related.discountedPrice ?? related.price}
|
||||
originalPrice={related.discountedPrice ? related.price : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimilarRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { EmptyState } from "@/components/catalog/EmptyState";
|
||||
import { normalizeImage, pickText, slugify } from "@/lib/meshkee/format";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getStoreItems } from "@/lib/meshkee/store-items";
|
||||
import type { StoreItemListResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "فروشگاه",
|
||||
description: "خرید بستهها و کالاهای آموزشی استاد علی عالمی.",
|
||||
};
|
||||
|
||||
export default async function StorePage() {
|
||||
const { items } = await safe<StoreItemListResponse>(getStoreItems({ pageSize: 24 }), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 24,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="فروشگاه" current="فروشگاه" />
|
||||
<section className="boxed py-16">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title="در حال حاضر کالایی برای فروش ثبت نشده است" text="بهزودی محصولات فروشگاه در این صفحه نمایش داده میشوند." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const name = pickText(item.product?.nameFa, item.nameFa, item.product?.name, item.name);
|
||||
const image = normalizeImage(item.product?.titleImageUrl ?? item.product?.imageUrl ?? item.variants?.[0]?.images?.[0]);
|
||||
const firstVariant = item.variants?.[0];
|
||||
const productId = item.productId ?? item.product?.id ?? item.id;
|
||||
return (
|
||||
<EntityCard
|
||||
key={item.id}
|
||||
href={`/store/${productId}/${slugify(name)}`}
|
||||
image={image}
|
||||
title={name}
|
||||
price={firstVariant?.discountedPrice ?? firstVariant?.price}
|
||||
originalPrice={firstVariant?.discountedPrice ? firstVariant.price : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Gallery } from "@/components/catalog/Gallery";
|
||||
import { Tabs } from "@/components/catalog/Tabs";
|
||||
import { CommentsPanel } from "@/components/catalog/CommentsPanel";
|
||||
import { AddToCartBar } from "@/components/catalog/AddToCartBar";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getComments } from "@/lib/meshkee/comments";
|
||||
import { normalizeGallery, normalizeImage, pickText, formatToman } from "@/lib/meshkee/format";
|
||||
import { getWorkshopBySlug } from "@/lib/meshkee/workshops";
|
||||
import { getStoreItemByProduct } from "@/lib/meshkee/store-items";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
try {
|
||||
const { workshop } = await getWorkshopBySlug(slug);
|
||||
return { title: pickText(workshop.titleFa, workshop.title, workshop.titleEn), description: workshop.summary };
|
||||
} catch {
|
||||
return { title: "دوره آموزشی" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function WorkshopDetailPage({ params }: { params: Promise<Params> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await getWorkshopBySlug(slug);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { workshop } = detail;
|
||||
const registrationProductId = workshop.productId ?? workshop.id;
|
||||
|
||||
const [comments, session, storeItemResult] = await Promise.all([
|
||||
safe(getComments("workshop", workshop.id), { items: [] }),
|
||||
getSession(),
|
||||
safe(getStoreItemByProduct(registrationProductId), { storeItem: null }),
|
||||
]);
|
||||
|
||||
const name = pickText(workshop.titleFa, workshop.title, workshop.titleEn);
|
||||
const images = normalizeGallery(workshop.gallery ?? []);
|
||||
const mainImage = normalizeImage(workshop.titleImageUrl);
|
||||
const gallery = mainImage && !images.includes(mainImage) ? [mainImage, ...images] : images;
|
||||
const variants = storeItemResult.storeItem?.variants ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={name} current="دوره های آموزشی" />
|
||||
<section className="boxed py-12">
|
||||
<div className="grid gap-10 md:grid-cols-2">
|
||||
<Gallery images={gallery} alt={name} />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-ink">{name}</h2>
|
||||
{(workshop.discountedPrice ?? workshop.price) !== undefined && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-xl font-bold text-primary">{formatToman(workshop.discountedPrice ?? workshop.price)}</span>
|
||||
{workshop.discountedPrice && workshop.price && (
|
||||
<span className="text-sm text-muted line-through">{formatToman(workshop.price)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{workshop.summary && <p className="mt-4 text-sm leading-8 text-body">{workshop.summary}</p>}
|
||||
|
||||
<div className="mt-6">
|
||||
{variants.length > 0 ? (
|
||||
<AddToCartBar variants={variants} productName={name} image={mainImage ?? gallery[0] ?? null} />
|
||||
) : (
|
||||
<div className="rounded-2xl bg-primary-soft p-5 text-center">
|
||||
<p className="text-sm font-medium text-ink-soft">برای ثبتنام در این دوره با ما تماس بگیرید.</p>
|
||||
<a
|
||||
href={`tel:${CONTACT.phone}`}
|
||||
dir="ltr"
|
||||
className="mt-3 inline-block rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white hover:bg-primary-dark"
|
||||
>
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
key: "description",
|
||||
label: "توضیحات",
|
||||
content: workshop.descriptionHtml ? (
|
||||
<div className="prose max-w-none text-sm leading-8 text-body" dangerouslySetInnerHTML={{ __html: workshop.descriptionHtml }} />
|
||||
) : (
|
||||
<p className="text-sm leading-8 text-body">{workshop.summary || "توضیحاتی ثبت نشده است."}</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: `نظرات${comments.items.length ? ` (${comments.items.length.toLocaleString("fa-IR")})` : ""}`,
|
||||
content: (
|
||||
<CommentsPanel
|
||||
entityType="workshop"
|
||||
entityId={workshop.id}
|
||||
initialComments={comments.items}
|
||||
user={session?.user ?? null}
|
||||
token={session?.token ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EntityCard } from "@/components/catalog/EntityCard";
|
||||
import { EmptyState } from "@/components/catalog/EmptyState";
|
||||
import { normalizeImage, pickText } from "@/lib/meshkee/format";
|
||||
import { safe } from "@/lib/meshkee/client";
|
||||
import { getWorkshops } from "@/lib/meshkee/workshops";
|
||||
import type { WorkshopListResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "دوره های آموزشی",
|
||||
description: "دورهها و کارگاههای آموزشی استاد علی عالمی برای ریاضی و حسابان کنکور.",
|
||||
};
|
||||
|
||||
const EMPTY: WorkshopListResponse = { items: [], total: 0, page: 1, pageSize: 24 };
|
||||
|
||||
export default async function WorkshopsPage() {
|
||||
const { items } = await safe(getWorkshops({ pageSize: 24 }), EMPTY);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="دوره های آموزشی" current="دوره های آموزشی" />
|
||||
<section className="boxed py-16">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="در حال حاضر دورهای برای ثبتنام باز نیست"
|
||||
text="برای اطلاع از زمان برگزاری دورههای بعدی از طریق صفحه تماس با ما در ارتباط باشید."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((workshop) => (
|
||||
<EntityCard
|
||||
key={workshop.id}
|
||||
href={`/workshops/${workshop.slug ?? workshop.id}`}
|
||||
image={normalizeImage(workshop.titleImageUrl)}
|
||||
title={pickText(workshop.titleFa, workshop.title, workshop.titleEn)}
|
||||
price={workshop.discountedPrice ?? workshop.price}
|
||||
originalPrice={workshop.discountedPrice ? workshop.price : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { CONTACT, NAV_LINKS, SITE_NAME } from "@/lib/site";
|
||||
import { IconInstagram, IconLocation, IconPhone, IconTelegram, IconWhatsapp } from "./icons";
|
||||
|
||||
const BIO_LINES = [
|
||||
"متولد ۱۳۶۶/۱۲/۲۲",
|
||||
"مهندس مکانیک دانشگاه علموصنعت",
|
||||
"کارشناس ارشد هوافضا گرایش سازههای هوایی از دانشگاه علموصنعت",
|
||||
"رتبهی ۲۴۳ کنکور سراسری",
|
||||
"۱۳ سال سابقهی تدریس دروس ریاضی و حسابان کنکور",
|
||||
"همکاری با برترین آموزشگاههای کنکور بهصورت حضوری و آنلاین",
|
||||
];
|
||||
|
||||
const SOCIALS = [
|
||||
{ icon: IconWhatsapp, href: CONTACT.whatsapp, label: "واتساپ" },
|
||||
{ icon: IconInstagram, href: CONTACT.instagram, label: "اینستاگرام" },
|
||||
{ icon: IconTelegram, href: CONTACT.telegram, label: "تلگرام" },
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-line bg-white">
|
||||
<div className="boxed grid gap-12 py-16 text-center lg:grid-cols-[1.3fr_1fr_1fr] lg:text-right">
|
||||
<div className="mx-auto lg:mx-0">
|
||||
<Image src="/images/logo.png" alt={SITE_NAME} width={150} height={44} className="mx-auto h-10 w-auto lg:mx-0" />
|
||||
<ul className="mt-6 space-y-2 text-sm leading-7 text-body">
|
||||
{BIO_LINES.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex items-center justify-center gap-3 lg:justify-start">
|
||||
{SOCIALS.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-soft text-primary transition-colors hover:bg-primary hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-5 text-base font-bold text-ink">راههای ارتباطی</h3>
|
||||
<ul className="space-y-4 text-sm text-body">
|
||||
<li className="flex items-start justify-center gap-3 lg:justify-start">
|
||||
<IconLocation className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>{CONTACT.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-center gap-3 lg:justify-start">
|
||||
<IconPhone className="h-4 w-4 shrink-0 text-primary" />
|
||||
<a href={`tel:${CONTACT.phone}`} dir="ltr" className="hover:text-primary">
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center justify-center gap-3 lg:justify-start">
|
||||
<IconInstagram className="h-4 w-4 shrink-0 text-primary" />
|
||||
<a href={CONTACT.instagram} target="_blank" rel="noopener noreferrer" dir="ltr" className="hover:text-primary">
|
||||
{CONTACT.instagramLabel}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-5 text-base font-bold text-ink">صفحات</h3>
|
||||
<ul className="space-y-3 text-sm text-body">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="transition-colors hover:text-primary">
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary">
|
||||
<div className="boxed footer-bottom-bar py-4">
|
||||
<div className="footer-bottom-site">
|
||||
© {new Date().getFullYear()} {SITE_NAME} — کلیه حقوق محفوظ است.
|
||||
</div>
|
||||
<a href="https://meshkee.com" target="_blank" rel="noopener noreferrer" dir="ltr" className="meshkee-credit">
|
||||
<span className="meshkee-credit__frame" aria-hidden>
|
||||
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||
</span>
|
||||
<img src="/brand/meshkee-logo-white.png" alt="Meshkee" className="meshkee-credit__logo" width={38} height={38} />
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { CONTACT, NAV_LINKS } from "@/lib/site";
|
||||
import type { CustomerUser } from "@/lib/meshkee/types";
|
||||
import { CartButton } from "@/components/cart/CartButton";
|
||||
import { UserMenu } from "@/components/UserMenu";
|
||||
import { IconPhone } from "./icons";
|
||||
|
||||
export function Header({ user, isAdmin }: { user: CustomerUser | null; isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-secondary">
|
||||
<div className="boxed flex h-20 items-center justify-between gap-4">
|
||||
<Link href="/" className="shrink-0" onClick={() => setOpen(false)}>
|
||||
<Image
|
||||
src="/images/logo.png"
|
||||
alt="استاد علی عالمی"
|
||||
width={140}
|
||||
height={40}
|
||||
priority
|
||||
className="h-9 w-auto brightness-0 invert"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-5 xl:flex">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`whitespace-nowrap text-sm font-medium transition-colors hover:text-primary ${
|
||||
active ? "text-primary" : "text-white/80"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<a
|
||||
href={`tel:${CONTACT.phone}`}
|
||||
className="flex items-center gap-2 text-sm font-medium text-white/80 transition-colors hover:text-primary"
|
||||
dir="ltr"
|
||||
>
|
||||
<IconPhone className="h-4 w-4 text-primary" />
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
<CartButton light />
|
||||
<UserMenu user={user} isAdmin={isAdmin} light />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<CartButton light />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="باز کردن منو"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-white/20"
|
||||
>
|
||||
<span className="relative block h-3.5 w-5">
|
||||
<span
|
||||
className={`absolute inset-x-0 top-0 h-0.5 rounded bg-white transition-transform ${open ? "translate-y-[6px] rotate-45" : ""}`}
|
||||
/>
|
||||
<span className={`absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded bg-white transition-opacity ${open ? "opacity-0" : ""}`} />
|
||||
<span
|
||||
className={`absolute inset-x-0 bottom-0 h-0.5 rounded bg-white transition-transform ${open ? "-translate-y-[6px] -rotate-45" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-white/10 bg-secondary lg:hidden">
|
||||
<nav className="boxed flex flex-col gap-1 py-4">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/5 hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-2 flex flex-col gap-3 border-t border-white/10 pt-4">
|
||||
<a href={`tel:${CONTACT.phone}`} className="flex items-center gap-2 px-3 text-sm text-white/80" dir="ltr">
|
||||
<IconPhone className="h-4 w-4 text-primary" />
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
<div className="px-3">
|
||||
<UserMenu user={user} isAdmin={isAdmin} light />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { IconArrow } from "./icons";
|
||||
|
||||
export function PageHeader({ title, current }: { title: string; current: string }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-l from-secondary to-primary py-16 md:py-20">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1.5px, transparent 1.5px)",
|
||||
backgroundSize: "20px 20px",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="boxed relative text-center">
|
||||
<h1 className="text-3xl font-bold text-white md:text-4xl">{title}</h1>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-white/70">
|
||||
<Link href="/" className="hover:text-white">
|
||||
خانه
|
||||
</Link>
|
||||
<IconArrow className="h-3.5 w-3.5 -scale-x-100" />
|
||||
<span className="text-white">{current}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
// Lightweight stand-in for the original theme's WOW.js + animate.css
|
||||
// scroll-reveal (see old-site/assets/js/wow.min.js, assets/css/animate.css).
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`reveal ${visible ? "is-visible" : ""} ${className ?? ""}`}
|
||||
style={{ transitionDelay: visible ? `${delay}ms` : "0ms" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Stat = { value: number; suffix?: string; label: string };
|
||||
|
||||
function Counter({ value, suffix }: { value: number; suffix?: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
let started = false;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || started) return;
|
||||
started = true;
|
||||
const duration = 1200;
|
||||
const start = performance.now();
|
||||
|
||||
function tick(now: number) {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
setDisplay(Math.round(value * (1 - Math.pow(1 - progress, 3))));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ threshold: 0.4 },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span ref={ref}>
|
||||
{display.toLocaleString("fa-IR")}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsBar({ items }: { items: Stat[] }) {
|
||||
return (
|
||||
<section className="border-t border-line bg-white py-14">
|
||||
<div className="boxed grid grid-cols-2 gap-8 text-center md:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<p className="text-3xl font-bold text-primary md:text-4xl">
|
||||
<Counter value={item.value} suffix={item.suffix ?? "+"} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-body">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BUSINESS_URL, CUSTOMER_URL, customerLoginUrl } from "@/lib/site";
|
||||
import type { CustomerUser } from "@/lib/meshkee/types";
|
||||
import { IconChevronDown, IconUser } from "@/components/icons";
|
||||
|
||||
export function UserMenu({ user, isAdmin, light = false }: { user: CustomerUser | null; isAdmin: boolean; light?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<a
|
||||
href={customerLoginUrl()}
|
||||
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
ورود / ثبتنام
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = user.firstName ? `${user.firstName} عزیز` : "کاربر عزیز";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-2 rounded-full px-4 py-2.5 text-sm font-semibold transition-colors ${
|
||||
light ? "text-white/90 hover:bg-white/10" : "text-ink-soft hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<IconUser className="h-4 w-4 text-primary" />
|
||||
{displayName}
|
||||
<IconChevronDown className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute end-0 z-20 mt-2 w-48 overflow-hidden rounded-2xl bg-white py-2 shadow-xl ring-1 ring-line">
|
||||
<a href={`${CUSTOMER_URL}/profile`} className="block px-4 py-2.5 text-sm text-ink-soft hover:bg-surface hover:text-primary">
|
||||
پروفایل من
|
||||
</a>
|
||||
{isAdmin && (
|
||||
<a href={BUSINESS_URL} className="block px-4 py-2.5 text-sm text-ink-soft hover:bg-surface hover:text-primary">
|
||||
مدیریت وبسایت
|
||||
</a>
|
||||
)}
|
||||
<a href={`${CUSTOMER_URL}/logout`} className="block px-4 py-2.5 text-sm text-rose-600 hover:bg-rose-50">
|
||||
خروج
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IconArrow, IconCheck, IconPencil, IconSearch } from "@/components/icons";
|
||||
|
||||
const BADGES = [
|
||||
{ icon: IconPencil, text: "بیش از ۱۵۰ ساعت آموزش" },
|
||||
{ icon: IconSearch, text: "نظارت دقیق و ارزیابی عملکرد" },
|
||||
{ icon: IconCheck, text: "آموزش با کیفیت" },
|
||||
];
|
||||
|
||||
export function BioIntro() {
|
||||
return (
|
||||
<section className="overflow-hidden py-16 md:py-24">
|
||||
<div className="boxed grid items-center gap-14 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">وبسایت استاد علی عالمی</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">درباره ما بیشتر بدانید</h2>
|
||||
<p className="mt-5 text-base leading-8 text-body">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و
|
||||
متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و
|
||||
کاربردهای متنوع با هدف بهبود ابزارهای کاربردی میباشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و
|
||||
آینده شناخت فراوان جامعه و متخصصان را میطلبد.
|
||||
</p>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{BADGES.map(({ icon: Icon, text }) => (
|
||||
<li key={text} className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-white">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-ink-soft">{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/workshops"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
مشاهده دورههای آموزشی
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-md">
|
||||
<div className="absolute -inset-x-6 -top-6 bottom-10 rounded-[2.5rem] bg-primary-soft" aria-hidden />
|
||||
<div className="relative overflow-hidden rounded-[2.25rem] bg-secondary">
|
||||
<Image
|
||||
src="/images/hero/character.png"
|
||||
alt="استاد علی عالمی"
|
||||
width={480}
|
||||
height={560}
|
||||
className="mx-auto h-[420px] w-auto object-contain object-bottom pt-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -start-6 rounded-2xl bg-white px-6 py-4 text-center shadow-xl ring-1 ring-line">
|
||||
<p className="text-2xl font-bold text-primary">۱۴,۵۰۰+</p>
|
||||
<p className="text-xs font-medium text-ink-soft">نفر دانشجو</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { IconArrow } from "@/components/icons";
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "کلاسها به چه صورت برگزار میشود؟",
|
||||
a: "کلاسها هم بهصورت حضوری در منزل و هم بهصورت آنلاین برگزار میشود. بسته به شرایط و محل زندگیتان میتوانید هرکدام از این دو روش را انتخاب کنید.",
|
||||
},
|
||||
{
|
||||
q: "برای شروع کلاسها چه باید کرد؟",
|
||||
a: "کافیست از طریق دکمه «تماس با ما» یا شماره تماس درجشده در سایت با ما در ارتباط باشید تا زمان و نوع کلاس متناسب با نیاز شما هماهنگ شود.",
|
||||
},
|
||||
{
|
||||
q: "آیا امکان انتخاب زمان دلخواه برای کلاسها وجود دارد؟",
|
||||
a: "بله، زمانبندی کلاسها با هماهنگی و بر اساس برنامهی هفتگی شما تنظیم میشود.",
|
||||
},
|
||||
{
|
||||
q: "بستههای معلم خصوصی شامل چه مواردی میشود؟",
|
||||
a: "این بستهها شامل تدریس، رفع اشکال، برگزاری آزمونهای منظم و ارزیابی دورهای عملکرد دانشآموز است تا روند پیشرفت بهطور دقیق دنبال شود.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Faq() {
|
||||
const [open, setOpen] = useState(0);
|
||||
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed mx-auto max-w-3xl">
|
||||
<h2 className="text-center text-3xl font-bold text-ink">برخی از سوالات متداول</h2>
|
||||
|
||||
<div className="mt-10 space-y-3">
|
||||
{FAQS.map((item, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<div key={item.q} className="overflow-hidden rounded-2xl bg-white ring-1 ring-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? -1 : i)}
|
||||
className="flex w-full items-center justify-between gap-4 px-6 py-5 text-right"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-sm font-bold text-ink md:text-base">{item.q}</span>
|
||||
<IconArrow className={`h-4 w-4 shrink-0 text-primary transition-transform ${isOpen ? "-rotate-90" : "rotate-90"}`} />
|
||||
</button>
|
||||
{isOpen && <p className="px-6 pb-5 text-sm leading-7 text-body">{item.a}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FEATURES } from "@/components/home/features-data";
|
||||
|
||||
export function FeatureStrip() {
|
||||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="boxed">
|
||||
<h2 className="text-center text-3xl font-bold text-ink">ویژگیهای ما</h2>
|
||||
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map(({ icon: Icon, title, tone }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex items-center gap-3 rounded-xl border border-line px-5 py-4 transition-colors hover:border-primary/30 hover:bg-primary-soft/40"
|
||||
>
|
||||
<span
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-white ${
|
||||
tone === "primary" ? "bg-primary" : "bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-sm font-bold text-ink">{title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
IconAward,
|
||||
IconBuilding,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconGraduationCap,
|
||||
} from "@/components/icons";
|
||||
|
||||
const RESUME = [
|
||||
{ icon: IconCalendar, title: "تاریخ تولد", text: "۱۳۶۶/۱۲/۲۲" },
|
||||
{ icon: IconGraduationCap, title: "کارشناسی مهندسی مکانیک", text: "دانشگاه علم و صنعت ایران" },
|
||||
{ icon: IconGraduationCap, title: "کارشناسی ارشد مهندسی هوافضا", text: "گرایش سازههای هوایی، دانشگاه علم و صنعت ایران" },
|
||||
{ icon: IconAward, title: "رتبهی ۲۴۳ کنکور سراسری", text: "در گروه آزمایشی ریاضی و فنی" },
|
||||
{ icon: IconClock, title: "۱۳ سال سابقهی تدریس", text: "دروس ریاضی و حسابان کنکور" },
|
||||
{ icon: IconBuilding, title: "همکاری با برترین آموزشگاهها", text: "بهصورت حضوری و آنلاین" },
|
||||
];
|
||||
|
||||
export function Resume() {
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<p className="text-sm font-semibold text-primary">رزومه و تحصیلات</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">استاد علی عالمی را بیشتر بشناسید</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{RESUME.map(({ icon: Icon, title, text }) => (
|
||||
<div key={title} className="flex items-start gap-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-line">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-soft text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ink">{title}</p>
|
||||
<p className="mt-1 text-xs leading-6 text-body">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/components/providers/CartProvider";
|
||||
import { IconCart } from "@/components/icons";
|
||||
|
||||
export function CartButton({ light = false }: { light?: boolean }) {
|
||||
const { totalQuantity, open } = useCart();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
aria-label="سبد خرید"
|
||||
className={`relative flex h-10 w-10 items-center justify-center rounded-full transition-colors ${
|
||||
light ? "text-white/90 hover:bg-white/10" : "text-ink-soft hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<IconCart className="h-5 w-5" />
|
||||
{totalQuantity > 0 && (
|
||||
<span className="absolute -top-1 -end-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-white">
|
||||
{totalQuantity.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/components/providers/CartProvider";
|
||||
import { continueToCheckoutUrl } from "@/lib/cart";
|
||||
import { formatToman } from "@/lib/meshkee/format";
|
||||
import { IconClose, IconMinus, IconPlus } from "@/components/icons";
|
||||
|
||||
export function MiniCart() {
|
||||
const { items, isOpen, close, totalPrice, removeItem, setQuantity } = useCart();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex justify-end bg-ink/40" onClick={close}>
|
||||
<div
|
||||
className="flex h-full w-full max-w-sm flex-col bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 className="text-base font-bold text-ink">سبد خرید</h2>
|
||||
<button type="button" onClick={close} aria-label="بستن" className="text-ink-soft hover:text-primary">
|
||||
<IconClose className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-10 text-center text-sm text-muted">سبد خرید شما خالی است.</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex gap-3 border-b border-line pb-4">
|
||||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-surface">
|
||||
{item.image && (
|
||||
<Image src={item.image} alt={item.name} width={64} height={64} className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-ink">{item.name}</p>
|
||||
<p className="mt-1 text-xs text-primary">{formatToman(item.price)}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity(item.id, item.quantity - 1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-line text-ink-soft"
|
||||
aria-label="کم کردن تعداد"
|
||||
>
|
||||
<IconMinus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-5 text-center text-xs font-bold">{item.quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity(item.id, item.quantity + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-line text-ink-soft"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
<IconPlus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="ms-auto text-xs text-muted hover:text-rose-600"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-line px-5 py-4">
|
||||
<div className="mb-4 flex items-center justify-between text-sm">
|
||||
<span className="text-ink-soft">جمع کل</span>
|
||||
<span className="font-bold text-ink">{formatToman(totalPrice)}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={continueToCheckoutUrl(items)}
|
||||
className="block rounded-full bg-primary px-6 py-3.5 text-center text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
ادامه فرآیند خرید
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCart } from "@/components/providers/CartProvider";
|
||||
import { useToast } from "@/components/providers/ToastProvider";
|
||||
import { formatToman } from "@/lib/meshkee/format";
|
||||
import type { StoreItemVariant } from "@/lib/meshkee/types";
|
||||
import { IconMinus, IconPlus } from "@/components/icons";
|
||||
|
||||
function inStock(variant: StoreItemVariant) {
|
||||
return variant.stockQuantity === null || variant.stockQuantity === undefined || variant.stockQuantity > 0;
|
||||
}
|
||||
|
||||
export function AddToCartBar({
|
||||
variants,
|
||||
productName,
|
||||
image,
|
||||
}: {
|
||||
variants: StoreItemVariant[];
|
||||
productName: string;
|
||||
image: string | null;
|
||||
}) {
|
||||
const availableVariants = useMemo(() => variants.filter(inStock), [variants]);
|
||||
const [selectedId, setSelectedId] = useState(availableVariants[0]?.id ?? "");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const { addItem, open } = useCart();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const selected = availableVariants.find((v) => v.id === selectedId) ?? availableVariants[0];
|
||||
|
||||
if (availableVariants.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-surface px-5 py-4 text-center text-sm font-medium text-muted">
|
||||
این محصول در حال حاضر موجود نیست.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!selected) return;
|
||||
addItem({
|
||||
id: selected.id,
|
||||
name: [productName, selected.label].filter(Boolean).join(" - "),
|
||||
price: selected.discountedPrice ?? selected.price ?? 0,
|
||||
originalPrice: selected.discountedPrice ? selected.price : undefined,
|
||||
image,
|
||||
quantity,
|
||||
});
|
||||
showToast(`${productName} به سبد خرید اضافه شد.`);
|
||||
open();
|
||||
}
|
||||
|
||||
const price = selected?.discountedPrice ?? selected?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-surface p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl font-bold text-primary">{formatToman(price)}</span>
|
||||
{selected?.discountedPrice && selected.price && (
|
||||
<span className="text-sm text-muted line-through">{formatToman(selected.price)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableVariants.length > 1 && (
|
||||
<div>
|
||||
<label htmlFor="variant" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
انتخاب گزینه
|
||||
</label>
|
||||
<select
|
||||
id="variant"
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="w-full rounded-xl border border-line bg-white px-4 py-3 text-sm outline-none focus:border-primary"
|
||||
>
|
||||
{availableVariants.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.label || v.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 rounded-full border border-line px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-soft hover:bg-surface"
|
||||
>
|
||||
<IconMinus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-bold">{quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-soft hover:bg-surface"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="flex-1 rounded-full bg-primary px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
افزودن به سبد خرید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { customerLoginUrl } from "@/lib/site";
|
||||
import { postComment, type EntityType } from "@/lib/meshkee/comments";
|
||||
import { formatDate } from "@/lib/meshkee/format";
|
||||
import type { Comment, CustomerUser } from "@/lib/meshkee/types";
|
||||
import { useToast } from "@/components/providers/ToastProvider";
|
||||
|
||||
export function CommentsPanel({
|
||||
entityType,
|
||||
entityId,
|
||||
initialComments,
|
||||
user,
|
||||
token,
|
||||
}: {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
initialComments: Comment[];
|
||||
user: CustomerUser | null;
|
||||
token: string | null;
|
||||
}) {
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!user || !text.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const authorName = [user.firstName, user.lastName].filter(Boolean).join(" ") || "کاربر";
|
||||
const { comment } = await postComment(entityType, entityId, { authorName, text: text.trim() }, token);
|
||||
setComments((prev) => [comment, ...prev]);
|
||||
setText("");
|
||||
showToast("نظر شما ثبت شد و پس از تایید نمایش داده میشود.");
|
||||
} catch {
|
||||
showToast("ثبت نظر با خطا مواجه شد. دوباره تلاش کنید.", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{user ? (
|
||||
<form onSubmit={handleSubmit} className="mb-8 rounded-2xl bg-surface p-5">
|
||||
<label htmlFor="comment-text" className="mb-2 block text-sm font-bold text-ink">
|
||||
ثبت نظر
|
||||
</label>
|
||||
<textarea
|
||||
id="comment-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
required
|
||||
placeholder="نظر خود را بنویسید..."
|
||||
className="w-full resize-none rounded-xl border border-line bg-white px-4 py-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-3 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-60"
|
||||
>
|
||||
{submitting ? "در حال ارسال..." : "ارسال نظر"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mb-8 flex flex-col items-center gap-3 rounded-2xl bg-surface p-6 text-center">
|
||||
<p className="text-sm text-ink-soft">برای ثبت نظر ابتدا وارد حساب کاربری خود شوید.</p>
|
||||
<a
|
||||
href={customerLoginUrl(pathname)}
|
||||
className="rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
ورود به حساب کاربری
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-sm text-muted">هنوز نظری ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="border-b border-line pb-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-ink">{comment.authorName || "کاربر"}</p>
|
||||
<p className="text-xs text-muted">{formatDate(comment.createdAt)}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-7 text-body">{comment.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function EmptyState({ title, text }: { title: string; text?: string }) {
|
||||
return (
|
||||
<div className="boxed flex flex-col items-center py-24 text-center">
|
||||
<h2 className="text-xl font-bold text-ink">{title}</h2>
|
||||
{text && <p className="mt-3 max-w-md text-sm leading-7 text-body">{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { formatToman } from "@/lib/meshkee/format";
|
||||
|
||||
export function EntityCard({
|
||||
href,
|
||||
image,
|
||||
title,
|
||||
subtitle,
|
||||
price,
|
||||
originalPrice,
|
||||
}: {
|
||||
href: string;
|
||||
image: string | null;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
price?: number | null;
|
||||
originalPrice?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group block overflow-hidden rounded-2xl bg-white ring-1 ring-line transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="relative aspect-square w-full overflow-hidden bg-surface">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted">بدون تصویر</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="line-clamp-2 text-sm font-bold text-ink">{title}</h3>
|
||||
{subtitle && <p className="mt-1 line-clamp-1 text-xs text-muted">{subtitle}</p>}
|
||||
{price !== undefined && price !== null && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-primary">{formatToman(price)}</span>
|
||||
{originalPrice && originalPrice > price && (
|
||||
<span className="text-xs text-muted line-through">{formatToman(originalPrice)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { Navigation, Thumbs } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
import { IconClose, IconZoom } from "@/components/icons";
|
||||
|
||||
// Hover-zoom main image + thumbnail strip + a fullscreen lightbox on click.
|
||||
// No extra dependency: reuses Swiper (already in the project for carousels).
|
||||
function ZoomableImage({ src, alt, onOpen }: { src: string; alt: string; onOpen: () => void }) {
|
||||
const [pos, setPos] = useState({ x: 50, y: 50 });
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
|
||||
function handleMove(e: MouseEvent<HTMLDivElement>) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setPos({ x, y });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-2xl bg-surface"
|
||||
onMouseEnter={() => setZoomed(true)}
|
||||
onMouseLeave={() => setZoomed(false)}
|
||||
onMouseMove={handleMove}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-contain" />
|
||||
{zoomed && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 hidden bg-white bg-no-repeat md:block"
|
||||
style={{
|
||||
backgroundImage: `url(${src})`,
|
||||
backgroundSize: "220%",
|
||||
backgroundPosition: `${pos.x}% ${pos.y}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="absolute bottom-3 end-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-ink-soft opacity-0 shadow transition-opacity group-hover:opacity-100">
|
||||
<IconZoom className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({ images, startAt, alt, onClose }: { images: string[]; startAt: number; alt: string; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 p-4" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="absolute top-5 end-5 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
>
|
||||
<IconClose className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative h-[80vh] w-full max-w-4xl" onClick={(e) => e.stopPropagation()}>
|
||||
<Swiper
|
||||
modules={[Navigation]}
|
||||
navigation
|
||||
initialSlide={startAt}
|
||||
className="h-full w-full lightbox-swiper"
|
||||
>
|
||||
{images.map((src, i) => (
|
||||
<SwiperSlide key={i} className="flex items-center justify-center">
|
||||
<div className="relative h-full w-full">
|
||||
<Image src={src} alt={alt} fill sizes="90vw" className="object-contain" />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperClass | null>(null);
|
||||
|
||||
if (images.length === 0) {
|
||||
return <div className="aspect-square w-full rounded-2xl bg-surface" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ZoomableImage src={images[activeIndex]} alt={alt} onOpen={() => setLightboxOpen(true)} />
|
||||
|
||||
{images.length > 1 && (
|
||||
<Swiper
|
||||
modules={[Thumbs, Navigation]}
|
||||
onSwiper={setThumbsSwiper}
|
||||
watchSlidesProgress
|
||||
slidesPerView={4}
|
||||
spaceBetween={12}
|
||||
className="mt-4"
|
||||
>
|
||||
{images.map((src, i) => (
|
||||
<SwiperSlide key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveIndex(i);
|
||||
thumbsSwiper?.slideTo(i);
|
||||
}}
|
||||
className={`block aspect-square w-full overflow-hidden rounded-xl ring-2 transition-colors ${
|
||||
activeIndex === i ? "ring-primary" : "ring-transparent"
|
||||
}`}
|
||||
>
|
||||
<Image src={src} alt="" width={100} height={100} className="h-full w-full object-cover" />
|
||||
</button>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
)}
|
||||
|
||||
{lightboxOpen && (
|
||||
<Lightbox images={images} startAt={activeIndex} alt={alt} onClose={() => setLightboxOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SimilarRow({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="border-t border-line py-12">
|
||||
<div className="boxed">
|
||||
<h2 className="mb-6 text-2xl font-bold text-ink">{title}</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export function Tabs({ tabs }: { tabs: Array<{ key: string; label: string; content: ReactNode }> }) {
|
||||
const [active, setActive] = useState(tabs[0]?.key);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 border-b border-line">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActive(tab.key)}
|
||||
className={`relative px-5 py-3 text-sm font-bold transition-colors ${
|
||||
active === tab.key ? "text-primary" : "text-muted hover:text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{active === tab.key && <span className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="py-6">{tabs.find((t) => t.key === active)?.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TechnicalInfoResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export function TechnicalDataTable({ info }: { info: TechnicalInfoResponse | null }) {
|
||||
const fields = info?.form?.fields ?? [];
|
||||
const values = info?.values ?? [];
|
||||
|
||||
if (fields.length === 0 || values.length === 0) {
|
||||
return <p className="text-sm text-muted">مشخصات فنی برای این مورد ثبت نشده است.</p>;
|
||||
}
|
||||
|
||||
const rows = [...fields]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((field) => {
|
||||
const value = values.find((v) => v.fieldId === field.id);
|
||||
if (!value) return null;
|
||||
|
||||
let display = value.textValue;
|
||||
if (!display && value.optionId) {
|
||||
display = field.options?.find((o) => o.id === value.optionId)?.label;
|
||||
}
|
||||
if (!display && value.optionIds?.length) {
|
||||
display = value.optionIds
|
||||
.map((id) => field.options?.find((o) => o.id === id)?.label)
|
||||
.filter(Boolean)
|
||||
.join("، ");
|
||||
}
|
||||
|
||||
if (!display) return null;
|
||||
return { label: field.labelFa || field.label || field.labelEn || field.id, value: display };
|
||||
})
|
||||
.filter((row): row is { label: string; value: string } => Boolean(row));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-muted">مشخصات فنی برای این مورد ثبت نشده است.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full overflow-hidden rounded-2xl text-sm">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.label} className={i % 2 === 0 ? "bg-surface" : "bg-white"}>
|
||||
<td className="w-1/3 px-4 py-3 font-medium text-ink-soft">{row.label}</td>
|
||||
<td className="px-4 py-3 text-ink">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { IconCheck } from "@/components/icons";
|
||||
import { useToast } from "@/components/providers/ToastProvider";
|
||||
import { submitContactForm } from "@/lib/meshkee/contact";
|
||||
|
||||
export function ContactForm() {
|
||||
const [sent, setSent] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitContactForm({
|
||||
title: "پیام از فرم تماس با ما",
|
||||
name: String(data.get("name") ?? ""),
|
||||
cellNumber: String(data.get("phone") ?? ""),
|
||||
text: String(data.get("message") ?? ""),
|
||||
});
|
||||
showToast("پیام شما با موفقیت ارسال شد.");
|
||||
setSent(true);
|
||||
form.reset();
|
||||
setTimeout(() => setSent(false), 4000);
|
||||
} catch {
|
||||
showToast("ارسال پیام با خطا مواجه شد. لطفاً دوباره تلاش کنید.", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid gap-5 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-line md:grid-cols-2"
|
||||
>
|
||||
{sent && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-primary-soft px-5 py-4 md:col-span-2">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-white">
|
||||
<IconCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-soft">
|
||||
پیام شما ارسال شد. از اینکه با استاد علی عالمی در ارتباط هستید سپاسگزاریم.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
نام و نام خانوادگی
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||
placeholder="نام شما"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
شماره تماس
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||
placeholder="0912 000 0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="message" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
پیام شما
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-xl border border-line px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||
placeholder="متن پیام..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-60 md:w-auto"
|
||||
>
|
||||
{submitting ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Image from "next/image";
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { IconInstagram, IconLocation, IconPhone } from "@/components/icons";
|
||||
|
||||
const ITEMS = [
|
||||
{ icon: IconLocation, text: CONTACT.address, href: undefined },
|
||||
{ icon: IconPhone, text: CONTACT.phoneLabel, href: `tel:${CONTACT.phone}`, dir: "ltr" as const },
|
||||
{ icon: IconInstagram, text: CONTACT.instagramLabel, href: CONTACT.instagram, dir: "ltr" as const },
|
||||
];
|
||||
|
||||
export function ContactInfo() {
|
||||
return (
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="boxed grid items-center gap-14 md:grid-cols-2">
|
||||
<div className="relative mx-auto w-full max-w-md">
|
||||
<div className="absolute -inset-x-6 -top-6 bottom-10 rounded-[2.5rem] bg-primary-soft" aria-hidden />
|
||||
<Image
|
||||
src="/images/contact-photo.jpg"
|
||||
alt="ارتباط با استاد علی عالمی"
|
||||
width={520}
|
||||
height={600}
|
||||
className="relative w-full rounded-[2.25rem] object-cover shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">راههای ارتباطی</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">با ما تماس بگیرید</h2>
|
||||
<p className="mt-4 text-base leading-8 text-body">ما هر روز هفته آمادهی شنیدن نظرات شما هستیم.</p>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{ITEMS.map(({ icon: Icon, text, href, dir }) => (
|
||||
<li key={text} className="flex items-center gap-4 rounded-2xl bg-primary-soft p-4">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
{href ? (
|
||||
<a href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noopener noreferrer" dir={dir} className="text-sm font-medium text-ink-soft hover:text-primary">
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink-soft">{text}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CUSTOMER_URL } from "@/lib/site";
|
||||
import { IconArrow } from "@/components/icons";
|
||||
|
||||
export function CustomerClubBanner() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-l from-secondary to-primary py-14">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1.5px, transparent 1.5px)",
|
||||
backgroundSize: "20px 20px",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="boxed relative flex flex-col items-center justify-between gap-6 text-center md:flex-row md:text-right">
|
||||
<h2 className="text-2xl font-bold text-white md:text-3xl">در باشگاه مشتریان ما عضو شوید</h2>
|
||||
<a
|
||||
href={`${CUSTOMER_URL}/register`}
|
||||
className="flex shrink-0 items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-secondary transition-transform hover:scale-105"
|
||||
>
|
||||
عضویت رایگان
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { IconLocation } from "@/components/icons";
|
||||
import { ContactForm } from "./ContactForm";
|
||||
|
||||
export function MessageSection() {
|
||||
const mapsHref = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(CONTACT.address)}`;
|
||||
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<h2 className="text-center text-3xl font-bold text-ink">به ما پیام بفرستید!</h2>
|
||||
|
||||
<div className="mt-12 grid gap-8 lg:grid-cols-[1.4fr_1fr]">
|
||||
<ContactForm />
|
||||
|
||||
<a
|
||||
href={mapsHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative flex min-h-[280px] flex-col justify-between overflow-hidden rounded-3xl bg-secondary p-8 text-white transition-colors hover:bg-secondary-dark"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1.5px, transparent 1.5px)",
|
||||
backgroundSize: "18px 18px",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="relative flex h-12 w-12 items-center justify-center rounded-xl bg-white/10 text-primary">
|
||||
<IconLocation className="h-6 w-6" />
|
||||
</span>
|
||||
<div className="relative">
|
||||
<p className="text-lg font-bold">موقعیت مکانی ما</p>
|
||||
<p className="mt-2 text-sm leading-7 text-white/70">{CONTACT.address}</p>
|
||||
<p className="mt-4 text-xs font-semibold text-primary group-hover:underline">
|
||||
مشاهده در نقشه ←
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
import { Autoplay, Navigation } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { IconChevron } from "@/components/icons";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { FEATURES } from "./features-data";
|
||||
|
||||
// Cards + a right-hand heading/nav column, matching the original theme's
|
||||
// feature section exactly (Owl Carousel there, Swiper here per instruction).
|
||||
export function FeatureCards() {
|
||||
const swiperRef = useRef<SwiperClass | null>(null);
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="boxed grid gap-10 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<Reveal className="text-center md:text-right">
|
||||
<h2 className="text-3xl font-bold text-ink">ویژگیهای ما</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-body">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-3 md:justify-start">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="اسلاید بعدی"
|
||||
onClick={() => swiperRef.current?.slideNext()}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-line text-ink-soft transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<IconChevron className="h-4 w-4 -scale-x-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="اسلاید قبلی"
|
||||
onClick={() => swiperRef.current?.slidePrev()}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-line text-ink-soft transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<IconChevron className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100} className="min-w-0">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation]}
|
||||
slidesPerView={1}
|
||||
spaceBetween={24}
|
||||
autoplay={{ delay: 4500, disableOnInteraction: false }}
|
||||
onSwiper={(instance) => {
|
||||
swiperRef.current = instance;
|
||||
}}
|
||||
breakpoints={{ 640: { slidesPerView: 2 }, 1024: { slidesPerView: 3 } }}
|
||||
>
|
||||
{FEATURES.map(({ icon: Icon, title, text, tone }) => (
|
||||
<SwiperSlide key={title}>
|
||||
<div className="flex h-full flex-col rounded-2xl bg-white p-7 shadow-sm ring-1 ring-line">
|
||||
<span
|
||||
className={`flex h-16 w-16 items-center justify-center rounded-2xl text-white ${
|
||||
tone === "primary" ? "bg-primary" : "bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
</span>
|
||||
<h3 className="mt-6 text-lg font-bold text-ink">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-body">{text}</p>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-white">
|
||||
<div className="boxed grid items-center gap-10 py-14 md:grid-cols-2 md:gap-8 md:py-20">
|
||||
<Reveal className="relative z-10 text-center md:text-right">
|
||||
<h1 className="text-4xl font-bold leading-tight text-ink md:text-5xl">
|
||||
وبسایت
|
||||
<br />
|
||||
<span className="text-ink">استاد علی عالمی</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-6 max-w-xl text-base leading-8 text-body md:mx-0">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون
|
||||
بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع
|
||||
با هدف بهبود ابزارهای کاربردی میباشد.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
delay={150}
|
||||
className="relative mx-auto flex h-[360px] w-[360px] items-center justify-center md:h-[440px] md:w-[440px]"
|
||||
>
|
||||
{/* Big dotted ring backdrop — old-site/assets/files/RHLYjNnYj6.png (the accent dots are baked into the asset). */}
|
||||
<Image
|
||||
src="/images/hero/ring.png"
|
||||
alt=""
|
||||
width={560}
|
||||
height={560}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 h-full w-full animate-[spin_60s_linear_infinite]"
|
||||
/>
|
||||
|
||||
{/* Decorative floaters — positions mirror the original theme's slider frame (old-site/custom/fa/frames/public-slider-zI2Qc.php). */}
|
||||
<Image
|
||||
src="/images/hero/shape-1.png"
|
||||
alt=""
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden
|
||||
className="absolute top-2 start-10 h-14 w-14 opacity-70 md:h-16 md:w-16"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/paper-plane.png"
|
||||
alt=""
|
||||
width={90}
|
||||
height={70}
|
||||
aria-hidden
|
||||
className="absolute top-0 end-2 h-16 w-20 opacity-80 md:h-20 md:w-24"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/grad-cap.png"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
aria-hidden
|
||||
className="absolute top-1/2 -start-3 h-12 w-12 animate-bounce [animation-duration:3s] md:h-14 md:w-14"
|
||||
/>
|
||||
|
||||
{/* Second student peeking from behind the main character. */}
|
||||
<Image
|
||||
src="/images/hero/avatar-1.png"
|
||||
alt=""
|
||||
width={140}
|
||||
height={140}
|
||||
aria-hidden
|
||||
className="absolute bottom-6 end-2 z-[5] h-24 w-24 md:h-28 md:w-28"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/character.png"
|
||||
alt="استاد علی عالمی"
|
||||
width={420}
|
||||
height={520}
|
||||
priority
|
||||
className="relative z-10 h-[110%] w-auto max-w-none object-contain object-bottom drop-shadow-2xl"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/shape-2.png"
|
||||
alt=""
|
||||
width={60}
|
||||
height={60}
|
||||
aria-hidden
|
||||
className="absolute bottom-0 start-0 h-10 w-10 opacity-70"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
const ITEMS = [
|
||||
{ icon: "/images/icons/play.png", eyebrow: "TIP AND TEST", title: "کلاسهای نکته و تست" },
|
||||
{ icon: "/images/icons/exam.png", eyebrow: "TEST", title: "برگزاری آزمونهای منظم" },
|
||||
{ icon: "/images/icons/price.png", eyebrow: "PRICE", title: "تضمین قیمت" },
|
||||
{ icon: "/images/icons/support.png", eyebrow: "SUPPORT", title: "پشتیبانی دقیق" },
|
||||
];
|
||||
|
||||
export function QuickLinks() {
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<Reveal className="mx-auto max-w-lg text-center">
|
||||
<p className="text-sm leading-7 text-body">
|
||||
استاد علی عالمی، ۱۳ سال سابقهی تدریس دروس ریاضی و حسابان کنکور
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">چرا استاد علی عالمی؟</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{ITEMS.map((item, i) => (
|
||||
<Reveal key={item.title} delay={i * 100}>
|
||||
<div className="flex h-full flex-col items-center rounded-3xl bg-white px-8 py-10 text-center shadow-sm ring-1 ring-line transition-shadow hover:shadow-lg">
|
||||
<Image src={item.icon} alt="" width={64} height={64} className="h-16 w-16" />
|
||||
<p className="mt-6 text-lg font-bold text-ink">{item.title}</p>
|
||||
<p className="eyebrow-en mt-2 text-xs font-semibold text-muted">{item.eyebrow}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal delay={200} className="mt-12 text-center">
|
||||
<Link
|
||||
href="/about-us"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-secondary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-dark"
|
||||
>
|
||||
درباره ما بیشتر بدانید
|
||||
</Link>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Autoplay, Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
const REVIEWS = [
|
||||
{
|
||||
name: "احمدرضا عابدی",
|
||||
role: "دانشجو",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.",
|
||||
},
|
||||
{
|
||||
name: "احمدرضا عابدی",
|
||||
role: "دانشجو",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.",
|
||||
},
|
||||
{
|
||||
name: "احمدرضا عابدی",
|
||||
role: "دانشجو",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<Reveal className="flex flex-col items-center justify-between gap-6 text-center md:flex-row md:text-right">
|
||||
<div>
|
||||
<p className="text-sm text-body">نظرات برخی از دانشجویان مارا مشاهده کنید.</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">نظرات برخی از دانشجویان</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/workshops"
|
||||
className="shrink-0 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
مشاهده دورههای آموزشی
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100}>
|
||||
<Swiper
|
||||
modules={[Autoplay, Pagination]}
|
||||
spaceBetween={24}
|
||||
slidesPerView={1}
|
||||
pagination={{ clickable: true }}
|
||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||
breakpoints={{ 768: { slidesPerView: 2 }, 1024: { slidesPerView: 3 } }}
|
||||
className="testimonial-swiper mt-12"
|
||||
>
|
||||
{REVIEWS.map((review, i) => (
|
||||
<SwiperSlide key={i}>
|
||||
<div className="flex h-full flex-col rounded-2xl bg-white p-8 shadow-sm ring-1 ring-line">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src="/images/testimonial/avatar.png"
|
||||
alt={review.name}
|
||||
width={52}
|
||||
height={52}
|
||||
className="h-13 w-13 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ink">{review.name}</p>
|
||||
<p className="text-xs text-muted">{review.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-5 flex-1 text-sm leading-7 text-body">{review.text}</p>
|
||||
<Image
|
||||
src="/images/icons/quote.png"
|
||||
alt=""
|
||||
width={36}
|
||||
height={30}
|
||||
className="mt-4 h-7 w-auto self-end opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IconArrow, IconCheck, IconPencil, IconSearch } from "@/components/icons";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
export function WhyChooseUs() {
|
||||
return (
|
||||
<section className="overflow-hidden py-20 md:py-28">
|
||||
<div className="boxed grid items-center gap-20 md:grid-cols-2">
|
||||
<Reveal className="text-center md:text-right">
|
||||
<p className="text-sm font-semibold text-primary">وبسایت استاد علی عالمی</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">چرا مارا انتخاب کنید؟</h2>
|
||||
<p className="mt-5 text-base leading-8 text-body">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و
|
||||
متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و
|
||||
کاربردهای متنوع با هدف بهبود ابزارهای کاربردی میباشد.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/workshops"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
مشاهده دورههای آموزشی
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={150} className="relative mx-auto w-full max-w-md">
|
||||
<div className="relative overflow-hidden rounded-[2.25rem] bg-secondary">
|
||||
<Image
|
||||
src="/images/hero/character.png"
|
||||
alt="استاد علی عالمی در حال تدریس"
|
||||
width={480}
|
||||
height={560}
|
||||
className="mx-auto h-[420px] w-auto -scale-x-100 object-contain object-bottom pt-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating stat badges scattered around the image, matching the original layout. */}
|
||||
<div className="absolute -top-5 right-4 flex items-center gap-2 rounded-2xl bg-white px-4 py-3 shadow-xl ring-1 ring-line">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-white">
|
||||
<IconCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink-soft">آموزش با کیفیت</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/3 -left-8 flex max-w-[180px] items-center gap-2 rounded-2xl bg-white px-4 py-3 shadow-xl ring-1 ring-line">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-rose-500 text-white">
|
||||
<IconSearch className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink-soft">نظارت دقیق و ارزیابی عملکرد</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-5 right-1/3 flex items-center gap-2 rounded-2xl bg-white px-4 py-3 shadow-xl ring-1 ring-line">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-secondary text-white">
|
||||
<IconPencil className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink-soft">بیش از ۱۵۰ ساعت آموزش</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-6 -left-6 flex items-center gap-3 rounded-2xl bg-white px-5 py-4 shadow-xl ring-1 ring-line">
|
||||
<div className="flex -space-x-3 space-x-reverse">
|
||||
{["avatar-1", "avatar-2", "avatar-1"].map((src, i) => (
|
||||
<span key={i} className="block h-9 w-9 overflow-hidden rounded-full ring-2 ring-white">
|
||||
<Image src={`/images/hero/${src}.png`} alt="" width={36} height={36} className="h-full w-full object-cover" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-bold text-ink-soft">
|
||||
۱۴,۵۰۰
|
||||
<span className="block font-normal text-muted">نفر دانشجو</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IconHome, IconUser, IconWifi } from "@/components/icons";
|
||||
|
||||
export const FEATURES = [
|
||||
{
|
||||
icon: IconHome,
|
||||
title: "تدریس حضوری در منزل",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است",
|
||||
tone: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
title: "بستههای معلم خصوصی",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است",
|
||||
tone: "secondary" as const,
|
||||
},
|
||||
{
|
||||
icon: IconWifi,
|
||||
title: "تدریس خصوصی آنلاین",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است",
|
||||
tone: "primary" as const,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,312 @@
|
||||
type IconProps = { className?: string };
|
||||
|
||||
// Small line icons for feature/list items that had no matching asset in the
|
||||
// exported theme files. Single stroke, currentColor, so they inherit the
|
||||
// badge's text color.
|
||||
|
||||
export function IconLaptop({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="4" y="5" width="16" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M2.5 19h19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M9.5 16l-1 3M14.5 16l1 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHome({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 11.5 12 4l8 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 10v8.5a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M10 19.5V14h4v5.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCalendar({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="4" y="5.5" width="16" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M4 9.5h16" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M8 3.5v3.5M16 3.5v3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M8 13h3M8 16h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPackage({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M12 3.5 20 7.5v9L12 20.5 4 16.5v-9L12 3.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M4 7.5 12 11.5m0 0 8-4M12 11.5v9" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 20l.9-3.6L15.6 5.7a1.5 1.5 0 0 1 2.1 0l.6.6a1.5 1.5 0 0 1 0 2.1L7.6 19.1 4 20Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M14 7.5l2.5 2.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M15.2 15.2 20 20" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M5 12.5 9.5 17 19 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPhone({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M6.5 3.5h2.2l1.4 4-2 1.4a11 11 0 0 0 5 5l1.4-2 4 1.4v2.2a1.5 1.5 0 0 1-1.6 1.5A15.5 15.5 0 0 1 5 5.1a1.5 1.5 0 0 1 1.5-1.6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLocation({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M12 21s7-6.1 7-11.5A7 7 0 0 0 5 9.5C5 14.9 12 21 12 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="9.5" r="2.4" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconInstagram({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="17.2" cy="6.8" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWhatsapp({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M6.5 17.5 4.5 20l2.6-.9a8.3 8.3 0 1 0-3-3.1l2.4 1.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.3 9.6c.2-.6.9-1.5 1.5-1.1.6.4 1.1 1.6 1 2-.2.5-.7.6-.5 1.1.3.8 1.5 2 2.3 2.3.5.2.6-.3 1.1-.5.4-.1 1.6.4 2 1 .4.6-.5 1.3-1.1 1.5-1 .3-2.4-.3-3.8-1.7-1.4-1.4-2-2.8-1.5-4.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTelegram({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="m7.5 12.3 8-3.4-2.7 8-2-2.6-1.7 1.4-.4-2.4Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrow({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconAward({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="9" r="5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M9 13.2 7.5 20l4.5-2.4 4.5 2.4-1.5-6.8" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClock({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGraduationCap({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M2.5 9.5 12 5l9.5 4.5-9.5 4.5-9.5-4.5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M6.5 11.6v3.6c0 1.4 2.5 2.6 5.5 2.6s5.5-1.2 5.5-2.6v-3.6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M21.5 9.5v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBuilding({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="5" y="3.5" width="10" height="17" rx="1" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M15 9.5h4v11h-4" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M8 7h1M11 7h1M8 10.5h1M11 10.5h1M8 14h1M11 14h1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWifi({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M4 9.5c4.5-4 11.5-4 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M7 13.2c2.9-2.5 7.1-2.5 10 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10.2 16.8c1.1-.9 2.5-.9 3.6 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<circle cx="12" cy="19.3" r="1.1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevron({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCart({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 5h1.6l1.2 10.4a2 2 0 0 0 2 1.8h7.6a2 2 0 0 0 2-1.7L19.5 8.5H6.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="9.5" cy="20" r="1.3" fill="currentColor" />
|
||||
<circle cx="16.5" cy="20" r="1.3" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClose({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMinus({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconZoom({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M15.5 15.5 20 20" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10.5 8v5M8 10.5h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMessageCircle({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 12a8 8 0 1 1 3.2 6.4L4 19.5l1.1-3.1A7.9 7.9 0 0 1 4 12Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconUser({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="8.2" r="3.4" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M5 19c1-3 3.8-4.8 7-4.8s6 1.8 7 4.8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { cartTotalPrice, cartTotalQuantity, readGuestCart, writeGuestCart, type CartLine } from "@/lib/cart";
|
||||
|
||||
type CartContextValue = {
|
||||
items: CartLine[];
|
||||
totalQuantity: number;
|
||||
totalPrice: number;
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
addItem: (line: CartLine) => void;
|
||||
removeItem: (id: string) => void;
|
||||
setQuantity: (id: string, quantity: number) => void;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<CartLine[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
// Reading localStorage during the initial render would mismatch the
|
||||
// server-rendered (empty) markup, so the real cart loads post-mount —
|
||||
// deferred to a microtask so the effect body itself never calls setState
|
||||
// synchronously.
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setItems(readGuestCart());
|
||||
setHydrated(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
writeGuestCart(items);
|
||||
}, [items, hydrated]);
|
||||
|
||||
function addItem(line: CartLine) {
|
||||
setItems((prev) => {
|
||||
const existing = prev.find((item) => item.id === line.id);
|
||||
if (existing) {
|
||||
return prev.map((item) => (item.id === line.id ? { ...item, quantity: item.quantity + line.quantity } : item));
|
||||
}
|
||||
return [...prev, line];
|
||||
});
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
function setQuantity(id: string, quantity: number) {
|
||||
if (quantity <= 0) {
|
||||
removeItem(id);
|
||||
return;
|
||||
}
|
||||
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, quantity } : item)));
|
||||
}
|
||||
|
||||
const value: CartContextValue = {
|
||||
items,
|
||||
totalQuantity: cartTotalQuantity(items),
|
||||
totalPrice: cartTotalPrice(items),
|
||||
isOpen,
|
||||
open: () => setIsOpen(true),
|
||||
close: () => setIsOpen(false),
|
||||
addItem,
|
||||
removeItem,
|
||||
setQuantity,
|
||||
};
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { IconCheck } from "@/components/icons";
|
||||
|
||||
type Toast = { id: number; message: string; tone: "success" | "error" };
|
||||
|
||||
const ToastContext = createContext<{ showToast: (message: string, tone?: Toast["tone"]) => void } | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, tone: Toast["tone"] = "success") => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, tone }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[100] flex flex-col items-center gap-3 px-4">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex items-center gap-3 rounded-2xl px-5 py-3.5 text-sm font-medium text-white shadow-xl ${
|
||||
toast.tone === "success" ? "bg-secondary" : "bg-rose-600"
|
||||
}`}
|
||||
>
|
||||
{toast.tone === "success" && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary">
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { meshkeeFetch } from "./meshkee/client";
|
||||
import type { CustomerUser } from "./meshkee/types";
|
||||
|
||||
const ACCESS_COOKIE = "meshkee_customer_access_token";
|
||||
|
||||
// The dashboard may chunk long cookies as `name_0`, `name_1`, ... plus a
|
||||
// `name_n` count (see AI_PROMPT.md "Shared login" cookie contract).
|
||||
async function readChunkedCookie(name: string): Promise<string | null> {
|
||||
const store = await cookies();
|
||||
const direct = store.get(name)?.value;
|
||||
if (direct) return decodeURIComponent(direct);
|
||||
|
||||
const countRaw = store.get(`${name}_n`)?.value;
|
||||
const count = countRaw ? Number(countRaw) : 0;
|
||||
if (!count) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const part = store.get(`${name}_${i}`)?.value;
|
||||
if (!part) return null;
|
||||
parts.push(part);
|
||||
}
|
||||
return decodeURIComponent(parts.join(""));
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return readChunkedCookie(ACCESS_COOKIE);
|
||||
}
|
||||
|
||||
export type Session = { user: CustomerUser; token: string } | null;
|
||||
|
||||
// Best-effort admin check — the exact shape of `user` isn't documented
|
||||
// publicly; checks the common conventions. Verify against a live tenant and
|
||||
// trim the ones that never hit.
|
||||
export function isAdminUser(user: CustomerUser | undefined | null): boolean {
|
||||
if (!user) return false;
|
||||
return Boolean(
|
||||
user.isAdmin ||
|
||||
user.role === "admin" ||
|
||||
user.role === "owner" ||
|
||||
user.roles?.includes("admin") ||
|
||||
user.businessRoles?.length,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<Session> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const { user } = await meshkeeFetch<{ user: CustomerUser }>("/auth/me", {
|
||||
token,
|
||||
revalidate: false,
|
||||
});
|
||||
return { user, token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { CUSTOMER_URL, SITE_DOMAIN } from "./site";
|
||||
|
||||
export type CartLine = {
|
||||
id: string; // storeItemVariantId — never a product id or store-item id
|
||||
name: string;
|
||||
slug?: string;
|
||||
price: number;
|
||||
originalPrice?: number | null;
|
||||
image?: string | null;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "meshkee-guest-cart";
|
||||
const COOKIE_MAX_LENGTH = 3500;
|
||||
const COOKIE_MAX_AGE_DAYS = 30;
|
||||
|
||||
export function readGuestCart(): CartLine[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeGuestCart(items: CartLine[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
const json = JSON.stringify(items);
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, json);
|
||||
} catch {
|
||||
// ignore quota errors — the cookie/localStorage pair is best-effort
|
||||
}
|
||||
|
||||
try {
|
||||
const encoded = encodeURIComponent(json);
|
||||
if (encoded.length <= COOKIE_MAX_LENGTH) {
|
||||
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
|
||||
document.cookie = `${STORAGE_KEY}=${encoded}; Domain=.${SITE_DOMAIN}; Path=/; SameSite=Lax; Max-Age=${maxAge}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore — URL payload on Continue still carries the cart
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the dashboard's own encoder exactly (AI_PROMPT.md "Guest cart").
|
||||
export function encodeGuestCart(items: CartLine[]): string {
|
||||
const json = JSON.stringify(items);
|
||||
return btoa(unescape(encodeURIComponent(json)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function cartTotalQuantity(items: CartLine[]): number {
|
||||
return items.reduce((sum, line) => sum + line.quantity, 0);
|
||||
}
|
||||
|
||||
export function cartTotalPrice(items: CartLine[]): number {
|
||||
return items.reduce((sum, line) => sum + line.price * line.quantity, 0);
|
||||
}
|
||||
|
||||
function isLoggedIn(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
return document.cookie.includes("meshkee_customer_access_token");
|
||||
}
|
||||
|
||||
export function continueToCheckoutUrl(items: CartLine[]): string {
|
||||
const encoded = encodeGuestCart(items);
|
||||
if (isLoggedIn()) {
|
||||
return `${CUSTOMER_URL}/checkout/cart?guestCart=${encoded}`;
|
||||
}
|
||||
const redirect = encodeURIComponent(`/checkout/cart?guestCart=${encoded}`);
|
||||
return `${CUSTOMER_URL}/login?redirect=${redirect}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Slot catalog for GET /meshkee/static-image-slots (business-dashboard import).
|
||||
// Empty for now: home/about/contact are still fully static (no CMS-driven
|
||||
// images yet). Fill this in once a section's image becomes dashboard-editable
|
||||
// — see CONTEXT.md "Next steps".
|
||||
export const STATIC_IMAGE_SLOT_CATALOG: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "single" | "list";
|
||||
pageKey: string;
|
||||
aspectRatio: string;
|
||||
itemCount: number | null;
|
||||
recommendedWidth: number;
|
||||
}> = [];
|
||||
@@ -0,0 +1,14 @@
|
||||
import { tenantFetch } from "./client";
|
||||
import type { BlogDetailResponse, BlogListResponse } from "./types";
|
||||
|
||||
export function getBlogs(params: { page?: number; pageSize?: number; categoryId?: string; tag?: string } = {}) {
|
||||
return tenantFetch<BlogListResponse>("/blogs", { query: params });
|
||||
}
|
||||
|
||||
export function getBlogBySlug(slug: string) {
|
||||
return tenantFetch<BlogDetailResponse>(`/blogs/${slug}`);
|
||||
}
|
||||
|
||||
export function getBlogById(id: string) {
|
||||
return tenantFetch<BlogDetailResponse>(`/blogs/by-id/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { tenantFetch } from "./client";
|
||||
import type { Category } from "./types";
|
||||
|
||||
export function getCategories(params: { entityType?: string } = {}) {
|
||||
return tenantFetch<{ items: Category[] }>("/categories", { query: params });
|
||||
}
|
||||
|
||||
export function getCategoryById(id: string) {
|
||||
return tenantFetch<{ category: Category }>(`/categories/by-id/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { API_BASE, SITE_DOMAIN } from "@/lib/site";
|
||||
|
||||
export class MeshkeeApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "MeshkeeApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type FetchOptions = {
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
revalidate?: number | false;
|
||||
token?: string | null;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
function buildUrl(path: string, query?: FetchOptions["query"]) {
|
||||
const url = new URL(`${API_BASE}${path}`);
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Every public GET goes through here. `revalidate` defaults to a short
|
||||
// window so homepage/list content stays fresh without hammering the API on
|
||||
// every request (Next.js data cache, not the browser).
|
||||
export async function meshkeeFetch<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { query, revalidate = 60, token, method = "GET", body } = options;
|
||||
const url = buildUrl(path, query);
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
...(revalidate === false ? { cache: "no-store" as const } : { next: { revalidate } }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new MeshkeeApiError(res.status, text || res.statusText);
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// Tenant-scoped shorthand: GET /tenants/{domain}/{path}
|
||||
export function tenantFetch<T>(path: string, options?: FetchOptions): Promise<T> {
|
||||
return meshkeeFetch<T>(`/tenants/${SITE_DOMAIN}${path}`, options);
|
||||
}
|
||||
|
||||
// Swallow errors and return a fallback — for optional/best-effort data
|
||||
// (empty states instead of a broken page when a module isn't enabled yet).
|
||||
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
|
||||
try {
|
||||
return await promise;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { tenantFetch } from "./client";
|
||||
import type { Comment, CommentListResponse } from "./types";
|
||||
|
||||
export type EntityType = "product" | "blog" | "portfolio" | "video" | "workshop" | "store_item";
|
||||
|
||||
// The generic endpoint documents product|blog|portfolio|video; workshop and
|
||||
// store_item aren't confirmed there yet but are passed the same way — see
|
||||
// CONTEXT.md. Falls back to [] on any error so a missing entityType never
|
||||
// breaks the page.
|
||||
export function getComments(entityType: EntityType, entityId: string) {
|
||||
return tenantFetch<CommentListResponse>("/comments", {
|
||||
query: { entityType, entityId },
|
||||
revalidate: 30,
|
||||
});
|
||||
}
|
||||
|
||||
export function postComment(
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
data: { authorName: string; authorEmail?: string; text: string },
|
||||
token?: string | null,
|
||||
) {
|
||||
return tenantFetch<{ comment: Comment; message?: string }>("/comments", {
|
||||
method: "POST",
|
||||
revalidate: false,
|
||||
token,
|
||||
body: { entityType, entityId, ...data },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { tenantFetch } from "./client";
|
||||
|
||||
export type ContactSubmission = {
|
||||
title: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
cellNumber?: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function submitContactForm(data: ContactSubmission) {
|
||||
return tenantFetch<{ message?: string }>("/contact-submissions", {
|
||||
method: "POST",
|
||||
revalidate: false,
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MeshkeeImage } from "./types";
|
||||
|
||||
// Defensive accessors: the public API doc doesn't enumerate every field name,
|
||||
// so these try the common Meshkee conventions (nameFa/title/name...) in order
|
||||
// instead of assuming one exact shape. Verify against a live tenant once
|
||||
// connected and trim the ones that never hit.
|
||||
export function pickText(...values: Array<string | null | undefined>): string {
|
||||
for (const v of values) {
|
||||
if (v && v.trim()) return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function normalizeImage(image: string | MeshkeeImage | undefined | null): string | null {
|
||||
if (!image) return null;
|
||||
if (typeof image === "string") return image;
|
||||
return image.url ?? null;
|
||||
}
|
||||
|
||||
export function normalizeGallery(gallery: Array<string | MeshkeeImage> | undefined | null): string[] {
|
||||
if (!gallery) return [];
|
||||
return gallery.map(normalizeImage).filter((url): url is string => Boolean(url));
|
||||
}
|
||||
|
||||
export function formatToman(amount: number | null | undefined): string {
|
||||
if (amount === null || amount === undefined) return "";
|
||||
return `${amount.toLocaleString("fa-IR")} تومان`;
|
||||
}
|
||||
|
||||
// Farsi (and any other) letters/numbers are kept, spaces become `-`, every
|
||||
// other punctuation is stripped — per the canonical-URL slug rule. Never use
|
||||
// the legacy DB `slug` field for a public link; build it from the display
|
||||
// name (nameFa first) with this instead.
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function formatDate(value: string | undefined | null): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||
new Date(value),
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { tenantFetch } from "./client";
|
||||
import type { PortfolioDetailResponse, PortfolioListResponse } from "./types";
|
||||
|
||||
export function getPortfolios(params: { page?: number; pageSize?: number; categoryId?: string; tag?: string } = {}) {
|
||||
return tenantFetch<PortfolioListResponse>("/portfolios", { query: params });
|
||||
}
|
||||
|
||||
export function getPortfolioBySlug(slug: string) {
|
||||
return tenantFetch<PortfolioDetailResponse>(`/portfolios/${slug}`);
|
||||
}
|
||||
|
||||
export function getPortfolioById(id: string) {
|
||||
return tenantFetch<PortfolioDetailResponse>(`/portfolios/by-id/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { tenantFetch } from "./client";
|
||||
import type { ProductDetailResponse, ProductListResponse, TechnicalInfoResponse } from "./types";
|
||||
|
||||
export function getProducts(params: { page?: number; pageSize?: number; name?: string; categoryId?: string } = {}) {
|
||||
return tenantFetch<ProductListResponse>("/products", { query: params });
|
||||
}
|
||||
|
||||
export function getProductByIdOrSlug(idOrSlug: string) {
|
||||
return tenantFetch<ProductDetailResponse>(`/products/${idOrSlug}`);
|
||||
}
|
||||
|
||||
export function getProductById(id: string) {
|
||||
return tenantFetch<ProductDetailResponse>(`/products/by-id/${id}`);
|
||||
}
|
||||
|
||||
export function getProductTechnicalInfo(idOrSlug: string) {
|
||||
return tenantFetch<TechnicalInfoResponse>(`/products/${idOrSlug}/technical-info`);
|
||||
}
|
||||