Initial commit: Alma Beauti Next.js site.
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,38 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.verify/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,18 @@
|
||||
Domain: brb-store.com
|
||||
Documentation for backend is:https://api.meshkee.com/docs/website
|
||||
|
||||
|
||||
Login or user icon should go to customer.{domainName}.com
|
||||
|
||||
|
||||
Create workshops page and details >> connect to workshops
|
||||
Details page:
|
||||
- use light box gallery
|
||||
- zoom image on hover
|
||||
- has two tabs in bottom, description and comments. Comment is for authenticated user if not show a login link button or box
|
||||
- and a registration row
|
||||
|
||||
Create blog page and details >> connect to blog
|
||||
|
||||
|
||||
update context md for better use in other Ai and systems - update it on every prompt (If required)
|
||||
@@ -0,0 +1,18 @@
|
||||
Wire this page to clinicsamen.com, static-image slots.
|
||||
|
||||
Read http://meshkee.com/docs/website (static-images). Env is already set.
|
||||
|
||||
Fetch slots, match by key:
|
||||
- slider > duplicatable
|
||||
- 4 people image > 4 slot
|
||||
- our-services-icon > 4 slot
|
||||
- two-banner-bg > 2 slot
|
||||
|
||||
|
||||
Use slot.images (all of them if kind=list, first if kind=single).
|
||||
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
|
||||
Use slot.aspectRatio from the API. Do not invent upload APIs.
|
||||
|
||||
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
|
||||
|
||||
update context md for better use in other Ai and systems - update it on every prompt (If required)
|
||||
@@ -0,0 +1,18 @@
|
||||
### Sitemap + robots.txt
|
||||
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
|
||||
- Live sitemap is an index: /sitemap.xml → /sitemap-main.xml + /sitemap-products.xml.
|
||||
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
|
||||
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
|
||||
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
|
||||
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
|
||||
- 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}
|
||||
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
|
||||
- After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages).
|
||||
|
||||
### On-page SEO (every public page)
|
||||
- Unique <title> and meta description on every crawlable page
|
||||
- Exactly one H1; H2/H3 for real sections
|
||||
- All content images must have meaningful alt (CMS title when available)
|
||||
- Prefer CMS fields for titles/descriptions; never leave Next defaults
|
||||
@@ -0,0 +1,18 @@
|
||||
Clone this website almabeauti.com
|
||||
Download and use banners,images and fonts from main website, icon and color Pallete from main website
|
||||
Logo from main website either
|
||||
|
||||
Pick data from current website - all data is valid
|
||||
Lang:fa
|
||||
Directions: rtl
|
||||
Use next js
|
||||
Color palette like website
|
||||
|
||||
Clone only homepage, about us, contact us and services pages
|
||||
|
||||
we will read blog from backend later
|
||||
|
||||
Footer Botton row (meshkee link) should be like https://meshkee.com/
|
||||
|
||||
update context md for better use in other Ai and systems - update it on every prompt (If required)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Alma Beauti — AI / Project Context
|
||||
|
||||
Persian RTL rebuild of **almabeauti.com** (کلینیک تخصصی پوست و زیبایی آلما بیوتی، قم). Keep this file current — other AI tools and later prompts read it first.
|
||||
|
||||
## Source of truth
|
||||
- Live site being cloned: https://www.almabeauti.com/
|
||||
- Prompt instructions: `Ai-data/prompt.txt`
|
||||
- Backend / Meshkee Website API: https://api.meshkee.com/docs/website
|
||||
- Static image slots prompt (later): `Ai-data/prompt-images-slot.txt`
|
||||
- SEO prompt: `Ai-data/prompt-seo.txt`
|
||||
- Domain: `almabeauti.com`
|
||||
|
||||
## Stack
|
||||
- Next.js 15 (App Router) + React 19 + TypeScript
|
||||
- Tailwind CSS v4 (`@import "tailwindcss"` in `src/app/globals.css`) + custom CSS
|
||||
- Language: `fa`, direction: `rtl`
|
||||
- Fonts (downloaded from Meshkee CDN into `public/fonts/`):
|
||||
- `yekan-bakh` (default Persian UI) — Light + Regular
|
||||
- `montserrat` (English kickers)
|
||||
|
||||
## Color palette (from live site CSS vars)
|
||||
- Primary: `#594AB8` (`--wb-primary-color`)
|
||||
- Secondary: `#7936FA` (`--wb-secondary-color`)
|
||||
- Page gray: `#F8F8F8`
|
||||
- Muted: `#6F6F6F` / `#ACACAC`
|
||||
- Navbar pill: `#E5E5E5`
|
||||
|
||||
## Routes
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `/` | Homepage |
|
||||
| `/about` | درباره ما (original `/aboutus`) |
|
||||
| `/contact` | تماس با ما (original `/contactus`) |
|
||||
| `/services` | خدمات — all 9 services as in-page sections with anchors |
|
||||
| `/blog` | مقالات (Meshkee blogs API) |
|
||||
| `/blog/{id}/{titleSlug}` | جزئیات مقاله |
|
||||
| `/blog/{id}` | Legacy redirect → canonical |
|
||||
|
||||
Individual service detail URLs (`/facial`, `/carboxytherapy`, …) still deferred — nav dropdown links to `/services#{slug}`.
|
||||
|
||||
## Meshkee API integration
|
||||
Base: `{NEXT_PUBLIC_MESHKEE_API_BASE}/tenants/{domain}/...`
|
||||
Helpers: `src/lib/meshkee.ts` → `meshkeeGet`, `tenantPath`, `getTenant`, `getBusinessId`
|
||||
Tenant: `almabeauti.com` (id `30`, modules: `products`, `blog`, `finance`)
|
||||
|
||||
### Blog
|
||||
- List: `GET /blogs?page=&pageSize=`
|
||||
- Detail: `GET /blogs/by-id/{id}` (canonical URL `/blog/{id}/{titleSlug}`)
|
||||
- Legacy redirect: `/blog/{id-or-slug}` → canonical
|
||||
- Comments: `GET /comments?entityType=blog&entityId={id}`
|
||||
- Lib: `src/lib/blog.ts`
|
||||
- Homepage preview: `src/components/ArticlesPreview.tsx` (3 latest posts)
|
||||
- Title images may be on `c804387.parspack.net` (allowed in `next.config.ts`)
|
||||
- If API is empty, fall back to static `articles` in `src/data/site.ts`
|
||||
|
||||
### Auth / customer portal
|
||||
- Login icon → `https://customer.almabeauti.com` (`customerPortalUrl` in `src/lib/config.ts`)
|
||||
- Admin dashboard: `https://business.almabeauti.com`
|
||||
- Session: `src/lib/session.tsx` + shared cookies (`src/lib/shared-auth-cookie.ts`)
|
||||
- Shared cookies: `meshkee_customer_access_token` / `meshkee_customer_refresh_token` (storefront + customer portal)
|
||||
- Profile API proxy: `GET /api/auth/me` → Meshkee `/auth/me`
|
||||
- Admin handoff: `POST /api/auth/handoff` → Meshkee `/auth/handoff` → `https://business.almabeauti.com`
|
||||
- Logged-in header: `{name} عزیز`; dropdown: پروفایل من، خروج، مدیریت وبسایت (admins only)
|
||||
- Component: `src/components/AccountMenu.tsx`
|
||||
|
||||
### Favicon
|
||||
- API: `GET /tenants/{domain}/website/favicon`
|
||||
- Helper: `src/lib/branding.ts` → `getWebsiteBranding`, `resolveFavicon`
|
||||
- Current tenant has no dedicated favicon (`hasDedicatedFavicon: false`) → fallback `/favicon.png`
|
||||
|
||||
## Original business data (do not invent replacements)
|
||||
- Phone: `09355036991` (footer `tel:` also used `09195196326` on the old site; display number is `09355036991`)
|
||||
- Hours: شنبه تا پنجشنبه، ۴ بعد از ظهر تا ۸:۳۰ شب
|
||||
- Address: بلوار شهید اخلاقی، بین بیست متری گلستان و بلوار شهید مفتح (محلهی بکایی)، پلاک ۵
|
||||
- Instagram: https://www.instagram.com/alma.skincares/
|
||||
- Telegram: https://t.me/almaskincares
|
||||
- Map embed: Google Maps iframe in `src/data/site.ts`
|
||||
|
||||
## Homepage order
|
||||
Hero slider → About → Price packages → Services grid (6) → Purple banner → Why Alma → Testimonials → Articles (Meshkee, local fallback) → Contact/map
|
||||
|
||||
## Content & assets
|
||||
- Copy, services, FAQs, phones, address, socials: `src/data/site.ts`
|
||||
- Logo: `public/images/logo.png` (from live theme)
|
||||
- Favicon: `public/favicon.png`
|
||||
- Banners / icons / service photos: `public/images/`
|
||||
- Fonts: `public/fonts/`
|
||||
|
||||
## Footer Meshkee credit
|
||||
- Bottom row matches https://meshkee.com/ (light bar, framed hover, “Designed And Developed By / Meshkee E-Commerce Team”)
|
||||
- Component: `src/components/Footer.tsx` → `.meshkee-credit`
|
||||
- Styles: `src/app/globals.css`
|
||||
- Links to `https://meshkee.com`
|
||||
- Logo: `public/images/footer/MeshkeeLogo.png`
|
||||
|
||||
## Important paths
|
||||
- Layout / shell: `src/app/layout.tsx`, `src/components/Providers.tsx`
|
||||
- Header + account: `src/components/Header.tsx`, `src/components/AccountMenu.tsx`
|
||||
- Footer: `src/components/Footer.tsx`
|
||||
- Home: `src/app/page.tsx`, `src/components/HeroSlider.tsx`, `src/components/AboutSection.tsx`
|
||||
- Hero + About share `container-fluid` (5rem inset ≥992px). About matches the live overlapping layout: vertical purple strip aligned with the slide counter, image overflowing into a white card, dotted grid on the left.
|
||||
- Blog: `src/app/blog/`, `src/components/ArticlesPreview.tsx`, `src/components/EntityTabs.tsx`
|
||||
- Config: `src/lib/config.ts`
|
||||
- Data: `src/data/site.ts`
|
||||
|
||||
## Env
|
||||
```bash
|
||||
NEXT_PUBLIC_WEBSITE_DOMAIN=almabeauti.com
|
||||
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
|
||||
# Optional overrides:
|
||||
# NEXT_PUBLIC_CUSTOMER_PORTAL_URL=https://customer.almabeauti.com
|
||||
# NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.almabeauti.com
|
||||
```
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run generate:sitemap-config
|
||||
```
|
||||
|
||||
## SEO canonical URLs
|
||||
- Blog: `/blog/{id}/{titleSlug}` → `GET /blogs/by-id/{id}`
|
||||
- Slugify helper: `src/lib/seo-slug.ts` (Farsi letters kept, spaces → `-`, punctuation stripped; do not use legacy DB slug in public URLs)
|
||||
- Page metadata helper: `src/lib/seo.ts` → `pageMetadata()` (absolute title, description, canonical, Open Graph, Twitter)
|
||||
- Exactly one `<h1>` per page (homepage: visually hidden brand in hero; inner pages: `PageHero`)
|
||||
- Content images use meaningful `alt` (CMS title when available)
|
||||
|
||||
## Sitemap + robots
|
||||
- **Do not** add `public/sitemap.xml` or `public/robots.txt` (nginx proxies to Meshkee API)
|
||||
- Live sitemap index: `/sitemap.xml` → `/sitemap-main.xml` + CMS URLs
|
||||
- Static pages config: `GET /meshkee/sitemap-config.json`
|
||||
- Generated at build: `scripts/generate-sitemap-config.mjs` → `src/lib/sitemap-config.generated.json`
|
||||
- Run manually: `npm run generate:sitemap-config`
|
||||
- After deploy: business dashboard → Website → Settings → Sync sitemap config
|
||||
|
||||
## Next planned prompts
|
||||
1. Wire static image slots (`Ai-data/prompt-images-slot.txt`)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "cdn.meshkee.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "c804387.parspack.net",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "alma-beauti",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.3.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Scans app page routes and writes src/lib/sitemap-config.generated.json
|
||||
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const appDir = path.join(root, "src", "app");
|
||||
const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json");
|
||||
|
||||
const EXCLUDE_SEGMENTS = new Set([
|
||||
"api",
|
||||
"meshkee",
|
||||
"admin",
|
||||
"auth",
|
||||
"login",
|
||||
"checkout",
|
||||
"cart",
|
||||
"account",
|
||||
]);
|
||||
|
||||
const PRIORITY = {
|
||||
"/": { changefreq: "daily", priority: 1.0 },
|
||||
"/services": { changefreq: "weekly", priority: 0.8 },
|
||||
"/blog": { changefreq: "weekly", priority: 0.8 },
|
||||
"/about": { changefreq: "monthly", priority: 0.6 },
|
||||
"/contact": { changefreq: "monthly", priority: 0.5 },
|
||||
};
|
||||
|
||||
function websiteDomain() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
|
||||
process.env.WEBSITE_DOMAIN ||
|
||||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ||
|
||||
"almabeauti.com"
|
||||
);
|
||||
}
|
||||
|
||||
function walkPages(dir, segments = []) {
|
||||
/** @type {string[]} */
|
||||
const pages = [];
|
||||
if (!fs.existsSync(dir)) return pages;
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
|
||||
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
|
||||
if (/^\(.+\)$/.test(entry.name)) {
|
||||
pages.push(...walkPages(full, segments));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pages.push(...walkPages(full, [...segments, entry.name]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
entry.name === "page.tsx" ||
|
||||
entry.name === "page.ts" ||
|
||||
entry.name === "page.jsx" ||
|
||||
entry.name === "page.js"
|
||||
) {
|
||||
const routePath = segments.length ? `/${segments.join("/")}` : "/";
|
||||
pages.push(routePath);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
|
||||
if (a === "/") return -1;
|
||||
if (b === "/") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const staticPages = paths.map((routePath) => {
|
||||
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
|
||||
return { path: routePath, ...meta };
|
||||
});
|
||||
|
||||
const config = {
|
||||
baseUrl: `https://${websiteDomain()}`,
|
||||
staticPages,
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import AboutSection from "@/components/AboutSection";
|
||||
import Faq from "@/components/Faq";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import PurpleBanner from "@/components/PurpleBanner";
|
||||
import Testimonials from "@/components/Testimonials";
|
||||
import { aboutPage, aboutWhyItems } from "@/data/site";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "درباره کلینیک تخصصی آلما بیوتی قم | مراقبت حرفهای از پوست و مو",
|
||||
description:
|
||||
"کلینیک آلما بیوتی قم با تیمی متخصص در زمینه پوست، مو و زیبایی، با بهرهگیری از جدیدترین تکنولوژیها خدمات تخصصی ارائه میدهد.",
|
||||
path: "/about",
|
||||
});
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero title="درباره ما" english="ABOUT US" />
|
||||
|
||||
<AboutSection
|
||||
image={aboutPage.image}
|
||||
titleLead={aboutPage.titleLead}
|
||||
titleAccent={aboutPage.titleAccent}
|
||||
english={aboutPage.english}
|
||||
body={aboutPage.body}
|
||||
ctaHref="/contact"
|
||||
ctaLabel="تماس با ما"
|
||||
accentColor="#7936FA"
|
||||
/>
|
||||
|
||||
<PurpleBanner
|
||||
title="آلمـــــــــــا بیوتــــــــــی (:"
|
||||
english="ALMA BEAUTY"
|
||||
image="/images/about/page-banner.png"
|
||||
ctaHref="/contact"
|
||||
ctaLabel="تماس با ما"
|
||||
/>
|
||||
|
||||
<section className="section why" style={{ background: "transparent" }}>
|
||||
<div className="container">
|
||||
<h2>چرا آلما بیوتی؟</h2>
|
||||
<div className="why-row">
|
||||
{aboutWhyItems.map((item) => (
|
||||
<div className="why-item" key={item.title}>
|
||||
<img src={item.image} alt={item.title} />
|
||||
<h3>{item.title}</h3>
|
||||
<p className="kicker latin">{item.english}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Testimonials title="نظر برخی از مراجعه کنندگان عزیز" />
|
||||
<Faq />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const res = await meshkeeFetch("/auth/handoff", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
|
||||
}
|
||||
|
||||
return jsonResponse(res.data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = getBearerToken(request);
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const res = await meshkeeFetch("/auth/me", { token });
|
||||
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
|
||||
return jsonResponse(res.data);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import EntityTabs from "@/components/EntityTabs";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { blogHref, blogImage, blogTitleSlug, fetchBlogById, getLocalBlogPost } from "@/lib/blog";
|
||||
import { fetchComments } from "@/lib/comments";
|
||||
import { formatFaDateLong } from "@/lib/format";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const apiPost = await fetchBlogById(id);
|
||||
if (apiPost) {
|
||||
return pageMetadata({
|
||||
title: apiPost.title,
|
||||
description: apiPost.abstract ?? apiPost.title,
|
||||
path: blogHref(apiPost),
|
||||
image: blogImage(apiPost),
|
||||
});
|
||||
}
|
||||
const local = getLocalBlogPost(id);
|
||||
if (!local) return { title: "مقاله" };
|
||||
return pageMetadata({
|
||||
title: local.title,
|
||||
description: local.abstract ?? local.title,
|
||||
path: blogHref(local),
|
||||
image: blogImage(local),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; slug: string }>;
|
||||
}) {
|
||||
const { id, slug } = await params;
|
||||
const apiPost = await fetchBlogById(id);
|
||||
|
||||
if (apiPost) {
|
||||
const canonical = blogTitleSlug(apiPost.title);
|
||||
if (decodeURIComponent(slug) !== canonical) {
|
||||
redirect(blogHref(apiPost));
|
||||
}
|
||||
|
||||
const comments = await fetchComments("blog", apiPost.id);
|
||||
const dateLabel = formatFaDateLong(apiPost.publishedAt || apiPost.createdAt);
|
||||
return (
|
||||
<>
|
||||
<PageHero title={apiPost.title} english="BLOG" />
|
||||
<section className="section">
|
||||
<div className="container detail-page">
|
||||
{dateLabel ? <p className="detail-date">{dateLabel}</p> : null}
|
||||
{apiPost.abstract ? <p className="detail-lead">{apiPost.abstract}</p> : null}
|
||||
{apiPost.mainTextHtml ? (
|
||||
<div className="rich-text" dangerouslySetInnerHTML={{ __html: apiPost.mainTextHtml }} />
|
||||
) : null}
|
||||
<EntityTabs showDescription={false} comments={comments} />
|
||||
<Link href="/blog" className="back-link">
|
||||
بازگشت به مقالات
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const local = getLocalBlogPost(id);
|
||||
if (!local) notFound();
|
||||
|
||||
const canonical = blogTitleSlug(local.title);
|
||||
if (decodeURIComponent(slug) !== canonical) {
|
||||
redirect(blogHref(local));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title={local.title} english="BLOG" />
|
||||
<section className="section">
|
||||
<div className="container detail-page">
|
||||
{local.dateLabel ? <p className="detail-date">{local.dateLabel}</p> : null}
|
||||
<div className="detail-image">
|
||||
<img src={blogImage(local)} alt={local.title} />
|
||||
</div>
|
||||
<Link href="/blog" className="back-link">
|
||||
بازگشت به مقالات
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { blogHref, fetchBlogById, fetchBlogBySlug, getLocalBlogPost } from "@/lib/blog";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
/** Legacy `/blog/{slug-or-id}` → canonical `/blog/{id}/{titleSlug}`. */
|
||||
export default async function LegacyBlogRedirect({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const byId = await fetchBlogById(id);
|
||||
if (byId) redirect(blogHref(byId));
|
||||
|
||||
const apiPost = await fetchBlogBySlug(id);
|
||||
if (apiPost) redirect(blogHref(apiPost));
|
||||
|
||||
const local = getLocalBlogPost(id);
|
||||
if (local) redirect(blogHref(local));
|
||||
|
||||
notFound();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { blogHref, blogImage, fetchBlogs, getLocalBlogPosts } from "@/lib/blog";
|
||||
import { faDigits, formatFaDateLong } from "@/lib/format";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: "مجلات زیبایی و آموزشی | آلما بیوتی",
|
||||
description: "مجلات زیبایی و آموزشی آلما بیوتی — آخرین مطالب مراقبت از پوست و درمانهای زیبایی",
|
||||
path: "/blog",
|
||||
});
|
||||
|
||||
export default async function BlogPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const { page: pageParam } = await searchParams;
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
const result = await fetchBlogs({ page, pageSize: 12 });
|
||||
const usingApi = result.items.length > 0;
|
||||
const posts = usingApi ? result.items : page === 1 ? getLocalBlogPosts() : [];
|
||||
const pageCount = usingApi
|
||||
? Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)))
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero title="مقالات" english="BLOG" />
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-title" style={{ textAlign: "center" }}>
|
||||
<h2>مجلات زیبایی و آموزشی</h2>
|
||||
<p>
|
||||
با الما بیوتی، در مسیر درمان و سلامت پوست خود قدمی موثر بردارید و زیبایی پوستتان را به
|
||||
اوج برسانید
|
||||
</p>
|
||||
</div>
|
||||
{posts.length === 0 ? (
|
||||
<p className="entity-tabs__empty" style={{ textAlign: "center" }}>
|
||||
مقالهای برای نمایش وجود ندارد.
|
||||
</p>
|
||||
) : (
|
||||
<div className="article-grid">
|
||||
{posts.map((post) => {
|
||||
const dateLabel = formatFaDateLong(post.publishedAt || post.createdAt) || post.dateLabel;
|
||||
return (
|
||||
<Link key={post.id} href={blogHref(post)} className="article-card">
|
||||
<div className="blog-img">
|
||||
<img src={blogImage(post)} alt={post.title} />
|
||||
{dateLabel ? <span className="blog-tag">{dateLabel}</span> : null}
|
||||
</div>
|
||||
<div className="meta">
|
||||
<h3>{post.title}</h3>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<div className="pagination">
|
||||
{Array.from({ length: pageCount }, (_, index) => {
|
||||
const number = index + 1;
|
||||
return (
|
||||
<Link
|
||||
key={number}
|
||||
href={`/blog?page=${number}`}
|
||||
className={number === page ? "active" : undefined}
|
||||
>
|
||||
{faDigits(number)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import ContactSection from "@/components/ContactSection";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "تماس با کلینیک آلما بیوتی قم | مشاوره تخصصی پوست و مو",
|
||||
description:
|
||||
"برای دریافت وقت مشاوره پوست و مو، اطلاع از آدرس کلینیک آلما بیوتی قم، شماره تماس، شبکههای اجتماعی و ساعات کاری با ما در ارتباط باشید.",
|
||||
path: "/contact",
|
||||
});
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero title="تماس با ما" english="CONTACT US" />
|
||||
<ContactSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import Footer from "@/components/Footer";
|
||||
import Header from "@/components/Header";
|
||||
import Providers from "@/components/Providers";
|
||||
import { site } from "@/data/site";
|
||||
import { getWebsiteBranding, resolveFavicon } from "@/lib/branding";
|
||||
import { websiteDomain } from "@/lib/config";
|
||||
import { getBusinessId } from "@/lib/meshkee";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const branding = await getWebsiteBranding();
|
||||
const favicon = resolveFavicon(branding);
|
||||
|
||||
return {
|
||||
metadataBase: new URL(`https://${websiteDomain}`),
|
||||
title: {
|
||||
default: "آلما بیوتی- مرجع تخصصی خدمات زیبایی پوست در قم",
|
||||
template: "%s | آلما بیوتی",
|
||||
},
|
||||
description: site.description,
|
||||
icons: {
|
||||
icon: favicon,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const businessId = await getBusinessId();
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body>
|
||||
<Providers businessId={businessId}>
|
||||
<div className="wrapper">
|
||||
<div className="page-lines" aria-hidden>
|
||||
<div className="page-lines-inner">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import sitemapConfig from "@/lib/sitemap-config.generated.json";
|
||||
|
||||
const headers = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Cache-Control": "public, max-age=60",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(sitemapConfig, { headers });
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import AboutSection from "@/components/AboutSection";
|
||||
import ArticlesPreview from "@/components/ArticlesPreview";
|
||||
import ContactSection from "@/components/ContactSection";
|
||||
import HeroSlider from "@/components/HeroSlider";
|
||||
import PurpleBanner from "@/components/PurpleBanner";
|
||||
import Testimonials from "@/components/Testimonials";
|
||||
import {
|
||||
homeAbout,
|
||||
homeBanner,
|
||||
homeServiceCards,
|
||||
pricePackages,
|
||||
site,
|
||||
whyItems,
|
||||
} from "@/data/site";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: "آلما بیوتی- مرجع تخصصی خدمات زیبایی پوست در قم",
|
||||
description: site.description,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<HeroSlider />
|
||||
|
||||
<AboutSection
|
||||
image={homeAbout.image}
|
||||
titleLead={homeAbout.titleLead}
|
||||
titleAccent={homeAbout.titleAccent}
|
||||
english={homeAbout.english}
|
||||
body={homeAbout.body}
|
||||
ctaHref="/about"
|
||||
ctaLabel="درباره مـــــــــــــا"
|
||||
/>
|
||||
|
||||
<section className="section prices">
|
||||
<div className="container">
|
||||
<div className="price-row">
|
||||
{pricePackages.map((pack) => (
|
||||
<article key={pack.title} className="price-card">
|
||||
<img src={pack.icon} alt={pack.title} />
|
||||
<h3>{pack.title}</h3>
|
||||
<p className="kicker latin">{pack.english}</p>
|
||||
<ul>
|
||||
{pack.items.map((item) => (
|
||||
<li key={item.name} className={item.highlight ? "highlight" : undefined}>
|
||||
<span className="name">{item.name}</span>
|
||||
<span className="value">{item.price}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-title" style={{ textAlign: "center" }}>
|
||||
<h2>خدمات تخصصی آلما بیوتی</h2>
|
||||
<p>
|
||||
ما طیف گستردهای از خدمات سلامت پوست و زیبایی پوست را ارائه میدهیم تا به شما کمک کنیم
|
||||
پوستی شاداب، سالم و درخشان داشته باشید
|
||||
</p>
|
||||
</div>
|
||||
<div className="service-grid">
|
||||
{homeServiceCards.map((service) => (
|
||||
<Link key={service.slug} href={service.href}>
|
||||
<img src={service.icon} alt={service.title} />
|
||||
<h3>{service.title}</h3>
|
||||
<p>{service.short}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PurpleBanner
|
||||
title={homeBanner.title}
|
||||
english={homeBanner.english}
|
||||
body={homeBanner.body}
|
||||
image={homeBanner.image}
|
||||
/>
|
||||
|
||||
<section className="section why">
|
||||
<div className="container">
|
||||
<h2>چرا آلما بیوتی؟</h2>
|
||||
<p className="kicker latin">WHY ALMA BEAUTY ?</p>
|
||||
<div className="why-row">
|
||||
{whyItems.map((item) => (
|
||||
<div className="why-item" key={item.title}>
|
||||
<img src={item.image} alt={item.title} />
|
||||
<h3>{item.title}</h3>
|
||||
<p className="kicker latin">{item.english}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="center-btn">
|
||||
<Link href="/about" className="btn-alma">
|
||||
درباره ما
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Testimonials />
|
||||
<ArticlesPreview />
|
||||
<ContactSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import ArticlesPreview from "@/components/ArticlesPreview";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { services } from "@/data/site";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = pageMetadata({
|
||||
title: "خدمات تخصصی پوست و مو در قم | کلینیک آلما بیوتی",
|
||||
description:
|
||||
"فیشیال، کربوکسی تراپی، پلاژن تراپی، مزونیدلینگ، میکروکارنت، ماساژ صورت، گرین پیل، هیدرودرمی و آنالیز پوست در کلینیک آلما بیوتی قم.",
|
||||
path: "/services",
|
||||
});
|
||||
|
||||
export default function ServicesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero title="خدمات" english="SERVICES" />
|
||||
{services.map((service) => (
|
||||
<section className="service-block" id={service.slug} key={service.slug}>
|
||||
<div className="container service-block-inner">
|
||||
<div>
|
||||
<h2>{service.pageTitle}</h2>
|
||||
<p>{service.pageBody}</p>
|
||||
</div>
|
||||
<img src={service.image} alt={service.title} />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<ArticlesPreview />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type AboutSectionProps = {
|
||||
image: string;
|
||||
titleLead: string;
|
||||
titleAccent: string;
|
||||
english: string;
|
||||
body: string;
|
||||
ctaHref: string;
|
||||
ctaLabel: string;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
export default function AboutSection({
|
||||
image,
|
||||
titleLead,
|
||||
titleAccent,
|
||||
english,
|
||||
body,
|
||||
ctaHref,
|
||||
ctaLabel,
|
||||
accentColor,
|
||||
}: AboutSectionProps) {
|
||||
return (
|
||||
<section className="aboutus" id="about">
|
||||
<div className="container-fluid">
|
||||
<div className="about-inner">
|
||||
<div className="about-left">
|
||||
<div className="ab-left-img">
|
||||
<img src={image} alt={`${titleLead} ${titleAccent}`.trim()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-right">
|
||||
<div className="about-right-inner">
|
||||
<h2>
|
||||
<span className="muted">{titleLead} </span>
|
||||
<span className="accent" style={accentColor ? { color: accentColor } : undefined}>
|
||||
{titleAccent}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="kicker latin">{english}</p>
|
||||
<p className="about-body">{body}</p>
|
||||
<Link href={ctaHref} className="btn-alma about-cta">
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
import { businessDashboardUrl, customerPortalUrl } from "@/lib/config";
|
||||
import { useSession, userDisplayName } from "@/lib/session";
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M5 19.2c1.6-3.2 4-4.7 7-4.7s5.4 1.5 7 4.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={open ? "account-menu__chevron open" : "account-menu__chevron"}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountMenu() {
|
||||
const { user, isAuthenticated, ready, isWebsiteAdmin, logout, getAccessToken } = useSession();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [handoffBusy, setHandoffBusy] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClickOutside = (event: Event) => {
|
||||
if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClickOutside);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (handoffBusy) return;
|
||||
|
||||
const baseUrl = businessDashboardUrl;
|
||||
const popup = window.open("about:blank", "_blank");
|
||||
setHandoffBusy(true);
|
||||
try {
|
||||
const token = getAccessToken();
|
||||
const res = await fetch("/api/auth/handoff", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error("handoff failed");
|
||||
const data = (await res.json()) as { ticket?: string };
|
||||
const url = new URL(baseUrl);
|
||||
if (data.ticket) url.searchParams.set("handoff", data.ticket);
|
||||
if (popup) {
|
||||
popup.opener = null;
|
||||
popup.location.replace(url.toString());
|
||||
} else {
|
||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch {
|
||||
if (popup) popup.location.replace(baseUrl);
|
||||
else window.open(baseUrl, "_blank", "noopener,noreferrer");
|
||||
} finally {
|
||||
setHandoffBusy(false);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<span className="account-menu account-menu--loading" aria-hidden>
|
||||
<UserIcon />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<a href={customerPortalUrl} className="account-menu account-menu--login" aria-label="ورود">
|
||||
<UserIcon />
|
||||
<span>ورود</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = userDisplayName(user);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="account-menu account-menu--user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="account-menu__trigger"
|
||||
>
|
||||
<UserIcon />
|
||||
<span className="account-menu__name">{displayName} عزیز</span>
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div role="menu" className="account-menu__dropdown">
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${customerPortalUrl}/profile`}
|
||||
className="account-menu__item"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
پروفایل من
|
||||
</a>
|
||||
{isWebsiteAdmin ? (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={businessDashboardUrl}
|
||||
className="account-menu__item"
|
||||
onClick={openBusinessDashboard}
|
||||
>
|
||||
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="account-menu__item account-menu__logout"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { blogHref, blogImage, fetchBlogs, getLocalBlogPosts } from "@/lib/blog";
|
||||
import { formatFaDateLong } from "@/lib/format";
|
||||
|
||||
export default async function ArticlesPreview() {
|
||||
const result = await fetchBlogs({ page: 1, pageSize: 3 });
|
||||
const posts = result.items.length > 0 ? result.items.slice(0, 3) : getLocalBlogPosts().slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className="section" id="articles">
|
||||
<div className="container">
|
||||
<div className="section-title" style={{ textAlign: "center" }}>
|
||||
<h2>مجلات زیبایی و آموزشی</h2>
|
||||
<p>
|
||||
با الما بیوتی، در مسیر درمان و سلامت پوست خود قدمی موثر بردارید و زیبایی پوستتان را به
|
||||
اوج برسانید
|
||||
</p>
|
||||
</div>
|
||||
<div className="article-grid">
|
||||
{posts.map((post) => {
|
||||
const dateLabel = formatFaDateLong(post.publishedAt || post.createdAt) || post.dateLabel;
|
||||
return (
|
||||
<Link key={post.id} href={blogHref(post)} className="article-card">
|
||||
<div className="blog-img">
|
||||
<img src={blogImage(post)} alt={post.title} />
|
||||
{dateLabel ? <span className="blog-tag">{dateLabel}</span> : null}
|
||||
</div>
|
||||
<div className="meta">
|
||||
<h3>{post.title}</h3>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="center-btn">
|
||||
<Link href="/blog" className="btn-alma">
|
||||
مشاهده بقیه مقالات
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { site } from "@/data/site";
|
||||
|
||||
export default function ContactSection() {
|
||||
return (
|
||||
<section className="contact-block" id="contact">
|
||||
<div className="container contact-grid">
|
||||
<div className="contact-info">
|
||||
<h2>آدرس</h2>
|
||||
<p className="kicker latin">ADDRESS</p>
|
||||
<div className="contact-row">
|
||||
<span>◷</span>
|
||||
<p>{site.hours}</p>
|
||||
</div>
|
||||
<div className="contact-row">
|
||||
<span>📍</span>
|
||||
<a href={site.mapsUrl} target="_blank" rel="noreferrer">
|
||||
{site.address}
|
||||
</a>
|
||||
</div>
|
||||
<div className="contact-row">
|
||||
<span>☎</span>
|
||||
<a href={`tel:${site.phoneTel}`} dir="ltr">
|
||||
{site.phoneDisplay}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contact-map">
|
||||
<iframe
|
||||
src={site.mapEmbed}
|
||||
title="موقعیت آلما بیوتی روی نقشه"
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { customerPortalUrl } from "@/lib/config";
|
||||
import type { CommentItem } from "@/lib/comments";
|
||||
import { formatFaDate } from "@/lib/format";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function EntityTabs({
|
||||
descriptionHtml,
|
||||
comments,
|
||||
showDescription = true,
|
||||
}: {
|
||||
descriptionHtml?: string | null;
|
||||
comments: CommentItem[];
|
||||
showDescription?: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"description" | "comments">(showDescription ? "description" : "comments");
|
||||
const { isAuthenticated, ready } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDescription) setTab("comments");
|
||||
}, [showDescription]);
|
||||
|
||||
return (
|
||||
<section className="entity-tabs">
|
||||
{showDescription ? (
|
||||
<div className="entity-tabs__nav">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("description")}
|
||||
className={tab === "description" ? "active" : undefined}
|
||||
>
|
||||
توضیحات
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("comments")}
|
||||
className={tab === "comments" ? "active" : undefined}
|
||||
>
|
||||
نظرات
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="entity-tabs__title">نظرات</h2>
|
||||
)}
|
||||
|
||||
{tab === "description" ? (
|
||||
descriptionHtml ? (
|
||||
<div className="rich-text entity-tabs__body" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||
) : (
|
||||
<p className="entity-tabs__empty">توضیحاتی ثبت نشده است.</p>
|
||||
)
|
||||
) : (
|
||||
<div className="entity-tabs__body">
|
||||
{comments.length === 0 ? (
|
||||
<p className="entity-tabs__empty">نظری ثبت نشده است.</p>
|
||||
) : (
|
||||
<div className="comment-list">
|
||||
{comments.map((comment, index) => (
|
||||
<article key={comment.id ?? index} className="comment-item">
|
||||
<p className="comment-item__author">{comment.authorName || "کاربر"}</p>
|
||||
{comment.createdAt ? (
|
||||
<p className="comment-item__date">{formatFaDate(comment.createdAt)}</p>
|
||||
) : null}
|
||||
<p className="comment-item__text">{comment.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ready && !isAuthenticated ? (
|
||||
<div className="login-prompt">
|
||||
<p className="login-prompt__title">برای ثبت نظر وارد شوید</p>
|
||||
<p className="login-prompt__text">نظرات فقط برای کاربران واردشده فعال است.</p>
|
||||
<a href={customerPortalUrl} className="btn-alma">
|
||||
ورود
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { faqs } from "@/data/site";
|
||||
|
||||
export default function Faq() {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container faq-grid">
|
||||
<img src="/images/about/faq.jpg" alt="سوالات متداول آلما بیوتی" />
|
||||
<div>
|
||||
<h2>سوالات متداول</h2>
|
||||
<p>سوالات متداول شما مراجعه کنندگان عزیز</p>
|
||||
{faqs.map((item, index) => (
|
||||
<div className="faq-item" key={item.q}>
|
||||
<button type="button" onClick={() => setOpen(open === index ? null : index)}>
|
||||
{item.q}
|
||||
</button>
|
||||
{open === index ? <p>{item.a}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { services, site } from "@/data/site";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="container footer-grid">
|
||||
<div>
|
||||
<Link href="/" className="foot-logo">
|
||||
<Image src="/images/logo.png" alt={site.name} width={140} height={48} />
|
||||
</Link>
|
||||
<p>{site.footerAbout}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>خدمات</h3>
|
||||
<ul>
|
||||
{services
|
||||
.filter((service) => service.slug !== "analysis")
|
||||
.map((service) => (
|
||||
<li key={service.slug}>
|
||||
<Link href={service.href}>{service.footerLabel}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>ارتباط با ما</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href={`tel:${site.phoneTel}`} dir="ltr">
|
||||
{site.phoneDisplay}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.mapsUrl} target="_blank" rel="noreferrer">
|
||||
{site.address}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom">
|
||||
<div className="footer-bottom-bar">
|
||||
<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>
|
||||
<Image
|
||||
src="/images/footer/MeshkeeLogo.png"
|
||||
alt="Meshkee"
|
||||
className="meshkee-credit__logo"
|
||||
width={36}
|
||||
height={32}
|
||||
/>
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import AccountMenu from "@/components/AccountMenu";
|
||||
import { navLinks, services, site } from "@/data/site";
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="header-shell">
|
||||
<Link href="/" className="logo-link" aria-label={site.name}>
|
||||
<Image src="/images/logo.png" alt={site.name} width={160} height={56} priority />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="nav-toggle"
|
||||
aria-label="منو"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<nav className={`main-nav${open ? " open" : ""}`}>
|
||||
{navLinks.map((link) => {
|
||||
if (link.hasDropdown) {
|
||||
return (
|
||||
<div
|
||||
key={link.href}
|
||||
className={`nav-drop${dropOpen ? " open" : ""}`}
|
||||
onMouseEnter={() => setDropOpen(true)}
|
||||
onMouseLeave={() => setDropOpen(false)}
|
||||
>
|
||||
<Link href="/services" onClick={() => setOpen(false)}>
|
||||
{link.label} ▾
|
||||
</Link>
|
||||
<div className="dropdown">
|
||||
<Link href="/services" onClick={() => setOpen(false)}>
|
||||
همه خدمات
|
||||
</Link>
|
||||
{services.map((service) => (
|
||||
<Link
|
||||
key={service.slug}
|
||||
href={service.href}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{service.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active =
|
||||
link.href === "/"
|
||||
? pathname === "/"
|
||||
: link.href.startsWith("/#")
|
||||
? false
|
||||
: pathname.startsWith(link.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={active ? "active" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="header-actions">
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { site, slides } from "@/data/site";
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
export default function HeroSlider() {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setIndex((current) => (current + 1) % slides.length);
|
||||
}, 5500);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const prev = () => setIndex((current) => (current - 1 + slides.length) % slides.length);
|
||||
const next = () => setIndex((current) => (current + 1) % slides.length);
|
||||
|
||||
return (
|
||||
<section className="hero container-fluid">
|
||||
<h1 className="sr-only">
|
||||
{site.name} — {site.tagline}
|
||||
</h1>
|
||||
<div className="hero-slider">
|
||||
{slides.map((slide, i) => (
|
||||
<div
|
||||
key={slide.image}
|
||||
className={`hero-slide${i === index ? " active" : ""}`}
|
||||
style={{ backgroundImage: `url(${slide.image})` }}
|
||||
role="img"
|
||||
aria-label={slide.alt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hero-bar">
|
||||
<div className="hero-scroll">
|
||||
<span>اسکرول کنید</span>
|
||||
<a href="#about" className="circle-btn" aria-label="اسکرول به درباره ما">
|
||||
↓
|
||||
</a>
|
||||
</div>
|
||||
<div className="hero-count" dir="ltr">
|
||||
<b>{pad(index + 1)}</b>
|
||||
/
|
||||
<span>{pad(slides.length)}</span>
|
||||
</div>
|
||||
<div className="hero-tools">
|
||||
<div className="hero-arrows">
|
||||
<button type="button" className="circle-btn" onClick={prev} aria-label="اسلاید قبلی">
|
||||
›
|
||||
</button>
|
||||
<button type="button" className="circle-btn" onClick={next} aria-label="اسلاید بعدی">
|
||||
‹
|
||||
</button>
|
||||
</div>
|
||||
<div className="hero-social">
|
||||
<a href={site.telegram} target="_blank" rel="noreferrer" aria-label="تلگرام">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.5 3.2 18.3 20.3c-.24 1.05-.86 1.3-1.74.81l-4.82-3.55-2.32 2.23c-.26.26-.47.47-.97.47l.35-4.9L17.7 6.4c.38-.33-.08-.52-.59-.19L7.2 13.7 2.45 12.2c-1.03-.32-1.05-1.03.22-1.53L20.2 3c.86-.33 1.61.2 1.3.2z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href={site.instagram} target="_blank" rel="noreferrer" aria-label="اینستاگرام">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.4" cy="6.6" r="0.8" fill="currentColor" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PageHero({
|
||||
title,
|
||||
english,
|
||||
}: {
|
||||
title: string;
|
||||
english?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-hero">
|
||||
<div className="container page-hero-inner">
|
||||
<div className="page-hero-title">
|
||||
<h1>{title}</h1>
|
||||
{english ? <span className="latin">{english}</span> : null}
|
||||
</div>
|
||||
<ul className="crumbs">
|
||||
<li>
|
||||
<Link href="/">صفحه اصلی</Link>
|
||||
</li>
|
||||
<li>
|
||||
<span>{title}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "@/lib/session";
|
||||
|
||||
export default function Providers({
|
||||
businessId,
|
||||
children,
|
||||
}: {
|
||||
businessId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider businessId={businessId}>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type PurpleBannerProps = {
|
||||
title: string;
|
||||
english: string;
|
||||
body?: string;
|
||||
image: string;
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
};
|
||||
|
||||
export default function PurpleBanner({
|
||||
title,
|
||||
english,
|
||||
body,
|
||||
image,
|
||||
ctaHref,
|
||||
ctaLabel,
|
||||
}: PurpleBannerProps) {
|
||||
return (
|
||||
<section className="video" id="banner">
|
||||
<div className="container-fluid">
|
||||
<div className="video-row">
|
||||
<div className="video-col">
|
||||
<div className="video-left-detail">
|
||||
<h2>{title}</h2>
|
||||
<p className="video-kicker latin">{english}</p>
|
||||
{body ? <p className="vdo-text">{body}</p> : null}
|
||||
{ctaHref && ctaLabel ? (
|
||||
<Link href={ctaHref} className="btn-alma btn-alma-black">
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-col">
|
||||
<div className="video-img">
|
||||
<img src={image} alt={title} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { testimonials } from "@/data/site";
|
||||
|
||||
function QuoteIcon() {
|
||||
return (
|
||||
<svg className="quote-icon" width="29" height="29" viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.2 19.2c-1.7 0-3.1-.6-4.1-1.7S1.5 14.8 1.5 13c0-2 .5-3.8 1.6-5.5S5.7 4.5 7.8 3.6l.9 1.6c-1.5.7-2.6 1.6-3.3 2.8-.7 1.1-1 2.3-1 3.5 0 .4.1.6.4.7.7.2 1.3.6 1.8 1.1.5.5.8 1.2.8 2 0 1-.3 1.8-1 2.5-.7.7-1.5 1-2.2 1.4zm11.2 0c-1.7 0-3.1-.6-4.1-1.7s-1.6-2.7-1.6-4.5c0-2 .5-3.8 1.6-5.5s2.6-3 4.7-3.9l.9 1.6c-1.5.7-2.6 1.6-3.3 2.8-.7 1.1-1 2.3-1 3.5 0 .4.1.6.4.7.7.2 1.3.6 1.8 1.1.5.5.8 1.2.8 2 0 1-.3 1.8-1 2.5-.6.7-1.4 1.4-2.2 1.4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Testimonials({ title }: { title?: string }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const visible = [0, 1, 2].map((offset) => {
|
||||
const item = testimonials[(index + offset) % testimonials.length];
|
||||
return { item, key: `${index}-${offset}` };
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="testimonial-main-container" id="testimonial">
|
||||
<div className="container-fluid">
|
||||
<div className="section-title" style={{ textAlign: "center" }}>
|
||||
<h2>{title || "نظر برخی مراجعه کنندگان عزیز"}</h2>
|
||||
<p className="kicker latin">USER COMMENTS</p>
|
||||
</div>
|
||||
|
||||
<div className="testimonial-slider">
|
||||
<div className="testimonial-track">
|
||||
{visible.map(({ item, key }) => (
|
||||
<article className="testimonial-inner" key={key}>
|
||||
<div className="testimonial-right-inner">
|
||||
<QuoteIcon />
|
||||
<p>{item.text}</p>
|
||||
<cite>{item.author}</cite>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="testimonial-dots">
|
||||
{testimonials.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={i === index ? "active" : undefined}
|
||||
aria-label={`نظر ${i + 1}`}
|
||||
onClick={() => setIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
export const site = {
|
||||
name: "آلما بیوتی",
|
||||
nameEn: "ALMA BEAUTY",
|
||||
tagline: "مرجع تخصصی خدمات زیبایی پوست در قم",
|
||||
description:
|
||||
"آلما بیوتی، کلینیک تخصصی زیبایی پوست و انجام دهنده انواع خدمات فیشیال، مزونیدلینگ، کربوکسی تراپی و ... در قم",
|
||||
phoneDisplay: "09355036991",
|
||||
phoneTel: "09355036991",
|
||||
hours: "شنبه تا پنجشنبه، ۴ بعد از ظهر تا ۸:۳۰ شب",
|
||||
address:
|
||||
"بلوار شهید اخلاقی، بین بیست متری گلستان و بلوار شهید مفتح (محلهی بکایی)، پلاک ۵",
|
||||
mapsUrl:
|
||||
"https://www.google.com/maps/place/%D8%B3%D8%A7%D8%AE%D8%AA%D9%85%D8%A7%D9%86+%D9%BE%D8%B2%D8%B4%DA%A9%D8%A7%D9%86+%D9%87%D9%88%D8%B1/@34.6189788,50.846387,18z",
|
||||
mapEmbed:
|
||||
"https://www.google.com/maps/embed?pb=!1m14!1m8!1m3!1d2278.6487565593457!2d50.86419096171365!3d34.62673098386569!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3f93bd00131a9e91%3A0x8794a39fe74b8f96!2z2qnZhNuM2YbbjNqpINm-2YjYs9iq2Iwg2YXZiCDZiCDYstuM2KjYp9uM24wg2K_aqdiq2LEg2YXYrdmF2K8g2KfYs9iv24zYp9mG!5e0!3m2!1sen!2sus!4v1755661948318!5m2!1sen!2sus",
|
||||
instagram: "https://www.instagram.com/alma.skincares/",
|
||||
telegram: "https://t.me/almaskincares",
|
||||
loginUrl: "https://customer.almabeauti.com",
|
||||
footerAbout:
|
||||
"به آلما بیوتی اعتماد کنید و زیبایی و طراوت پوست خود را به دستان حرفهای ما بسپارید. با آلما بیوتی، در مسیر درمان و سلامت پوست خود قدمی موثر بردارید و زیبایی پوستتان را به اوج برسانید. همین حالا با ما تماس بگیرید و وقت مشاوره خود را رزرو کنید!",
|
||||
};
|
||||
|
||||
export const navLinks = [
|
||||
{ href: "/", label: "صفحه اصلی" },
|
||||
{ href: "/services", label: "خدمات", hasDropdown: true },
|
||||
{ href: "/blog", label: "مقالات" },
|
||||
{ href: "/about", label: "درباره ما" },
|
||||
{ href: "/contact", label: "تماس با ما" },
|
||||
];
|
||||
|
||||
export type ServiceItem = {
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
short: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
pageTitle: string;
|
||||
pageBody: string;
|
||||
image: string;
|
||||
footerLabel: string;
|
||||
};
|
||||
|
||||
export const services: ServiceItem[] = [
|
||||
{
|
||||
slug: "facial",
|
||||
title: "فیشیال",
|
||||
titleEn: "FACIAL",
|
||||
short: "پاکسازی، لایهبرداری، آبرسانی و تغذیه پوست",
|
||||
href: "/services#facial",
|
||||
icon: "/images/services/mesoneedling.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی فیشیال در آلما بیوتی",
|
||||
pageBody:
|
||||
"آلما بیوتی یکی از مراکز تخصصی فیشیال در قم است که با بهرهگیری از دستگاهها و تکنیکهای مدرن، خدمات متنوعی در زمینه مراقبت و زیبایی پوست ارائه میدهد. این مرکز با استفاده از محصولات باکیفیت و تکنیکهای حرفهای، به بهبود سلامت و زیبایی پوست شما کمک میکند.\nفیشیال یکی از روشهای حرفهای مراقبت از پوست است که شامل مراحل پاکسازی، لایهبرداری، آبرسانی و تغذیه پوست میشود. در قم، مراکز زیبایی متعددی این خدمات را با استفاده از جدیدترین دستگاهها و محصولات ارائه میدهند.",
|
||||
image: "/images/services-page/facial.jpg",
|
||||
footerLabel: "فیشیال در قم",
|
||||
},
|
||||
{
|
||||
slug: "carboxytherapy",
|
||||
title: "کربوکسی تراپی بدون تزریق",
|
||||
titleEn: "CARBOXYTHERAPY",
|
||||
short: "روشی موثر برای کاهش تیرگیها و افزایش اکسیژنرسانی به پوست",
|
||||
href: "/services#carboxytherapy",
|
||||
icon: "/images/services/carboxy.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی کربوکسی تراپی در آلما بیوتی",
|
||||
pageBody:
|
||||
"در شهر قم، مرکز آلما بیوتی خدمات کربوکسی تراپی بدون تزریق را با استفاده از ماسکها و ژلهای کربوکسی ارائه میدهد. این مرکز با بهرهگیری از تکنیکهای مدرن و محصولات باکیفیت، به بهبود کیفیت و جوانسازی پوست شما کمک میکند.\nکربوکسی تراپی بدون تزریق یکی از روشهای نوین و غیرتهاجمی در حوزه زیبایی و مراقبت از پوست است که بدون نیاز به سوزن و تزریق، به بهبود مشکلات پوستی کمک میکند. در این روش، از ماسکها و ژلهای حاوی دیاکسید کربن استفاده میشود که با قرارگیری بر روی پوست، باعث افزایش جریان خون، تحریک کلاژنسازی و بهبود بافت پوست میشوند.",
|
||||
image: "/images/services-page/carboxy.jpg",
|
||||
footerLabel: "کربوکسی تراپی قم",
|
||||
},
|
||||
{
|
||||
slug: "pollagentherapy",
|
||||
title: "پلاژن تراپی",
|
||||
titleEn: "POLLAGENTHERAPY",
|
||||
short: "برای جوانسازی، لیفت و بهبود کیفیت پوست",
|
||||
href: "/services#pollagentherapy",
|
||||
icon: "/images/services/pollagen.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی پلاژن تراپی در آلما بیوتی",
|
||||
pageBody:
|
||||
"در شهر قم، مرکز آلما بیوتی با کادر مجرب، خدمات پلاژن تراپی را به صورت حرفهای ارائه میدهد. این مرکز با استفاده از تکنیکهای مدرن و محصولات با کیفیت، به بهبود و جوانسازی پوست شما کمک میکند. پلاژن تراپی یکی از روشهای مؤثر و پیشرفته در جوانسازی پوست است که با استفاده از کپسولها و ژلهای خاص حاوی مواد فعال و کلاژن، به تحریک تولید کلاژن در پوست کمک میکند. این روش به ویژه برای رفع چینوچروکها، افزایش الاستیسیته پوست و بهبود بافت آن مناسب است.",
|
||||
image: "/images/services-page/pollagen.jpg",
|
||||
footerLabel: "پلاژن تراپی قم",
|
||||
},
|
||||
{
|
||||
slug: "mesoneedling",
|
||||
title: "مزونیدلینگ",
|
||||
titleEn: "MESONEEDLING",
|
||||
short: "راهکاری موثر برای کاهش چینوچروک و افزایش استحکام پوست",
|
||||
href: "/services#mesoneedling",
|
||||
icon: "/images/services/mesoneedling.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی مزونیدلینگ در آلما بیوتی",
|
||||
pageBody:
|
||||
"در شهر قم، مرکز آلما بیوتی با کادر مجرب، خدمات مزونیدلینگ را به صورت حرفهای ارائه میدهد. این مرکز با استفاده از تکنیکهای مدرن و محصولات با کیفیت، به بهبود و جوانسازی پوست شما کمک میکند. مزونیدلینگ یکی از روشهای پیشرفته در جوانسازی پوست است که با ایجاد میکروکانال و تغذیه پوست، به تحریک تولید کلاژن کمک میکند. این روش به ویژه برای رفع چینوچروکها، افزایش الاستیسیته پوست و بهبود بافت آن مناسب است.",
|
||||
image: "/images/services-page/meso.jpg",
|
||||
footerLabel: "میکرونیدلینگ قم",
|
||||
},
|
||||
{
|
||||
slug: "microcurrent",
|
||||
title: "میکروکارنت",
|
||||
titleEn: "MICROCURRENT",
|
||||
short: "روشی غیرتهاجمی برای کاهش چینوچروک و جوانسازی پوست",
|
||||
href: "/services#microcurrent",
|
||||
icon: "/images/services/microcurrent.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی میکروکارنت در آلما بیوتی",
|
||||
pageBody:
|
||||
"میکروکارنت یک روش غیرتهاجمی در حوزه زیبایی است که با استفاده از جریانهای الکتریکی ضعیف، به تحریک عضلات و بافتهای پوستی میپردازد. این تکنیک به بهبود ظاهر چینوچروکها، لیفتینگ و جوانسازی پوست کمک میکند.\nمرکز تخصصی آلما بیوتی در قم، با بهرهگیری از تکنیکهای مدرن، خدمات میکروکارنت را ارائه میدهد. این مرکز با استفاده از این روش، به کاهش چینوچروکها و جوانسازی پوست مراجعین کمک میکند.",
|
||||
image: "/images/services-page/micro.jpg",
|
||||
footerLabel: "میکروکارنت قم",
|
||||
},
|
||||
{
|
||||
slug: "massage",
|
||||
title: "ماساژ صورت و دکلته",
|
||||
titleEn: "MASSAGE",
|
||||
short: "کاهش التهابات و قرمزی با تکنیکهای آرامشبخش و ملایم",
|
||||
href: "/services#massage",
|
||||
icon: "/images/services/massage.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی ماساژ صورت و دکلته در آلما بیوتی",
|
||||
pageBody:
|
||||
"مرکز تخصصی آلما بیوتی در قم، خدمات ماساژ صورت و دکلته را با بهرهگیری از تکنیکهای حرفهای و بهروز ارائه میدهد. این ماساژها با تمرکز بر نواحی صورت، گردن و بالای سینه، به بهبود گردش خون، کاهش تنشهای عضلانی و افزایش شادابی و طراوت پوست کمک میکنند.\nفواید ماساژ صورت و دکلته در آلما بیوتی: افزایش گردش خون، کاهش تنشهای عضلانی، جوانسازی پوست، آبرسانی و تغذیه پوست",
|
||||
image: "/images/services-page/massage.jpg",
|
||||
footerLabel: "ماساژ صورت قم",
|
||||
},
|
||||
{
|
||||
slug: "green-pill",
|
||||
title: "گرین پیل",
|
||||
titleEn: "GREEN PEEL",
|
||||
short: "لایهبرداری گیاهی برای بازسازی و روشنسازی پوست",
|
||||
href: "/services#green-pill",
|
||||
icon: "/images/services/hydro.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی گرین پیل در آلما بیوتی",
|
||||
pageBody:
|
||||
"در مرکز تخصصی آلما بیوتی در قم، خدمات گرین پیل توسط تیمی حرفهای و با بهرهگیری از تکنیکهای پیشرفته ارائه میشود. این مرکز با استفاده از ترکیبات گیاهی منحصر به فرد، به شما کمک میکند تا پوستی روشنتر، نرمتر و شادابتر داشته باشید.\nگرین پیل یک روش لایهبرداری طبیعی و گیاهی است که با استفاده از ترکیبات گیاهی و آنزیمها، به بازسازی و جوانسازی پوست کمک میکند. این روش، بدون استفاده از مواد شیمیایی، به بهبود مشکلاتی مانند آکنه، لکهای پوستی، چین و چروک و افتادگی پوست میپردازد.",
|
||||
image: "/images/services-page/green.jpg",
|
||||
footerLabel: "گرین پیل قم",
|
||||
},
|
||||
{
|
||||
slug: "hydrodermabrasion",
|
||||
title: "هیدرودرمی (آبرسانی عمقی پوست)",
|
||||
titleEn: "HYDRODERMABRASION",
|
||||
short: "مناسب برای آبرسانی موثر، شادابی و نرمکنندگی پوست",
|
||||
href: "/services#hydrodermabrasion",
|
||||
icon: "/images/services/hydro.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی هیدرودرمی در آلما بیوتی",
|
||||
pageBody:
|
||||
"هیدرودرمی، یا آبرسانی عمقی پوست، روشی مؤثر برای بهبود رطوبت و شادابی پوست است. این روش با استفاده از دستگاههای تخصصی، سرمهای آبرسان را به لایههای عمیقتر پوست منتقل میکند، که منجر به افزایش طراوت، نرمی و کاهش چین و چروکهای سطحی میشود.\nدر مرکز تخصصی آلما بیوتی در قم، خدمات هیدرودرمی توسط تیمی مجرب و با استفاده از تکنیکهای پیشرفته ارائه میشود. این مرکز با بهرهگیری از دستگاههای مدرن و محصولات با کیفیت، به آبرسانی مؤثر و بهبود سلامت پوست مراجعین کمک میکند.",
|
||||
image: "/images/services-page/hydro.jpg",
|
||||
footerLabel: "هیدرودرمی قم",
|
||||
},
|
||||
{
|
||||
slug: "analysis",
|
||||
title: "آنالیز پوست با دستگاه",
|
||||
titleEn: "SKIN ANALYSIS",
|
||||
short: "تشخیص دقیق نوع پوست و انتخاب بهترین برنامه درمانی",
|
||||
href: "/services#analysis",
|
||||
icon: "/images/why/skin.png",
|
||||
pageTitle: "خدمات تخصصی پوست و زیبایی آنالیز پوست با دستگاه در آلما بیوتی",
|
||||
pageBody:
|
||||
"در مرکز تخصصی آلما بیوتی در قم، آنالیز پوست با استفاده از دستگاههای پیشرفته انجام میشود. این فرآیند بهمنظور تشخیص دقیق نوع پوست، مشکلات موجود و تعیین بهترین روشهای درمانی و مراقبتی صورت میگیرد.\nمراحل آنالیز پوست در آلما بیوتی: مشاوره اولیه، اسکن پوست با دستگاه آنالیزور، تجزیه و تحلیل نتایج، توصیههای درمانی و مراقبتی",
|
||||
image: "/images/services-page/analysis.jpg",
|
||||
footerLabel: "آنالیز پوست با دستگاه",
|
||||
},
|
||||
];
|
||||
|
||||
export const homeServiceCards = [
|
||||
services.find((s) => s.slug === "mesoneedling")!,
|
||||
services.find((s) => s.slug === "pollagentherapy")!,
|
||||
services.find((s) => s.slug === "massage")!,
|
||||
services.find((s) => s.slug === "microcurrent")!,
|
||||
services.find((s) => s.slug === "carboxytherapy")!,
|
||||
services.find((s) => s.slug === "hydrodermabrasion")!,
|
||||
];
|
||||
|
||||
export const slides = [
|
||||
{ image: "/images/slider/slide-1.jpg", alt: "آلما بیوتی — خدمات تخصصی پوست" },
|
||||
{ image: "/images/slider/slide-2.jpg", alt: "آلما بیوتی — مراقبت حرفهای از پوست" },
|
||||
{ image: "/images/slider/slide-3.jpg", alt: "آلما بیوتی — کلینیک زیبایی در قم" },
|
||||
];
|
||||
|
||||
export const pricePackages = [
|
||||
{
|
||||
title: "فیشال ماهانه",
|
||||
english: "FACIAL",
|
||||
icon: "/images/prices/facial.png",
|
||||
items: [
|
||||
{ name: "استاندارد", price: "تماس بگیرید" },
|
||||
{ name: "VIP", price: "تماس بگیرید", highlight: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "کربوکسی تراپی",
|
||||
english: "CARBOXYTHERAPY",
|
||||
icon: "/images/prices/carboxy.png",
|
||||
items: [
|
||||
{ name: "استاندارد", price: "تماس بگیرید" },
|
||||
{ name: "VIP", price: "تماس بگیرید", highlight: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "مزونیدلینگ",
|
||||
english: "MESONEEDLING",
|
||||
icon: "/images/prices/meso.png",
|
||||
items: [
|
||||
{ name: "استاندارد", price: "تماس بگیرید" },
|
||||
{ name: "VIP", price: "تماس بگیرید", highlight: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "پلاژن تراپی",
|
||||
english: "POLLAGENTHERAPY",
|
||||
icon: "/images/prices/meso.png",
|
||||
items: [
|
||||
{ name: "استاندارد", price: "تماس بگیرید" },
|
||||
{ name: "VIP", price: "تماس بگیرید", highlight: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const whyItems = [
|
||||
{ title: "رضایت مشتریان ما", english: "SATISFACTION", image: "/images/why/satisfaction.png" },
|
||||
{ title: "تخصص در درمان پوست", english: "SKIN", image: "/images/why/skin.png" },
|
||||
{ title: "زیبایی پوست با تکنولوژی پوست", english: "SKIN", image: "/images/why/tech.png" },
|
||||
{ title: "محیطی امن و بهداشتی", english: "SANITARY", image: "/images/why/sanitary.png" },
|
||||
{ title: "مشاوره رایگان برای درمان پوست", english: "CONSULTING", image: "/images/why/consulting.png" },
|
||||
];
|
||||
|
||||
export const aboutWhyItems = [
|
||||
{ title: "رضایت مشتریان", english: "SATISFACTION", image: "/images/why/about-satisfaction.png" },
|
||||
{ title: "تخصص در درمان پوست", english: "SKIN", image: "/images/why/about-skin.png" },
|
||||
{ title: "زیبایی با تکنولوژی پوست", english: "SKIN", image: "/images/why/about-tech.png" },
|
||||
{ title: "محیطی امین و بهداشتی", english: "SANITARY", image: "/images/why/about-sanitary.png" },
|
||||
{ title: "مشاوره پوست برای درمان پوست", english: "CONSULTING", image: "/images/why/about-consulting.png" },
|
||||
];
|
||||
|
||||
export const testimonials = [
|
||||
{
|
||||
text: "من شگفتانگیزترین درمان صورت را در آلما بیوتی انجام دادم. پوست من احساس جوانی و درخشندگی میکند. زیباییشناس بسیار آگاه بود و باعث شد در کل جلسه احساس نازپروردگی کنم. نمیتوانم منتظر بمانم تا قرار بعدی خود را رزرو کنم.",
|
||||
author: "کاربر آلما اسکین",
|
||||
},
|
||||
{
|
||||
text: "تیم آلما بهترین مواد ها رو برای کاراشون استفاده میکنن و این خیلی برای من مهمه، تو کارشون هم واقعا حرفه ای هستند.",
|
||||
author: "کاربر آلما اسکین",
|
||||
},
|
||||
{
|
||||
text: "تیم آلما بهترین مواد ها رو برای کاراشون استفاده میکنن و این خیلی برای من مهمه، تو کارشون هم واقعا حرفه ای هستند.",
|
||||
author: "کاربر آلما اسکین",
|
||||
},
|
||||
{
|
||||
text: "تیم آلما بهترین مواد ها رو برای کاراشون استفاده میکنن و این خیلی برای من مهمه، تو کارشون هم واقعا حرفه ای هستند.",
|
||||
author: "کاربر آلما اسکین",
|
||||
},
|
||||
];
|
||||
|
||||
export const articles = [
|
||||
{
|
||||
title: "فیشیال صورت عروس قبل از مراسم + ۵ نکته طلایی که باید رعایت شود",
|
||||
image: "/images/blog/bridal-facial.jpg",
|
||||
date: "یکشنبه ۲۳ شهریور ۱۴۰۴",
|
||||
},
|
||||
{
|
||||
title: "آیا انجام فیشیال پوست در دوران بارداری مجاز است؟",
|
||||
image: "/images/blog/pregnancy-facial.jpg",
|
||||
date: "یکشنبه ۱۶ شهریور ۱۴۰۴",
|
||||
},
|
||||
{
|
||||
title: "مراحل انجام فیشیال برای پوست های خشک",
|
||||
image: "/images/blog/dry-skin-facial.jpg",
|
||||
date: "پنجشنبه ۶ شهریور ۱۴۰۴",
|
||||
},
|
||||
];
|
||||
|
||||
export const faqs = [
|
||||
{
|
||||
q: "لطفا در خصوص انواع خدمات خود توضیح دهید !",
|
||||
a: "در مرکز آلما بیوتی، ما مجموعهای از خدمات تخصصی و پیشرفته پوست و زیبایی را به شما ارائه میدهیم که بهطور کامل بر اساس نیازها و نوع پوست شما شخصیسازی شده است تا بهترین و مؤثرترین نتایج را برای شما به ارمغان آورد.\nانواع خدمات ما عبارتند از: فیشیال صورت، کربوکسیتراپی، پلاژنتراپی، مزونیدلینگ، میکروکارنت، ماساژ صورت و دکلته، گرین پیل، هیدرودرمی، آنالیز پوست با دستگاه\nتمامی این خدمات در مرکز آلما بیوتی با هدف بهبود سلامت و زیبایی پوست شما طراحی شدهاند و به شما این اطمینان را میدهیم که بهترین و مؤثرترین نتایج را دریافت خواهید کرد. اگر سوالی دارید یا به مشاوره نیاز دارید، ما آمادهایم تا شما را راهنمایی کنیم.",
|
||||
},
|
||||
{
|
||||
q: "چه مدت بعد از درمان میتوانم نتیجه را مشاهده کنم؟",
|
||||
a: "نتایج بسته به نوع درمان و نیاز پوست متفاوت است. درمانهای فیشیال صورت، هیدرودرمی و میکروکارنت ممکن است نتایج فوری داشته باشند، در حالی که درمانهایی مانند مزونیدلینگ، کربوکسیتراپی و پلاژنتراپی ممکن است برای مشاهده نتایج بهتر نیاز به چندین جلسه درمان داشته باشند.",
|
||||
},
|
||||
{
|
||||
q: "آیا این درمانها برای همه افراد مناسب است؟",
|
||||
a: "درمانهای پوست برای اکثر افراد مناسب هستند، اما برای برخی افراد با مشکلات خاص مانند حساسیتهای پوستی شدید، عفونتهای فعال یا بیماریهای پوستی خاص، ممکن است برخی از درمانها مناسب نباشد. مشاوره اولیه به شما کمک میکند تا بهترین درمان برای وضعیت پوست شما انتخاب شود.",
|
||||
},
|
||||
];
|
||||
|
||||
export const homeAbout = {
|
||||
titleLead: "درباره",
|
||||
titleAccent: "آلما بیوتی",
|
||||
english: "ABOUT US",
|
||||
body: "مرکز تخصصی الما بیوتی با هدف ارائه بهترین خدمات پوست و زیبایی در شهر قم، فضایی ایدهآل برای مراقبت از پوست و رفع نیازهای درمانی شما ایجاد کرده است. ما با بهرهگیری از پیشرفتهترین دستگاهها و تکنیکهای مدرن، خدماتی از جمله فیشال پوست چرب و جوشدار، کربوکسی تراپی بدون تزریق، مزونیدلینگ برای درمان اسکار و رویش مجدد مو، و پلاژن تراپی را به شما ارائه میدهیم.",
|
||||
image: "/images/about/home.jpg",
|
||||
};
|
||||
|
||||
export const aboutPage = {
|
||||
titleLead: "درباره",
|
||||
titleAccent: "آلمابیوتی",
|
||||
english: "ABOUT US",
|
||||
body: "آلما بیوتی ارائهدهنده خدمات تخصصی درمانهای پوست و زیبایی در قم. شامل میکرونیدلینگ، کربوکسی تراپی، فیشال های تخصصی ، درمانهای ضدلک و جوانسازی پوست .\nخدمات تخصصی پوست و زیبایی آلما بیوتی به شما این امکان را میدهد تا با استفاده از جدیدترین روشهای علمی و تکنولوژیهای روز دنیا، سلامت و زیبایی پوست خود را بازسازی کنید. در این مرکز، خدمات متنوعی نظیر میکرونیدلینگ برای درمان اسکار و جوانسازی پوست، کربوکسی تراپی بدون تزریق برای بهبود گردش خون و رفع سلولیت، و فیشالهای تخصصی برای درمان پوستهای چرب، جوشدار و دارای لک و .... ارائه میشود. با توجه به نیازهای خاص پوست شما، متخصصین ما در آلما بیوتی بهترین خدمات زیبایی را به شما پیشنهاد میدهند تا تجربهای شگفتانگیز از سلامت و زیبایی پوست داشته باشید.",
|
||||
image: "/images/about/page.jpg",
|
||||
};
|
||||
|
||||
export const homeBanner = {
|
||||
title: "آلما بیوتی برای شما ...",
|
||||
english: "ALMA BEAUTY",
|
||||
body: "تیم متخصص و مجرب ما با استفاده از آنالیز پوست با دستگاههای پیشرفته، بهترین راهکارها را برای رفع مشکلات پوستی شما پیشنهاد میدهند. همچنین، در الما بیوتی، شما میتوانید از خدمات مشاوره رایگان بهرهمند شوید و بهترین روشهای مراقبتی و هوم کر مناسب پوست خود را دریافت کنید.\n\nما معتقدیم که زیبایی و سلامت پوست، سرمایهای ارزشمند است و با ارائه خدماتی مانند لیفت و جوانسازی پوست، ماساژ صورت و دکلته، و هیدرودرمی (آبرسانی عمقی پوست)، شما را در مسیر داشتن پوستی شاداب و درخشان همراهی میکنیم.",
|
||||
image: "/images/about/home-banner.png",
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { articles } from "@/data/site";
|
||||
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||
import { detailPath, slugifyForUrl } from "@/lib/seo-slug";
|
||||
|
||||
export type BlogPost = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
abstract?: string | null;
|
||||
mainTextHtml?: string | null;
|
||||
titleImageUrl?: string | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
commentCount?: number;
|
||||
author?: { firstName?: string; lastName?: string; name?: string } | string | null;
|
||||
dateLabel?: string;
|
||||
};
|
||||
|
||||
export type PagedBlogs = {
|
||||
items: BlogPost[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
|
||||
const query = new URLSearchParams();
|
||||
query.set("page", String(params.page ?? 1));
|
||||
query.set("pageSize", String(params.pageSize ?? 12));
|
||||
const data = await meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: params.pageSize ?? 12,
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
items: (data.items ?? []).map((item) => ({
|
||||
...item,
|
||||
id: String(item.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchBlogById(id: string) {
|
||||
const data = await meshkeeGet<{ blog?: BlogPost }>(
|
||||
tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`),
|
||||
{},
|
||||
);
|
||||
if (!data.blog) return null;
|
||||
return { ...data.blog, id: String(data.blog.id) };
|
||||
}
|
||||
|
||||
/** @deprecated Prefer fetchBlogById for public detail pages. */
|
||||
export async function fetchBlogBySlug(slug: string) {
|
||||
const data = await meshkeeGet<{ blog?: BlogPost }>(tenantPath(`/blogs/${encodeURIComponent(slug)}`), {});
|
||||
if (!data.blog) return null;
|
||||
return { ...data.blog, id: String(data.blog.id) };
|
||||
}
|
||||
|
||||
export function blogImage(post: BlogPost) {
|
||||
return post.titleImageUrl || "/images/blog/bridal-facial.jpg";
|
||||
}
|
||||
|
||||
export function blogHref(post: Pick<BlogPost, "id" | "title">) {
|
||||
return detailPath("/blog", post.id, post.title);
|
||||
}
|
||||
|
||||
export function blogTitleSlug(title: string) {
|
||||
return slugifyForUrl(title);
|
||||
}
|
||||
|
||||
export function getLocalBlogPosts(): BlogPost[] {
|
||||
return articles.map((item, index) => ({
|
||||
id: `local-${index}`,
|
||||
title: item.title,
|
||||
slug: slugifyForUrl(item.title, `article-${index}`),
|
||||
titleImageUrl: item.image,
|
||||
publishedAt: null,
|
||||
createdAt: null,
|
||||
dateLabel: item.date,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLocalBlogPost(idOrSlug: string) {
|
||||
const posts = getLocalBlogPosts();
|
||||
return (
|
||||
posts.find((post) => post.id === idOrSlug || post.slug === idOrSlug) ??
|
||||
posts.find((post) => slugifyForUrl(post.title) === decodeURIComponent(idOrSlug)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||
|
||||
export type WebsiteBranding = {
|
||||
faviconUrl: string | null;
|
||||
logoUrl: string | null;
|
||||
logoDarkUrl: string | null;
|
||||
hasDedicatedFavicon: boolean;
|
||||
};
|
||||
|
||||
const FALLBACK_FAVICON = "/favicon.png";
|
||||
|
||||
export async function getWebsiteBranding() {
|
||||
return meshkeeGet<WebsiteBranding>(tenantPath("/website/favicon"), {
|
||||
faviconUrl: null,
|
||||
logoUrl: null,
|
||||
logoDarkUrl: null,
|
||||
hasDedicatedFavicon: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFavicon(branding: WebsiteBranding) {
|
||||
return branding.faviconUrl || branding.logoUrl || FALLBACK_FAVICON;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
|
||||
|
||||
export type CommentItem = {
|
||||
id?: string;
|
||||
authorName?: string | null;
|
||||
text?: string | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchComments(entityType: "product" | "blog" | "portfolio" | "video", entityId: string) {
|
||||
const data = await meshkeeGet<{ items?: CommentItem[] }>(
|
||||
tenantPath(`/comments?entityType=${entityType}&entityId=${encodeURIComponent(entityId)}`),
|
||||
{ items: [] },
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const websiteDomain =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
|
||||
process.env.WEBSITE_DOMAIN ??
|
||||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ??
|
||||
"almabeauti.com";
|
||||
|
||||
export const meshkeeApiBase = (
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
|
||||
process.env.MESHKEE_API_BASE ??
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL ??
|
||||
"https://api.meshkee.com/api/v1"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const customerPortalUrl = (
|
||||
process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? `https://customer.${websiteDomain}`
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const businessDashboardUrl = (
|
||||
process.env.NEXT_PUBLIC_BUSINESS_ORIGIN ?? `https://business.${websiteDomain}`
|
||||
).replace(/\/$/, "");
|
||||
@@ -0,0 +1,32 @@
|
||||
export function faDigits(value: number | string) {
|
||||
return String(value).replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)]);
|
||||
}
|
||||
|
||||
export function formatFaDate(value?: string | null) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFaDateLong(value?: string | null) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("fa-IR", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).formatToParts(new Date(value));
|
||||
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
return [get("weekday"), get("day"), get("month"), get("year")].filter(Boolean).join(" ");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { meshkeeApiBase } from "@/lib/config";
|
||||
|
||||
export function getBearerToken(request: Request): string | null {
|
||||
const auth = request.headers.get("authorization");
|
||||
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function meshkeeFetch(
|
||||
path: string,
|
||||
init?: RequestInit & { token?: string | null },
|
||||
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("Content-Type") && init?.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (init?.token) {
|
||||
headers.set("Authorization", `Bearer ${init.token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${meshkeeApiBase}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
export function jsonResponse(data: unknown, status = 200) {
|
||||
return Response.json(data, { status });
|
||||
}
|
||||
|
||||
export function unauthorizedResponse() {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
|
||||
|
||||
export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 60): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${meshkeeApiBase}${path}`, {
|
||||
next: { revalidate },
|
||||
});
|
||||
if (!response.ok) return fallback;
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantPath(suffix: string) {
|
||||
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
|
||||
}
|
||||
|
||||
type Tenant = {
|
||||
id: number | string;
|
||||
nameFa?: string;
|
||||
name?: string;
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
export async function getTenant() {
|
||||
return meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null);
|
||||
}
|
||||
|
||||
export async function getBusinessId() {
|
||||
const tenant = await getTenant();
|
||||
return tenant?.id != null ? String(tenant.id) : "";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Build a URL path segment from a title (Persian / Arabic / Latin). */
|
||||
export function slugifyForUrl(value: string, fallback = "item"): string {
|
||||
const slug = value
|
||||
.trim()
|
||||
.normalize("NFC")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
return slug || fallback;
|
||||
}
|
||||
|
||||
export function detailPath(base: string, id: string | number, title: string) {
|
||||
const slug = slugifyForUrl(title);
|
||||
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { site } from "@/data/site";
|
||||
import { websiteDomain } from "@/lib/config";
|
||||
|
||||
export const siteUrl = `https://${websiteDomain}`;
|
||||
|
||||
export function pageMetadata({
|
||||
title,
|
||||
description,
|
||||
path = "/",
|
||||
image,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
path?: string;
|
||||
image?: string | null;
|
||||
}): Metadata {
|
||||
const url = `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
const ogImage = image || `${siteUrl}/images/logo.png`;
|
||||
const isDetail = path.split("/").filter(Boolean).length > 1;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
type: isDetail ? "article" : "website",
|
||||
locale: "fa_IR",
|
||||
url,
|
||||
siteName: site.name,
|
||||
title,
|
||||
description,
|
||||
images: [{ url: ogImage, alt: title }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description,
|
||||
images: [ogImage],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
clearSharedAuthCookies,
|
||||
hydrateLocalTokensFromSharedCookies,
|
||||
SHARED_ACCESS_TOKEN_KEY,
|
||||
SHARED_REFRESH_TOKEN_KEY,
|
||||
writeSharedAuthCookies,
|
||||
} from "@/lib/shared-auth-cookie";
|
||||
|
||||
export type AuthBusinessMembership = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOwner: boolean;
|
||||
teamRole: string | null;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
cellNumber?: string;
|
||||
email?: string;
|
||||
roles?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
businesses?: AuthBusinessMembership[];
|
||||
};
|
||||
|
||||
type SessionContextValue = {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
ready: boolean;
|
||||
businessId: string;
|
||||
isWebsiteAdmin: boolean;
|
||||
logout: () => void;
|
||||
getAccessToken: () => string | null;
|
||||
};
|
||||
|
||||
function normalizeUser(raw: Partial<AuthUser> & { id?: string | number }): AuthUser | null {
|
||||
if (raw.id == null) return null;
|
||||
return {
|
||||
id: String(raw.id),
|
||||
firstName: raw.firstName ?? undefined,
|
||||
lastName: raw.lastName ?? undefined,
|
||||
cellNumber: raw.cellNumber ?? undefined,
|
||||
email: raw.email ?? undefined,
|
||||
roles: Array.isArray(raw.roles) ? raw.roles : undefined,
|
||||
isSuperAdmin: Boolean(raw.isSuperAdmin),
|
||||
businesses: Array.isArray(raw.businesses)
|
||||
? raw.businesses.map((business) => ({
|
||||
id: String(business.id),
|
||||
name: business.name,
|
||||
slug: business.slug,
|
||||
isOwner: Boolean(business.isOwner),
|
||||
teamRole: business.teamRole ?? null,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean {
|
||||
if (!user || !businessId) return false;
|
||||
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
|
||||
return Boolean(
|
||||
user.businesses?.some(
|
||||
(business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
function readToken(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(key: string, value: string | null) {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
businessId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
businessId: string;
|
||||
}) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const getAccessToken = useCallback(() => {
|
||||
hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY);
|
||||
return (
|
||||
readToken(SHARED_ACCESS_TOKEN_KEY) ||
|
||||
readToken("accessToken") ||
|
||||
readToken("meshkeeToken") ||
|
||||
readToken("token")
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearTokens = useCallback(() => {
|
||||
writeToken(SHARED_ACCESS_TOKEN_KEY, null);
|
||||
writeToken(SHARED_REFRESH_TOKEN_KEY, null);
|
||||
writeToken("accessToken", null);
|
||||
writeToken("meshkeeToken", null);
|
||||
writeToken("token", null);
|
||||
clearSharedAuthCookies();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const refresh = readToken(SHARED_REFRESH_TOKEN_KEY);
|
||||
writeSharedAuthCookies(token, refresh ?? undefined);
|
||||
|
||||
fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(async (res) => {
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { user?: AuthUser } & AuthUser;
|
||||
const next = normalizeUser(data.user ?? data);
|
||||
if (next) setUser(next);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setReady(true));
|
||||
}, [clearTokens, getAccessToken]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearTokens();
|
||||
}, [clearTokens]);
|
||||
|
||||
const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isAuthenticated: Boolean(user),
|
||||
ready,
|
||||
businessId,
|
||||
isWebsiteAdmin,
|
||||
logout,
|
||||
getAccessToken,
|
||||
}),
|
||||
[user, ready, businessId, isWebsiteAdmin, logout, getAccessToken],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSession must be used within SessionProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function userDisplayName(user: AuthUser | null | undefined) {
|
||||
if (!user) return "کاربر";
|
||||
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||
return full || user.cellNumber || user.email || "کاربر";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/** Shared auth cookies so storefront + customer.{domain} share one login. */
|
||||
|
||||
export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
|
||||
export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
|
||||
|
||||
const CHUNK_SIZE = 2800;
|
||||
const MAX_CHUNKS = 24;
|
||||
|
||||
function cookieDomain(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
if (host === "localhost" || host === "127.0.0.1") return undefined;
|
||||
const parts = host.split(".").filter(Boolean);
|
||||
if (parts.length < 2) return undefined;
|
||||
return `.${parts.slice(-2).join(".")}`;
|
||||
}
|
||||
|
||||
function readRawCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const prefix = `${name}=`;
|
||||
const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
|
||||
if (!match) return null;
|
||||
return match.slice(prefix.length);
|
||||
}
|
||||
|
||||
function writeRawCookie(name: string, value: string | null) {
|
||||
if (typeof document === "undefined") return;
|
||||
const domain = cookieDomain();
|
||||
if (value == null) {
|
||||
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
|
||||
if (domain) cookie += `; domain=${domain}`;
|
||||
document.cookie = cookie;
|
||||
return;
|
||||
}
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||
if (domain) cookie += `; domain=${domain}`;
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
||||
function clearChunkedCookie(name: string) {
|
||||
writeRawCookie(name, null);
|
||||
const n = Number(readRawCookie(`${name}_n`) || "0");
|
||||
writeRawCookie(`${name}_n`, null);
|
||||
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
|
||||
writeRawCookie(`${name}_${i}`, null);
|
||||
}
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string | null) {
|
||||
clearChunkedCookie(name);
|
||||
if (value == null || value === "") return;
|
||||
|
||||
const encoded = encodeURIComponent(value);
|
||||
if (encoded.length <= 3500) {
|
||||
writeRawCookie(name, encoded);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
|
||||
chunks.push(encoded.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
writeRawCookie(`${name}_n`, String(chunks.length));
|
||||
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const n = Number(readRawCookie(`${name}_n`) || "0");
|
||||
if (n > 0) {
|
||||
let encoded = "";
|
||||
for (let i = 0; i < n; i++) {
|
||||
const part = readRawCookie(`${name}_${i}`);
|
||||
if (part == null) return null;
|
||||
encoded += part;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const raw = readRawCookie(name);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
|
||||
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
|
||||
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
export function clearSharedAuthCookies() {
|
||||
writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
|
||||
writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
|
||||
}
|
||||
|
||||
export function readSharedAccessToken(): string | null {
|
||||
return readCookie(SHARED_ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function readSharedRefreshToken(): string | null {
|
||||
return readCookie(SHARED_REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function hydrateLocalTokensFromSharedCookies(
|
||||
accessKey = SHARED_ACCESS_TOKEN_KEY,
|
||||
refreshKey = SHARED_REFRESH_TOKEN_KEY,
|
||||
): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (localStorage.getItem(accessKey)) return false;
|
||||
const access = readSharedAccessToken();
|
||||
if (!access) return false;
|
||||
localStorage.setItem(accessKey, access);
|
||||
const refresh = readSharedRefreshToken();
|
||||
if (refresh) localStorage.setItem(refreshKey, refresh);
|
||||
return true;
|
||||
}
|
||||