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>
This commit is contained in:
amirhosein.ashourloo
2026-09-16 09:43:10 +03:30
co-authored by Claude Sonnet 5
commit 8f102a8250
106 changed files with 11605 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "ali-alemi",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+34
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+2
View File
@@ -0,0 +1,2 @@
@AGENTS.md
@CONTEXT.md
+147
View File
@@ -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.
+19
View File
@@ -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.
+18
View File
@@ -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;
+8
View File
@@ -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;
+6802
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+33
View File
@@ -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 />
</>
);
}
+83
View File
@@ -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>
</>
);
}
+48
View File
@@ -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>
</>
);
}
+22
View File
@@ -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 />
</>
);
}
+22
View File
@@ -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",
});
+285
View File
@@ -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;
}
}
+67
View File
@@ -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": "*",
},
},
);
}
+23
View File
@@ -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>
);
}
+26
View File
@@ -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: "آموزش زبان انگلیسی" },
]}
/>
</>
);
}
+123
View File
@@ -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>
)}
</>
);
}
+47
View File
@@ -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>
</>
);
}
+131
View File
@@ -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>
</>
);
}
+50
View File
@@ -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>
</>
);
}
+125
View File
@@ -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>
)}
</>
);
}
+52
View File
@@ -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>
</>
);
}
+121
View File
@@ -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>
</>
);
}
+46
View File
@@ -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>
</>
);
}
+106
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+22
View File
@@ -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,
},
];
+312
View File
@@ -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>
);
}
+83
View File
@@ -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;
}
+60
View File
@@ -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;
}
}
+81
View File
@@ -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}`;
}
+13
View File
@@ -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;
}> = [];
+14
View File
@@ -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}`);
}
+10
View File
@@ -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}`);
}
+70
View File
@@ -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;
}
}
+29
View File
@@ -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 },
});
}
+17
View File
@@ -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,
});
}
+52
View File
@@ -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 "";
}
}
+14
View File
@@ -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}`);
}
+18
View File
@@ -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`);
}

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