Connect Meshkee backend, blog and shared account session

This commit is contained in:
Alireza Hassani
2026-09-05 19:41:56 +03:30
parent ee4b384238
commit de46b50e13
33 changed files with 803 additions and 11 deletions
+5 -1
View File
@@ -1 +1,5 @@
{"project_id":"appgprj_6a9ba2dadd448191bc030f95aa2edb04","static":{"directory":"out"},"d1":null,"r2":null}
{
"project_id": "appgprj_6a9ba2dadd448191bc030f95aa2edb04",
"d1": null,
"r2": null
}
+33
View File
@@ -15,3 +15,36 @@ Original public PDF URLs all return 404; recovered 29 of 30 from asset host http
Catalog and original provenance/status: site/app/catalog.json. Local PDFs: site/public/pdfs. Missing Barcode-and-sticky-indicator-of-type-4.pdf: product requests catalog via contact page instead of broken download. Do not claim all 30 were downloaded.
Local original logo and footer logo in public/images, font public/iran-sans.ttf. Supplied louis-reed photo used as laboratory.jpg and national-cancer-institute-fd0b-Bl4cFc photo as care.jpg. Preserve ImageSamples originals.
Site ID stored in site/.openai/hosting.json, static output out. Never store hosting credentials in repository.
## Update: compact banners and product mega menu (2026-09-05)
User requested shorter page-title row with background pattern and header product mega menu. Shared about/contact banner now uses compact spacing and a fading CSS medical-cross pattern. ProductMenu client component exposes all 30 catalog items grouped in four columns (responsive), using 29 local PDFs and the existing contact fallback. Click/tap toggle, Escape with focus restoration, outside-click and focus-leave dismissal; disclosure semantics with standard navigation links. Local preview remains the active review surface.
## Update: English title typography (2026-09-05)
English titles now use locally hosted Montserrat regular/bold from the original site's font CDN. Applied to English eyebrows, category subtitles, image caption, educational labels, footer brand, and product titles including mega menu. Latin unicode ranges and Iran fallback preserve Persian typography. Assets: public/fonts/montserrat-{regular,bold}.ttf.
## Update: contact section redesign (2026-09-05)
Replaced oversized contact cards with two compact office/expert panels using aligned label/number rows and line icons. Email, Telegram and non-interactive fax use a secondary horizontal strip that stacks on small screens. Preserved all original contact values and link actions. Montserrat used for Latin numbers and English labels; Persian remains Iran Sans. Local preview is the current review surface.
## Update: lighter title background (2026-09-05)
Shared page title background changed to #f8fcfd. Medical-cross pattern increased to .34 opacity and 1.5px strokes with a wider fade, keeping the text area quiet.
## Update: mega-menu Persian heading font (2026-09-05)
Explicitly set mega-menu category headings and heading label to Iran Sans; downloaded matching real bold face to public/fonts/iran-sans-bold.ttf and registered weight 700 to avoid synthetic bold. English catalog titles retain Montserrat.
## Update: animated homepage slider (2026-09-05)
HeroSlider client component replaces static hero. Three supplied photos with image wipe/zoom and staggered text reveal; 6.5-second autoplay, direct slide selection, previous/next, pause. Hover and hidden document suspend rotation; keyboard focus/manual selection pauses. Reduced-motion disables animations/autoplay. Uses existing source-grounded themes and navigation. Added research.jpg from supplied hans-reniers photo.
## Backend integration (2026-09-05)
Requested brief: https://api.meshkee.com/api/v1/public/ai-prompts/sge-medical-group/backend/b40e612a9009a7c365b47980
Canonical API https://api.meshkee.com/api/v1, tenant sgemed.com resolves id 3. Docs https://api.meshkee.com/docs/website/openapi.json. Uses tenant resolution before public APIs. Live blogs currently empty and favicon null (verified); fallback /favicon.svg. No contact messages sent to live backend during tests.
Next.js now requires a server (removed output:export) for live blog detail routes, metadata, contact proxy, and cookie authentication. The project uses Vinext to build its Next.js App Router routes for Cloudflare Workers: `npm run build` emits `dist/server/index.js` with a callable `fetch`; `npm run dev` / `npm start` use Vinext. Old `out/` is stale and must not be deployed. Removed static output from hosting manifest.
New routes: /blog, /blog/{id}/{titleSlug}, /api/contact POST, /api/account GET, /api/logout POST. All outbound tenant requests are fixed to sgemed.com. Blog HTML sanitized with sanitize-html, canonical links use id/titleSlug. Blog list/detail schema beyond envelope is not specified in docs and live list empty; content mapping supports title, content/text, summary/excerpt, coverUrl/imageUrl; verify against real article when one is published. No fabricated articles. Contact validation has required title/name/text and optional email/cellNumber; Persian phone digits normalized. Confirmed success resets form, shows inline message and dismissible toast; failure retains values. Tests mock submission to avoid sending messages.
User confirmed parent-domain cookies meshkee_customer_access_token and meshkee_customer_refresh_token on .sgemed.com; no token handoff. Server validates via /auth/me and refreshes 401 via /auth/refresh. No tokens are sent to client JSON. Logout expires both shared cookies; no documented backend revoke endpoint. Localhost and private Sites domain cannot read .sgemed.com cookies, so production SSO can only be verified under sgemed.com. customer.sgemed.com did not resolve during inspection. Account role/profile/admin URL clarification pending; admin is not assumed from an unverified claim.
## Backend access rules confirmed (2026-09-05)
Profile URL: https://customer.sgemed.com/profile. Website management URL: https://business.sgemed.com/website. The account endpoint treats a user as eligible for the management link only if they are a super-admin (`isSuperAdmin` or `roles` includes `super_admin`) or hold a membership for the resolved SGE tenant (`businessId`/`id` matches tenant ID) with `isOwner: true` or `teamRole` `admin`, `editor`, or `viewer`. This decision is made server-side from the verified `/auth/me` response; it is never inferred from client data.
Build-generated public/meshkee/sitemap-config.json lists public static pages. Do not ship sitemap.xml or robots.txt because production nginx delegates them to Meshkee. Test commands: node --experimental-strip-types --test tests/contact.test.mjs; node --test tests/backend.test.cjs.
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { api, ApiError, getTenant } from '../../../lib/meshkee/api';
import { ACCESS_COOKIE,REFRESH_COOKIE,setSessionCookies } from '../../../lib/meshkee/session';
type BusinessMembership={businessId?:number|string;id?:number|string;isOwner?:boolean;teamRole?:'admin'|'editor'|'viewer'|null};
type User={firstName?:string;lastName?:string;businesses?:BusinessMembership[];isSuperAdmin?:boolean;roles?:string[]};
const headers={'Cache-Control':'no-store'};
function hasWebsiteAccess(user:User, businessId:number|string){
if(user.isSuperAdmin||user.roles?.includes('super_admin'))return true;
return user.businesses?.some(membership=>
String(membership.businessId??membership.id)===String(businessId)
&& (membership.isOwner===true||membership.teamRole==='admin'||membership.teamRole==='editor'||membership.teamRole==='viewer')
)??false;
}
export async function GET(request:Request){
const jar=await cookies();const token=jar.get(ACCESS_COOKIE)?.value;const refreshToken=jar.get(REFRESH_COOKIE)?.value;
if(!token&&!refreshToken)return NextResponse.json({user:null},{headers});
let refreshed:{accessToken:string;refreshToken:string}|undefined;
try{
let result:{user:User};
try{
if(!token)throw new ApiError(401);
result=await api<{user:User}>('/auth/me',{headers:{Authorization:`Bearer ${token}`}});
}catch(error){
if(!(error instanceof ApiError)||error.status!==401||!refreshToken)throw error;
refreshed=await api<{accessToken:string;refreshToken:string}>('/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refreshToken})});
if(!refreshed.accessToken||!refreshed.refreshToken)throw new Error('Invalid refresh response');
result=await api<{user:User}>('/auth/me',{headers:{Authorization:`Bearer ${refreshed.accessToken}`}});
}
const tenant=await getTenant();
const response=NextResponse.json({user:{name:[result.user.firstName,result.user.lastName].filter(Boolean).join(' ')||'کاربر',isAdmin:hasWebsiteAccess(result.user,tenant.id)}},{headers});
if(refreshed)setSessionCookies(response,request,refreshed);
return response;
}catch(error){return NextResponse.json({user:null},{status:error instanceof ApiError&&error.status===401?401:503,headers})}
}
+18
View File
@@ -0,0 +1,18 @@
import {sameOrigin} from '../../../lib/meshkee/session';
import { tenantApi } from '../../../lib/meshkee/api';
import { validateContact } from '../../../lib/meshkee/contact';
export async function POST(request:Request) {
if(!sameOrigin(request))return Response.json({message:'درخواست نامعتبر است.'},{status:403});
if(!request.headers.get('content-type')?.includes('application/json'))return Response.json({message:'فرمت درخواست نامعتبر است.'},{status:415});
const raw=await request.text();
if(raw.length>20000)return Response.json({message:'متن پیام بیش از حد طولانی است.'},{status:413});
let input;
try{input=JSON.parse(raw)}catch{return Response.json({message:'درخواست نامعتبر است.'},{status:400})}
if(!input||typeof input!=='object'||Array.isArray(input))return Response.json({message:'درخواست نامعتبر است.'},{status:400});
const {data,errors}=validateContact(input);
if(Object.keys(errors).length)return Response.json({message:'اطلاعات فرم را بررسی کنید.',errors},{status:400});
try {
await tenantApi('/contact-submissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(Object.fromEntries(Object.entries(data).filter(([,value])=>value)))});
return Response.json({message:'پیام شما با موفقیت ثبت شد. از ارتباط شما سپاسگزاریم.'},{status:201});
}catch{return Response.json({message:'پیام ارسال نشد. لطفاً دوباره تلاش کنید یا با ما تماس بگیرید.'},{status:502})}
}
+12
View File
@@ -0,0 +1,12 @@
import {NextResponse} from 'next/server';
import { ACCESS_COOKIE,REFRESH_COOKIE,cookieOptions,sameOrigin } from '../../../lib/meshkee/session';
export async function POST(request:Request){
if(!sameOrigin(request))return NextResponse.json({message:'درخواست نامعتبر است.'},{status:403});
const response=NextResponse.json({ok:true});
for(const name of [ACCESS_COOKIE,REFRESH_COOKIE]){
response.cookies.set(name,'',{...cookieOptions(request),maxAge:0});
// Also expire a host-only cookie left by a local/older deployment.
if(cookieOptions(request).domain)response.headers.append('Set-Cookie',`${name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`);
}
return response;
}
+17
View File
@@ -0,0 +1,17 @@
import type {Metadata} from 'next';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { ApiError } from '../../../../lib/meshkee/api';
import { getBlog, titleSlug, blogHref, summary, cover, articleHtml } from '../../../../lib/meshkee/blog';
type Props={params:Promise<{id:string;slug:string}>};
export const dynamic='force-dynamic';
export async function generateMetadata({params}:Props):Promise<Metadata>{
try {const {id}=await params;const post=await getBlog(id);return {title:post.title,description:summary(post)||`${post.title}؛ مجله سلامت گستر اعتماد`,alternates:{canonical:`https://sgemed.com${blogHref(post)}`}}}catch{return {title:'مطلب',robots:{index:false}}}
}
export default async function Article({params}:Props){
const {id,slug}=await params;
let post;
try{post=await getBlog(id)}catch(error){if(error instanceof ApiError&&error.status===404)notFound();throw error}
if(slug!==titleSlug(post.title))redirect(blogHref(post));
return <><section className="pagehead"><div className="wrap"><Link className="eyebrow" href="/blog">مجله سلامت گستر ←</Link><h1>{post.title}</h1></div></section><article className="wrap section blog-article">{cover(post)&&<img className="article-cover" src={cover(post)} alt={post.title}/>}<div className="article-body" dangerouslySetInnerHTML={{__html:articleHtml(post)}}/><Link className="textlink" href="/blog">بازگشت به مجله ←</Link></article></>;
}
+2
View File
@@ -0,0 +1,2 @@
'use client';
export default function ErrorPage({reset}:{reset:()=>void}) {return <section className="wrap section backend-empty"><h1>دریافت مطلب ممکن نشد</h1><p>ارتباط با مجله برقرار نشد. لطفاً دوباره تلاش کنید.</p><button className="button" onClick={reset}>تلاش دوباره</button></section>}
+1
View File
@@ -0,0 +1 @@
export default function Loading(){return <div className="wrap section backend-empty" role="status">در حال دریافت مطالب…</div>}
+2
View File
@@ -0,0 +1,2 @@
import Link from 'next/link';
export default function Missing(){return <section className="wrap section backend-empty"><h1>مطلب پیدا نشد</h1><p>این مطلب در دسترس نیست یا هنوز منتشر نشده است.</p><Link href="/blog" className="button">بازگشت به مجله</Link></section>}
+12
View File
@@ -0,0 +1,12 @@
import Link from 'next/link';
import { BookOpen, ArrowUpLeft } from 'lucide-react';
import { getBlogs, blogHref, summary, cover } from '../../lib/meshkee/blog';
export const metadata = {title:'مجله سلامت گستر',description:'مقالات و تازه‌های سلامت گستر اعتماد در زمینه استریلیزاسیون، تجهیزات پزشکی و کنترل عفونت.'};
export const dynamic = 'force-dynamic';
export default async function BlogPage({searchParams}:{searchParams:Promise<{page?:string}>}) {
const query=await searchParams; const page=Math.max(1,Math.min(10000,Number.parseInt(query.page||'1',10)||1));
let data;
try{data=await getBlogs(page)}catch{return <><Banner/><section className="wrap section backend-empty"><BookOpen size={32}/><h2>دریافت مطالب ممکن نشد</h2><p>لطفاً کمی بعد دوباره تلاش کنید.</p><a className="button" href="/blog">تلاش دوباره</a></section></>}
return <><Banner/><section className="wrap section">{data.items.length ? <div className="blog-grid">{data.items.map(post=><Link className="blog-card" href={blogHref(post)} key={post.id}>{cover(post)?<img src={cover(post)} alt={post.title}/>:<div className="blog-placeholder"><BookOpen size={38} strokeWidth={1}/></div>}<div className="blog-card-copy"><h2>{post.title}</h2><p>{summary(post)}</p><span>مطالعه مطلب <ArrowUpLeft size={18}/></span></div></Link>)}</div>:<div className="backend-empty"><BookOpen size={36} strokeWidth={1.3}/><h2>به‌زودی با مطالب تازه همراه شما هستیم</h2><p>هنوز مطلبی منتشر نشده است.</p></div>}{data.total>12&&<nav className="blog-pagination" aria-label="صفحه‌بندی مجله">{page>1&&<Link href={`/blog?page=${page-1}`}>صفحه قبل</Link>}<span>صفحه {page.toLocaleString('fa-IR')}</span>{page*12<data.total&&<Link href={`/blog?page=${page+1}`}>صفحه بعد</Link>}</nav>}</section></>;
}
function Banner(){return <section className="pagehead"><div className="wrap"><span className="eyebrow">SGE JOURNAL</span><h1>مجله سلامت گستر</h1><p>دانش و تازه‌های استریلیزاسیون و کنترل عفونت</p></div></section>}
+77
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -1,7 +1,11 @@
import AccountMenu from '../components/backend/account-menu';
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
export const metadata: Metadata = { title: { default: 'سلامت گستر اعتماد | تجهیزات استریل و کنترل عفونت', template: '%s | سلامت گستر اعتماد' }, description: 'شرکت مهندسی سلامت گستر اعتماد؛ مشاور و مجری استریل و کنترل عفونت. محصولات تخصصی، کاتالوگ‌ها و راه‌های ارتباط با ما.' };
import { getFavicon } from '../lib/meshkee/api';
import ProductMenu from '../components/product-menu';
const baseMetadata: Metadata = { title: { default: 'سلامت گستر اعتماد | تجهیزات استریل و کنترل عفونت', template: '%s | سلامت گستر اعتماد' }, description: 'شرکت مهندسی سلامت گستر اعتماد؛ مشاور و مجری استریل و کنترل عفونت. محصولات تخصصی، کاتالوگ‌ها و راه‌های ارتباط با ما.' };
export async function generateMetadata(): Promise<Metadata> { return { ...baseMetadata, icons: { icon: await getFavicon() } }; }
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="fa" dir="rtl"><body><div className="topbar"><div className="wrap"><span>مشاور و مجری استریل و کنترل عفونت</span><a href="tel:02166157609">دفتر تهران <b dir="ltr">۰۲۱ − ۶۶۱۵۷۶۰۹</b></a></div></div><header className="wrap header"><Link className="brand" href="/" aria-label="سلامت گستر اعتماد، صفحه اصلی"><img src="/images/logo.png" alt="SGE Medical Group" /></Link><nav aria-label="منوی اصلی"><Link href="/">صفحه اصلی</Link><Link href="/#products">محصولات</Link><Link href="/about-us">درباره ما</Link><Link href="/tmas-bama">تماس با ما</Link></nav><Link className="button small" href="/tmas-bama">مشاوره و خرید <span>↖</span></Link></header><main>{children}</main><footer><div className="wrap footergrid"><div><img className="footerlogo" src="/images/logo.png" alt="سلامت گستر اعتماد"/><h3>شرکت مهندسی سلامت گستر اعتماد</h3><p>همراه شما در مسیر استریلیزاسیون<br/>و کنترل عفونت</p></div><div><h3>دسترسی سریع</h3><Link href="/">صفحه اصلی</Link><Link href="/#products">محصولات و کاتالوگ‌ها</Link><Link href="/about-us">درباره ما</Link><Link href="/tmas-bama">تماس با ما</Link></div><div><h3>با ما در ارتباط باشید</h3><a href="tel:02166157609">دفتر تهران: <b dir="ltr">۰۲۱۶۶۱۵۷۶۰۹</b></a><a href="tel:02532891573">دفتر شهرستان‌ها: <b dir="ltr">۰۲۵۳۲۸۹۱۵۷۳</b></a><a href="mailto:sale.steril@gmail.com">sale.steril@gmail.com</a><a href="https://t.me/CCSD1" target="_blank" rel="noreferrer">تلگرام ↗</a></div></div><div className="wrap copyright"><span>تمامی حقوق برای سلامت گستر اعتماد محفوظ است.</span><span dir="ltr">SGE MEDICAL GROUP</span></div></footer></body></html>
return <html lang="fa" dir="rtl"><body><div className="topbar"><div className="wrap"><span>مشاور و مجری استریل و کنترل عفونت</span><a href="tel:02166157609">دفتر تهران <b dir="ltr">۰۲۱ − ۶۶۱۵۷۶۰۹</b></a></div></div><header className="wrap header"><Link className="brand" href="/" aria-label="سلامت گستر اعتماد، صفحه اصلی"><img src="/images/logo.png" alt="SGE Medical Group" /></Link><nav aria-label="منوی اصلی"><Link href="/">صفحه اصلی</Link><ProductMenu /><Link href="/blog">مجله</Link><Link href="/about-us">درباره ما</Link><Link href="/tmas-bama">تماس با ما</Link></nav><AccountMenu /><Link className="button small" href="/tmas-bama">مشاوره و خرید <span>↖</span></Link></header><main>{children}</main><footer><div className="wrap footergrid"><div><img className="footerlogo" src="/images/logo.png" alt="سلامت گستر اعتماد"/><h3>شرکت مهندسی سلامت گستر اعتماد</h3><p>همراه شما در مسیر استریلیزاسیون<br/>و کنترل عفونت</p></div><div><h3>دسترسی سریع</h3><Link href="/">صفحه اصلی</Link><Link href="/#products">محصولات و کاتالوگ‌ها</Link><Link href="/blog">مجله</Link><Link href="/about-us">درباره ما</Link><Link href="/tmas-bama">تماس با ما</Link></div><div><h3>با ما در ارتباط باشید</h3><a href="tel:02166157609">دفتر تهران: <b dir="ltr">۰۲۱۶۶۱۵۷۶۰۹</b></a><a href="tel:02532891573">دفتر شهرستان‌ها: <b dir="ltr">۰۲۵۳۲۸۹۱۵۷۳</b></a><a href="mailto:sale.steril@gmail.com">sale.steril@gmail.com</a><a href="https://t.me/CCSD1" target="_blank" rel="noreferrer">تلگرام ↗</a></div></div><div className="wrap copyright"><span>تمامی حقوق برای سلامت گستر اعتماد محفوظ است.</span><span dir="ltr">SGE MEDICAL GROUP</span></div></footer></body></html>
}
+2 -1
View File
@@ -1,5 +1,6 @@
import HeroSlider from '../components/hero-slider';
import Link from 'next/link';
import { FlaskConical, ShieldCheck, Package, Microscope, ArrowDownLeft, MoveLeft } from 'lucide-react';
import catalog from './catalog.json';
const groups=[{title:'اندیکاتورهای شیمیایی',en:'Chemical indicators',icon:FlaskConical,start:2,end:15},{title:'اندیکاتورهای بیولوژیکال',en:'Biological indicators',icon:Microscope,start:0,end:2},{title:'بسته‌بندی استریل',en:'Sterile packaging',icon:Package,start:15,end:26},{title:'بهداشت و کنترل عفونت',en:'Infection control',icon:ShieldCheck,start:26,end:30}];
export default function Home(){return <><section className="hero"><div className="hero-photo"><img src="/images/laboratory.jpg" alt="محیط آزمایشگاهی و تجهیزات تخصصی"/><div className="photo-label"><span className="cross">+</span><div>دانش، دقت، اعتماد<small>SCIENCE IN SERVICE OF HEALTH</small></div></div></div><div className="hero-copy"><span className="eyebrow">SGE MEDICAL GROUP <i/></span><h1>اعتماد، نخستین گام<br/>در مسیر <em>سلامت.</em></h1><p>راهکارهای تخصصی استریلیزاسیون و کنترل عفونت؛<br/>همراه مراکز درمانی، از انتخاب تا استفاده.</p><div className="actions"><a className="button" href="#products">محصولات تخصصی ما <MoveLeft size={19}/></a><Link className="textlink" href="/about-us">آشنایی با سلامت گستر <ArrowDownLeft size={19}/></Link></div><div className="hero-bottom"><ShieldCheck size={25}/><span>شرکت مهندسی سلامت گستر اعتماد<small>مشاور و مجری استریل و کنترل عفونت</small></span></div></div></section><div className="wrap categories">{groups.map((g,i)=><a href={'#group-'+i} key={g.title}><g.icon size={30} strokeWidth={1.4}/><div><strong>{g.title}</strong><small>{g.en}</small></div><ArrowDownLeft size={17}/></a>)}</div><section className="wrap section" id="products"><div className="section-heading"><div><span className="eyebrow">محصولات تخصصی ما</span><h2>دقت در انتخاب؛ اطمینان در عملکرد</h2></div><p>معرفی محصولات و دریافت کاتالوگ‌های تخصصی<br/>استریل، بسته‌بندی و کنترل عفونت</p></div><div className="catalog">{groups.map((g,i)=><details id={'group-'+i} key={g.title} open={i===0}><summary><span className="group-title"><g.icon size={24}/>{g.title}<small>{g.en}</small></span><span>مشاهده محصولات +</span></summary><div className="product-grid">{catalog.slice(g.start,g.end).map(p=><a className="product" href={p.downloaded ? p.url : '/tmas-bama'} key={p.source} target="_blank" rel="noreferrer"><span className="pdf">PDF</span><h3>{p.title}</h3><span className="download">{p.downloaded ? 'دریافت کاتالوگ' : 'درخواست کاتالوگ'} <ArrowDownLeft size={18}/></span></a>)}</div></details>)}</div></section><section className="education"><div className="wrap split"><img src="/images/care.jpg" alt="فعالیت متخصصان در محیط پزشکی"/><div><span className="eyebrow">دانش، پایهٔ مراقبت بهتر</span><h2>آموزش، کلید توسعه<br/>و تخصص ما است.</h2><p>دانش استریل و بازفرآوری ابزار پزشکی، دانشی چندبعدی و تخصصی است. کتاب راهنمای جامع استریلیزاسیون، ترجمه گایدلاین ST79، برای فعالان حوزه استریل و کارشناسان کنترل عفونت معرفی شده است.</p><Link href="/tmas-bama" className="textlink">برای تهیه کتاب با ما تماس بگیرید <MoveLeft size={20}/></Link></div></div></section><section className="wrap section info"><span className="eyebrow">استریل و کنترل عفونت</span><h2>شناخت بهتر، انتخاب آگاهانه‌تر</h2><div className="info-grid"><article><span>۰۱ / CHEMICAL</span><h3>اندیکاتور شیمیایی چیست؟</h3><p>این نشانگرها معمولاً جامد و کاغذی‌شکل هستند، داخل پک‌های استریل قرار می‌گیرند و در طول فرایند استریلیزاسیون تغییر رنگ می‌دهند.</p></article><article><span>۰۲ / BIOLOGICAL</span><h3>اندیکاتور بیولوژیکال چیست؟</h3><p>این اندیکاتورها حاوی میکروارگانیسم‌های زنده هستند. نتیجه کشت آن‌ها در محیط مرتبط، برای ارزیابی عملکرد فرایند استریلیزاسیون استفاده می‌شود.</p></article><article><span>۰۳ / DOCUMENTATION</span><h3>مستندسازی و ردیابی استریل</h3><p>نرم‌افزار CSSD-DOC برای مستندسازی و ردیابی استریل؛ گذار از دنیای کاغذی به عصر الکترونیک.</p><Link href="/tmas-bama">دریافت اطلاعات بیشتر ←</Link></article></div></section><section className="contactband"><div className="wrap"><div><h2>برای انتخاب محصول مناسب، همراه شما هستیم.</h2><p>مشاوره و خرید محصولات تخصصی کنترل عفونت و استریلیزاسیون</p></div><Link href="/tmas-bama" className="button white">ارتباط با کارشناسان <MoveLeft size={20}/></Link></div></section></>}
export default function Home(){return <><HeroSlider /><div className="wrap categories">{groups.map((g,i)=><a href={'#group-'+i} key={g.title}><g.icon size={30} strokeWidth={1.4}/><div><strong>{g.title}</strong><small>{g.en}</small></div><ArrowDownLeft size={17}/></a>)}</div><section className="wrap section" id="products"><div className="section-heading"><div><span className="eyebrow">محصولات تخصصی ما</span><h2>دقت در انتخاب؛ اطمینان در عملکرد</h2></div><p>معرفی محصولات و دریافت کاتالوگ‌های تخصصی<br/>استریل، بسته‌بندی و کنترل عفونت</p></div><div className="catalog">{groups.map((g,i)=><details id={'group-'+i} key={g.title} open={i===0}><summary><span className="group-title"><g.icon size={24}/>{g.title}<small>{g.en}</small></span><span>مشاهده محصولات +</span></summary><div className="product-grid">{catalog.slice(g.start,g.end).map(p=><a className="product" href={p.downloaded ? p.url : '/tmas-bama'} key={p.source} target="_blank" rel="noreferrer"><span className="pdf">PDF</span><h3>{p.title}</h3><span className="download">{p.downloaded ? 'دریافت کاتالوگ' : 'درخواست کاتالوگ'} <ArrowDownLeft size={18}/></span></a>)}</div></details>)}</div></section><section className="education"><div className="wrap split"><img src="/images/care.jpg" alt="فعالیت متخصصان در محیط پزشکی"/><div><span className="eyebrow">دانش، پایهٔ مراقبت بهتر</span><h2>آموزش، کلید توسعه<br/>و تخصص ما است.</h2><p>دانش استریل و بازفرآوری ابزار پزشکی، دانشی چندبعدی و تخصصی است. کتاب راهنمای جامع استریلیزاسیون، ترجمه گایدلاین ST79، برای فعالان حوزه استریل و کارشناسان کنترل عفونت معرفی شده است.</p><Link href="/tmas-bama" className="textlink">برای تهیه کتاب با ما تماس بگیرید <MoveLeft size={20}/></Link></div></div></section><section className="wrap section info"><span className="eyebrow">استریل و کنترل عفونت</span><h2>شناخت بهتر، انتخاب آگاهانه‌تر</h2><div className="info-grid"><article><span>۰۱ / CHEMICAL</span><h3>اندیکاتور شیمیایی چیست؟</h3><p>این نشانگرها معمولاً جامد و کاغذی‌شکل هستند، داخل پک‌های استریل قرار می‌گیرند و در طول فرایند استریلیزاسیون تغییر رنگ می‌دهند.</p></article><article><span>۰۲ / BIOLOGICAL</span><h3>اندیکاتور بیولوژیکال چیست؟</h3><p>این اندیکاتورها حاوی میکروارگانیسم‌های زنده هستند. نتیجه کشت آن‌ها در محیط م��تبط، برای ارزیابی عملکرد فرایند استریلیزاسیون استفاده می‌شود.</p></article><article><span>۰۳ / DOCUMENTATION</span><h3>مستندسازی و ردیابی استریل</h3><p>نرم‌افزار CSSD-DOC برای مستندسازی و ردیابی استریل؛ گذار از دنیای کاغذی به عصر الکترونیک.</p><Link href="/tmas-bama">دریافت اطلاعات بیشتر ←</Link></article></div></section><section className="contactband"><div className="wrap"><div><h2>برای انتخاب محصول مناسب، همراه شما هستیم.</h2><p>مشاوره و خرید محصولات تخصصی کنترل عفونت و استریلیزاسیون</p></div><Link href="/tmas-bama" className="button white">ارتباط با کارشناسان <MoveLeft size={20}/></Link></div></section></>}
+31 -2
View File
@@ -1,2 +1,31 @@
export const metadata={title:'تماس با ما'};
export default function Contact(){return <><section className="pagehead"><div className="wrap"><span className="eyebrow">CONTACT US</span><h1>با ما در تماس باشید</h1><p>برای مشاوره و خرید محصولات کنترل عفونت و استریلیزاسیون، همراه شما هستیم.</p></div></section><section className="wrap section"><div className="section-heading"><div><span className="eyebrow">راه‌های ارتباطی</span><h2>گفت‌وگو، آغاز یک انتخاب مطمئن</h2></div><p>شرکت مهندسی سلامت گستر اعتماد<br/>مشاور و مجری استریل و کنترل عفونت</p></div><div className="contactcards"><article className="contactcard"><span className="eyebrow">۰۱ / CALL</span><h2>شماره‌های تماس دفاتر</h2><small>دفتر تهران</small><a href="tel:02166157609" dir="ltr">021 6615 7609</a><small>دفتر شهرستان‌ها</small><a href="tel:02532891573" dir="ltr">025 3289 1573</a><a href="tel:02536550178" dir="ltr">025 3655 0178</a></article><article className="contactcard"><span className="eyebrow">۰۲ / MOBILE</span><h2>ارتباط با کارشناسان</h2><small>آقای مهندس اویسی</small><a href="tel:09125537630" dir="ltr">0912 553 7630</a><small>خانم مهندس یعقوبی راد</small><a href="tel:09389221460" dir="ltr">0938 922 1460</a></article><article className="contactcard"><span className="eyebrow">۰۳ / NETWORKS</span><h2>با ما همراه باشید</h2><small>پست الکترونیکی</small><a href="mailto:sale.steril@gmail.com">sale.steril@gmail.com</a><small>کانال تلگرام</small><a href="https://t.me/CCSD1" target="_blank" rel="noreferrer" dir="ltr">@CCSD1 ↗</a></article><article className="contactcard"><span className="eyebrow">۰۴ / FAX</span><h2>شماره فکس</h2><p>ارسال مکاتبات و اسناد</p><span dir="ltr" style={{display:'block',fontSize:23,color:'#0088a4'}}>025 3655 0166</span></article></div></section></>}
import ContactForm from '../../components/backend/contact-form';
import { Phone, Headphones, Mail, Send, Printer, ArrowUpLeft } from 'lucide-react';
export const metadata = { title: 'تماس با ما' };
export default function Contact() {
return <>
<section className="pagehead"><div className="wrap"><span className="eyebrow">CONTACT US</span><h1>با ما در تماس باشید</h1><p>برای مشاوره و خرید محصولات کنترل عفونت و استریلیزاسیون، همراه شما هستیم.</p></div></section>
<section className="wrap section contact-section">
<div className="section-heading"><div><span className="eyebrow">راه‌های ارتباطی</span><h2>گفت‌وگو، آغاز یک انتخاب مطمئن</h2></div><p>شرکت مهندسی سلامت گستر اعتماد<br/>مشاور و مجری استریل و کنترل عفونت</p></div>
<div className="contact-primary">
<article className="contact-panel office-panel">
<div className="contact-panel-heading"><span className="contact-icon"><Phone size={24} strokeWidth={1.5}/></span><div><span className="eyebrow">OFFICES</span><h2>تماس با دفاتر</h2></div></div>
<div className="contact-line"><span>دفتر تهران</span><a href="tel:02166157609" aria-label="تماس با دفتر تهران"><bdi>021 6615 7609</bdi><ArrowUpLeft size={17}/></a></div>
<div className="contact-line"><span>دفتر شهرستان‌ها</span><div className="contact-numbers"><a href="tel:02532891573" aria-label="تماس با دفتر شهرستان‌ها، خط اول"><bdi>025 3289 1573</bdi><ArrowUpLeft size={17}/></a><a href="tel:02536550178" aria-label="تماس با دفتر شهرستان‌ها، خط دوم"><bdi>025 3655 0178</bdi><ArrowUpLeft size={17}/></a></div></div>
</article>
<article className="contact-panel expert-panel">
<div className="contact-panel-heading"><span className="contact-icon"><Headphones size={25} strokeWidth={1.5}/></span><div><span className="eyebrow">OUR EXPERTS</span><h2>ارتباط مستقیم با کارشناسان</h2></div></div>
<div className="contact-line"><span>آقای مهندس اویسی</span><a href="tel:09125537630" aria-label="تماس با مهندس اویسی"><bdi>0912 553 7630</bdi><ArrowUpLeft size={17}/></a></div>
<div className="contact-line"><span>خانم مهندس یعقوبی راد</span><a href="tel:09389221460" aria-label="تماس با مهندس یعقوبی راد"><bdi>0938 922 1460</bdi><ArrowUpLeft size={17}/></a></div>
</article>
</div>
<div className="contact-secondary">
<a className="contact-channel" href="mailto:sale.steril@gmail.com"><span className="channel-icon"><Mail size={23} strokeWidth={1.5}/></span><div><h3>پست الکترونیکی</h3><bdi>sale.steril@gmail.com</bdi></div><ArrowUpLeft className="channel-arrow" size={18}/></a>
<a className="contact-channel" href="https://t.me/CCSD1" target="_blank" rel="noreferrer"><span className="channel-icon"><Send size={23} strokeWidth={1.5}/></span><div><h3>همراه ما در تلگرام</h3><bdi>@CCSD1</bdi></div><ArrowUpLeft className="channel-arrow" size={18}/></a>
<div className="contact-channel fax-channel"><span className="channel-icon"><Printer size={23} strokeWidth={1.5}/></span><div><h3>فکس · مکاتبات و اسناد</h3><bdi>025 3655 0166</bdi></div></div>
</div>
<ContactForm />
</section>
</>;
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
import {useEffect,useState,useRef} from 'react';
import {UserRound,ChevronDown} from 'lucide-react';
import {CUSTOMER_URL,CUSTOMER_PROFILE_URL,WEBSITE_MANAGEMENT_URL} from '../../lib/meshkee/api';
export default function AccountMenu(){
const [user,setUser]=useState<{name:string;isAdmin:boolean}|null>(null);const [open,setOpen]=useState(false);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const root=useRef<HTMLDivElement>(null);const button=useRef<HTMLButtonElement>(null);
useEffect(()=>{const controller=new AbortController();fetch('/api/account',{cache:'no-store',signal:controller.signal}).then(r=>r.json()).then(data=>setUser(data.user||null)).catch(()=>{});return()=>controller.abort()},[]);
useEffect(()=>{if(!open)return;const outside=(event:PointerEvent)=>{if(!root.current?.contains(event.target as Node))setOpen(false)};const key=(event:KeyboardEvent)=>{if(event.key==='Escape'){setOpen(false);button.current?.focus()}};document.addEventListener('pointerdown',outside);document.addEventListener('keydown',key);return()=>{document.removeEventListener('pointerdown',outside);document.removeEventListener('keydown',key)}},[open]);
async function logout(){setBusy(true);setError('');try{const response=await fetch('/api/logout',{method:'POST'});if(!response.ok)throw new Error();setUser(null);setOpen(false)}catch{setError('خروج انجام نشد؛ دوباره تلاش کنید.')}finally{setBusy(false)}}
if(!user)return <a className="account-login" href={CUSTOMER_URL} aria-label="ورود به حساب کاربری"><UserRound size={20}/><span>ورود</span></a>;
return <div className="account-menu" ref={root} onBlur={e=>{if(!e.currentTarget.contains(e.relatedTarget as Node))setOpen(false)}}><button ref={button} type="button" className="account-login" aria-expanded={open} aria-controls="account-dropdown" onClick={()=>setOpen(!open)}><UserRound size={19}/>{user.name} عزیز<ChevronDown size={14}/></button>{open&&<div id="account-dropdown" className="account-dropdown"><a href={CUSTOMER_PROFILE_URL}>پروفایل من</a>{user.isAdmin&&<a href={WEBSITE_MANAGEMENT_URL}>مدیریت وبسایت</a>}<button disabled={busy} onClick={logout}>{busy?'در حال خروج…':'خروج'}</button>{error&&<p role="alert">{error}</p>}</div>}</div>
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { Send, CheckCircle2, X } from 'lucide-react';
import { validateContact } from '../../lib/meshkee/contact';
export default function ContactForm(){
const [busy,setBusy]=useState(false);const [errors,setErrors]=useState<Record<string,string>>({});const [message,setMessage]=useState('');const [success,setSuccess]=useState(false);const [toast,setToast]=useState(false);const locked=useRef(false);
useEffect(()=>{if(!toast)return;const timer=setTimeout(()=>setToast(false),6500);return()=>clearTimeout(timer)},[toast]);
async function submit(event:React.FormEvent<HTMLFormElement>){
event.preventDefault();if(locked.current)return;
const form=event.currentTarget;const {data,errors:validation}=validateContact(Object.fromEntries(new FormData(form)));
setErrors(validation);setMessage('');setSuccess(false);setToast(false);
if(Object.keys(validation).length){(form.elements.namedItem(Object.keys(validation)[0]) as HTMLElement)?.focus();return}
locked.current=true;setBusy(true);
try{const response=await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal:AbortSignal.timeout(30000)});const result=await response.json();if(!response.ok){setErrors(result.errors||{});throw new Error(result.message||'ارسال پیام ممکن نشد.')};setMessage(result.message);setSuccess(true);setToast(true);form.reset();}
catch(error){setMessage(error instanceof Error&&error.name!=='TimeoutError'?error.message:'ارتباط برقرار نشد. لطفاً دوباره تلاش کنید.');}
finally{locked.current=false;setBusy(false)}
}
return <section className="contact-form-section"><div><span className="eyebrow">SEND A MESSAGE</span><h2>برای ما بنویسید</h2><p>پرسش یا درخواست خود را با کارشناسان ما در میان بگذارید.</p></div><form onSubmit={submit} noValidate aria-busy={busy}><fieldset disabled={busy}><div className="form-grid">{[{name:'name',label:'نام و نام خانوادگی',required:true,max:120},{name:'title',label:'موضوع پیام',required:true,max:200},{name:'email',label:'پست الکترونیکی',type:'email',max:254},{name:'cellNumber',label:'شماره تماس',type:'tel',max:25}].map(field=><label key={field.name} htmlFor={'contact-'+field.name}>{field.label}{field.required&&' *'}<input id={'contact-'+field.name} name={field.name} type={field.type||'text'} required={field.required} maxLength={field.max} autoComplete={field.name==='name'?'name':field.type==='email'?'email':field.type==='tel'?'tel':'off'} dir={field.type?'ltr':undefined} aria-invalid={Boolean(errors[field.name])} aria-describedby={errors[field.name]?'error-'+field.name:undefined}/>{errors[field.name]&&<small id={'error-'+field.name} className="field-error">{errors[field.name]}</small>}</label>)}</div><label htmlFor="contact-text">متن پیام *<textarea id="contact-text" name="text" rows={5} required maxLength={5000} aria-invalid={Boolean(errors.text)} aria-describedby={errors.text?'error-text':undefined}/>{errors.text&&<small id="error-text" className="field-error">{errors.text}</small>}</label><button className="button" type="submit">{busy?'در حال ارسال…':'ارسال پیام'}<Send size={18}/></button></fieldset>{message&&<p className={success?'form-success':'field-error'} role={success?'status':'alert'}>{message}</p>}</form>{toast&&<div className="success-toast"><CheckCircle2 size={22}/><span>پیام شما با موفقیت ثبت شد.</span><button type="button" onClick={()=>setToast(false)} aria-label="بستن اعلان"><X size={18}/></button></div>}</section>
}
+28
View File
@@ -0,0 +1,28 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowDownLeft, MoveLeft, ShieldCheck, Pause, Play, ChevronLeft, ChevronRight } from 'lucide-react';
const slides = [
{image:'laboratory.jpg',alt:'محیط آزمایشگاهی و تجهیزات تخصصی',title:'اعتماد، نخستین گام',line:'در مسیر',accent:'سلامت.',description:'راهکارهای تخصصی استریلیزاسیون و کنترل عفونت؛',detail:'همراه مراکز درمانی، از انتخاب تا استفاده.',caption:'دانش، دقت، اعتماد',en:'SCIENCE IN SERVICE OF HEALTH',cta:'محصولات تخصصی ما',href:'#products'},
{image:'care.jpg',alt:'متخصصان در محیط پزشکی',title:'همراه شما، در مسیر',line:'مراقبت',accent:'آگاهانه.',description:'محصولات تخصصی بهداشت و کنترل عفونت؛',detail:'برای بیمارستان‌ها، آزمایشگاه‌ها و مراکز درمانی.',caption:'همراه مراکز درمانی',en:'INFECTION CONTROL',cta:'مشاوره و انتخاب محصول',href:'/tmas-bama'},
{image:'research.jpg',alt:'پژوهش و فعالیت در آزمایشگاه',title:'دانش، پایهٔ پیشرفت',line:'و انتخاب',accent:'مطمئن.',description:'آموزش، کلید توسعه و تخصص ما است؛',detail:'هر روز، بهتر از دیروز.',caption:'بهبود مستمر، با دانش و تجربه',en:'KNOWLEDGE AND EXPERIENCE',cta:'آشنایی با سلامت گستر',href:'/about-us'},
];
export default function HeroSlider(){
const [index,setIndex]=useState(0);
const [paused,setPaused]=useState(false);
const [hovered,setHovered]=useState(false);
const [reduced,setReduced]=useState(true);
const [hidden,setHidden]=useState(false);
useEffect(()=>{const query=window.matchMedia('(prefers-reduced-motion: reduce)');const sync=()=>setReduced(query.matches);const visibility=()=>setHidden(document.hidden);sync();visibility();query.addEventListener('change',sync);document.addEventListener('visibilitychange',visibility);return()=>{query.removeEventListener('change',sync);document.removeEventListener('visibilitychange',visibility)}},[]);
const running=!paused&&!hovered&&!reduced&&!hidden;
useEffect(()=>{if(!running)return;const timer=setTimeout(()=>setIndex(i=>(i+1)%slides.length),6500);return()=>clearTimeout(timer)},[index,running]);
const choose=(next:number)=>{setPaused(true);setIndex((next+slides.length)%slides.length)};
const slide=slides[index];
return <section className="hero hero-slider" aria-label="معرفی سلامت گستر اعتماد" aria-roledescription="اسلایدر" onMouseEnter={()=>setHovered(true)} onMouseLeave={()=>setHovered(false)} onFocusCapture={()=>setPaused(true)}>
<div className="hero-photo"><div className="hero-images">{slides.map((s,i)=><img key={s.image} src={'/images/'+s.image} alt={i===index?s.alt:''} aria-hidden={i!==index} className={i===index?'active':''} loading="eager"/>)}</div><div key={index} className="photo-label"><span className="cross">+</span><div>{slide.caption}<small>{slide.en}</small></div></div></div>
<div className="hero-copy"><div key={index} className="hero-sequence" aria-live={paused?'polite':'off'} aria-atomic="true"><span className="eyebrow">SGE MEDICAL GROUP <i/></span><h1><span>{slide.title}</span><span>{slide.line} <em>{slide.accent}</em></span></h1><p>{slide.description}<br/>{slide.detail}</p><div className="actions"><Link className="button" href={slide.href}>{slide.cta}<MoveLeft size={19}/></Link><Link className="textlink" href="/about-us">درباره ما <ArrowDownLeft size={19}/></Link></div></div>
<div className="hero-bottom"><ShieldCheck size={25}/><span>شرکت مهندسی سلامت گستر اعتماد<small>مشاور و مجری استریل و کنترل عفونت</small></span></div>
<div className="slider-controls"><div className="slider-dots">{slides.map((s,i)=><button key={s.image} aria-label={'اسلاید '+(i+1)+': '+s.caption} aria-current={index===i?'true':undefined} onClick={()=>choose(i)}><span className={index===i?'selected':''}/></button>)}</div><span className="slide-count" dir="ltr">0{index+1} / 03</span><div className="slider-arrows"><button aria-label="اسلاید قبلی" onClick={()=>choose(index-1)}><ChevronRight size={18}/></button><button aria-label={paused?'پخش خودکار':'توقف پخش خودکار'} aria-pressed={paused} disabled={reduced} onClick={()=>setPaused(p=>!p)}>{paused||reduced?<Play size={15}/>:<Pause size={15}/>}</button><button aria-label="اسلاید بعدی" onClick={()=>choose(index+1)}><ChevronLeft size={18}/></button></div></div>
</div>
</section>
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { ChevronDown, ArrowUpLeft, FlaskConical, Microscope, Package, ShieldCheck } from 'lucide-react';
import catalog from '../app/catalog.json';
const groups = [
{ title: 'اندیکاتورهای شیمیایی', icon: FlaskConical, start: 2, end: 15 },
{ title: 'اندیکاتورهای بیولوژیکال', icon: Microscope, start: 0, end: 2 },
{ title: 'بسته‌بندی استریل', icon: Package, start: 15, end: 26 },
{ title: 'بهداشت و کنترل عفونت', icon: ShieldCheck, start: 26, end: 30 },
];
export default function ProductMenu() {
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
function outside(event: PointerEvent) {
if (!root.current?.contains(event.target as Node)) setOpen(false);
}
function escape(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false);
trigger.current?.focus();
}
}
document.addEventListener('pointerdown', outside);
document.addEventListener('keydown', escape);
return () => {
document.removeEventListener('pointerdown', outside);
document.removeEventListener('keydown', escape);
};
}, [open]);
return <div className="product-menu" ref={root} onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) setOpen(false);
}}>
<button ref={trigger} className="product-menu-trigger" aria-expanded={open} aria-controls="product-mega-menu" onClick={() => setOpen(!open)}>
محصولات <ChevronDown size={14} aria-hidden="true" />
</button>
<div id="product-mega-menu" className="mega-menu" hidden={!open}>
<div className="mega-heading"><div><strong>محصولات تخصصی ما</strong><span>استریلیزاسیون و کنترل عفونت</span></div><Link href="/#products" onClick={() => setOpen(false)}>همه محصولات <ArrowUpLeft size={17} /></Link></div>
<div className="mega-columns">{groups.map(group => <section key={group.title}>
<h2><group.icon size={21} aria-hidden="true" />{group.title}</h2>
<ul>{catalog.slice(group.start, group.end).map(product => <li key={product.source}>
<a href={product.downloaded ? product.url : '/tmas-bama'} target={product.downloaded ? '_blank' : undefined} rel={product.downloaded ? 'noreferrer' : undefined} onClick={() => setOpen(false)}>
<span>{product.title}</span><small>{product.downloaded ? 'PDF' : 'درخواست'}</small>
</a>
</li>)}</ul>
</section>)}</div>
</div>
</div>;
}
+29
View File
@@ -0,0 +1,29 @@
import { cache } from 'react';
export const DOMAIN = 'sgemed.com';
export const API_BASE = 'https://api.meshkee.com/api/v1';
export const CUSTOMER_URL = 'https://customer.sgemed.com';
export const CUSTOMER_PROFILE_URL = `${CUSTOMER_URL}/profile`;
export const WEBSITE_MANAGEMENT_URL = 'https://business.sgemed.com/website';
export class ApiError extends Error {
constructor(public status: number) { super(`Meshkee request failed (${status})`); }
}
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { ...init, cache: 'no-store', signal: AbortSignal.timeout(12000) });
if (!response.ok) throw new ApiError(response.status);
return response.json() as Promise<T>;
}
export const getTenant = cache(() => api<{id: number | string; nameFa: string}>(`/tenants/${DOMAIN}`));
export async function tenantApi<T>(path: string, init?: RequestInit): Promise<T> {
await getTenant();
return api<T>(`/tenants/${DOMAIN}${path}`, init);
}
export function safeUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value) return;
try { const url = new URL(value); if (url.protocol === 'https:') return url.href; } catch {}
}
export async function getFavicon() {
try {
const data = await tenantApi<{faviconUrl: string | null}>('/website/favicon');
return safeUrl(data.faviconUrl) || '/favicon.svg';
} catch { return '/favicon.svg'; }
}
+28
View File
@@ -0,0 +1,28 @@
import { cache } from 'react';
import sanitizeHtml from 'sanitize-html';
import { tenantApi, safeUrl } from './api';
export type Blog = { id: string | number; title: string; slug?: string; excerpt?: string; summary?: string; content?: string; text?: string; coverUrl?: string; imageUrl?: string; publishedAt?: string };
export type BlogList = { items: Blog[]; total: number; page: number; pageSize: number };
export const getBlogs = cache(async (page: number) => {
const data = await tenantApi<BlogList>(`/blogs?page=${page}&pageSize=12`);
if (!Array.isArray(data.items) || typeof data.total !== 'number') throw new Error('Invalid blog list response');
return data;
});
export const getBlog = cache(async (id: string) => {
const data = await tenantApi<{blog: Blog}>(`/blogs/by-id/${encodeURIComponent(id)}`);
if (!data.blog || !data.blog.id || typeof data.blog.title !== 'string') throw new Error('Invalid blog detail response');
return data.blog;
});
export function titleSlug(title: string) { return title.trim().replace(/[^\p{L}\p{N}\s-]/gu, '').replace(/[\s-]+/g, '-') || 'article'; }
export function blogHref(blog: Blog) { return `/blog/${encodeURIComponent(blog.id)}/${encodeURIComponent(titleSlug(blog.title))}`; }
export function plainText(value: string) { return sanitizeHtml(value, {allowedTags: [], allowedAttributes: {}}); }
export function summary(blog: Blog) { return plainText(blog.excerpt || blog.summary || blog.content || blog.text || '').slice(0,160); }
export function cover(blog: Blog) { return safeUrl(blog.coverUrl || blog.imageUrl); }
export function articleHtml(blog: Blog) {
return sanitizeHtml(blog.content || blog.text || '', {
allowedTags: ['p','br','h2','h3','h4','strong','b','em','i','ul','ol','li','blockquote','a','img','figure','figcaption','table','thead','tbody','tr','th','td','hr','pre','code'],
allowedAttributes: {a:['href','title'],img:['src','alt','width','height']},
allowedSchemes: ['https','http','mailto'], allowedSchemesByTag: {img:['https']},
allowProtocolRelative: false, transformTags: {h1:'h2'},
});
}
+17
View File
@@ -0,0 +1,17 @@
export function normalizeCell(value: string) {
const latin=value.replace(/[۰-۹]/g,c=>String('۰۱۲۳۴۵۶۷۸۹'.indexOf(c))).replace(/[٠-٩]/g,c=>String('٠١٢٣٤٥٦٧٨٩'.indexOf(c))).replace(/[\s()-]/g,'');
if(latin.startsWith('09'))return '+98'+latin.slice(1);
if(latin.startsWith('0098'))return '+'+latin.slice(2);
return latin;
}
export function validateContact(input: Record<string,unknown>) {
const fields=['title','name','email','cellNumber','text'] as const;
const data=Object.fromEntries(fields.map(key=>[key,typeof input[key]==='string'?input[key].trim():''])) as Record<typeof fields[number],string>;
const errors:Record<string,string>={};
for(const key of ['title','name','text'] as const)if(!data[key])errors[key]='تکمیل این بخش ضروری است.';
for(const [key,max] of Object.entries({title:200,name:120,email:254,cellNumber:25,text:5000}))if(data[key as keyof typeof data].length>max)errors[key]='متن واردشده بیش از حد طولانی است.';
if(data.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email))errors.email='ایمیل معتبر وارد کنید.';
data.cellNumber=normalizeCell(data.cellNumber);
if(data.cellNumber&&!/^\+[1-9]\d{7,14}$/.test(data.cellNumber))errors.cellNumber='شماره را با ۰۹ یا کد کشور وارد کنید.';
return {data,errors};
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
export const ACCESS_COOKIE='meshkee_customer_access_token';
export const REFRESH_COOKIE='meshkee_customer_refresh_token';
export function cookieOptions(request:Request){
const host=(request.headers.get('host')||'').split(':')[0];
const production=host==='sgemed.com'||host.endsWith('.sgemed.com');
return {path:'/',httpOnly:true,sameSite:'lax' as const,secure:production||new URL(request.url).protocol==='https:',...(production?{domain:'.sgemed.com'}:{})};
}
export function setSessionCookies(response:NextResponse,request:Request,tokens:{accessToken:string;refreshToken:string}){
// Session lifetime remains controlled by the backend; never infer claims from an unverified JWT.
const options=cookieOptions(request);
response.cookies.set(ACCESS_COOKIE,tokens.accessToken,options);
response.cookies.set(REFRESH_COOKIE,tokens.refreshToken,options);
}
export function sameOrigin(request:Request){
const origin=request.headers.get('origin');
if(!origin)return false;
try{return new URL(origin).host===request.headers.get('host')}catch{return false}
}
+1 -1
View File
@@ -1,3 +1,3 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { output: 'export', images: { unoptimized: true } };
const nextConfig: NextConfig = { images: { unoptimized: true } };
export default nextConfig;
+278
View File
@@ -24,6 +24,7 @@
"react-resizable-panels": "4.5.8",
"react-server-dom-webpack": "19.2.6",
"recharts": "3.8.0",
"sanitize-html": "^2.17.7",
"shadcn": "4.18.0",
"tailwind-merge": "3.6.0",
"tw-animate-css": "1.4.0",
@@ -37,6 +38,7 @@
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/sanitize-html": "^2.16.1",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"oxfmt": "0.61.0",
@@ -4129,6 +4131,121 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/sanitize-html/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/@types/sanitize-html/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -5288,6 +5405,12 @@
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debounce-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz",
@@ -5422,6 +5545,73 @@
"node": ">=0.3.1"
}
},
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dot-prop": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
@@ -5553,6 +5743,18 @@
"node": ">=8.6"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -5686,6 +5888,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -6214,6 +6428,28 @@
"node": ">=16.9.0"
}
},
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -6463,6 +6699,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -6654,6 +6899,15 @@
"node": ">=6"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -8272,6 +8526,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -8926,6 +9186,24 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.7",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/satori": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/satori/-/satori-0.16.0.tgz",
+7 -4
View File
@@ -6,11 +6,12 @@
"node": ">=22.13.0"
},
"scripts": {
"dev": "next dev --hostname 0.0.0.0",
"build": "next build",
"start": "next start",
"dev": "vinext dev --host 0.0.0.0",
"build": "vinext build",
"start": "vinext start",
"lint": "oxlint",
"format": "oxfmt"
"format": "oxfmt",
"prebuild": "node scripts/sitemap-config.mjs"
},
"dependencies": {
"@base-ui/react": "1.7.0",
@@ -29,6 +30,7 @@
"react-resizable-panels": "4.5.8",
"react-server-dom-webpack": "19.2.6",
"recharts": "3.8.0",
"sanitize-html": "^2.17.7",
"shadcn": "4.18.0",
"tailwind-merge": "3.6.0",
"tw-animate-css": "1.4.0",
@@ -42,6 +44,7 @@
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/sanitize-html": "^2.16.1",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"oxfmt": "0.61.0",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"baseUrl": "https://sgemed.com",
"staticPages": [
{
"path": "/about-us",
"priority": 0.6
},
{
"path": "/blog",
"priority": 0.6
},
{
"path": "/",
"priority": 1
},
{
"path": "/tmas-bama",
"priority": 0.6
}
]
}
+5
View File
@@ -0,0 +1,5 @@
import {readdir,mkdir,writeFile} from 'node:fs/promises';
import path from 'node:path';
const staticPages=[];
async function scan(dir,parts=[]){for(const entry of await readdir(dir,{withFileTypes:true})){if(entry.isDirectory()&&!entry.name.startsWith('[')&&!entry.name.startsWith('_')&&!['api','account','admin','login','cart','checkout'].includes(entry.name))await scan(path.join(dir,entry.name),entry.name.startsWith('(')?parts:[...parts,entry.name]);else if(entry.isFile()&&/^page\.(tsx|jsx|js|ts)$/.test(entry.name))staticPages.push({path:'/'+parts.join('/'),priority:parts.length?0.6:1})}}
await scan('app');await mkdir('public/meshkee',{recursive:true});await writeFile('public/meshkee/sitemap-config.json',JSON.stringify({baseUrl:'https://sgemed.com',staticPages},null,2)+'\n');
+11
View File
@@ -0,0 +1,11 @@
const test=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path');const ts=require('typescript');const vm=require('node:vm');const {createRequire}=require('node:module');
function load(file,mocks={}){const full=path.resolve(file);const source=ts.transpileModule(fs.readFileSync(full,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;const module={exports:{}};const localRequire=createRequire(full);vm.runInNewContext('(function(require,module,exports){'+source+'\n})',{Response,Request,URL,AbortSignal,console})(name=>name in mocks?mocks[name]:localRequire(name),module,module.exports);return module.exports}
const contact=load('lib/meshkee/contact.ts');const session=load('lib/meshkee/session.ts');
function contactRoute(api){return load('app/api/contact/route.ts',{'../../../lib/meshkee/session':session,'../../../lib/meshkee/contact':contact,'../../../lib/meshkee/api':{tenantApi:api}})}
const request=(body,origin='https://sgemed.com')=>new Request('https://sgemed.com/api/contact',{method:'POST',headers:{host:'sgemed.com',origin,'content-type':'application/json'},body:JSON.stringify(body)});
test('contact success forwards only validated fields and returns 201',async()=>{let sent;const route=contactRoute(async(p,init)=>{sent={p,data:JSON.parse(init.body)};return {submission:{id:1}}});const response=await route.POST(request({title:'Test',name:'Test',text:'Test',cellNumber:'09125537630',extra:'ignored'}));assert.equal(response.status,201);assert.equal(sent.p,'/contact-submissions');assert.equal(sent.data.cellNumber,'+989125537630');assert.equal(sent.data.extra,undefined)});
test('invalid and cross-origin forms never reach backend',async()=>{let called=false;const route=contactRoute(async()=>{called=true});assert.equal((await route.POST(request({}))).status,400);assert.equal((await route.POST(request({},'https://other.example'))).status,403);assert.equal(called,false)});
test('upstream failure is not reported as success',async()=>{const route=contactRoute(async()=>{throw new Error('offline')});assert.equal((await route.POST(request({title:'a',name:'b',text:'c'}))).status,502)});
test('logout expires parent and host-only cookies and rejects cross-origin',async()=>{const route=load('app/api/logout/route.ts',{'../../../lib/meshkee/session':session});const response=await route.POST(request({}));assert.equal(response.status,200);const cookies=response.headers.get('set-cookie');assert.match(cookies,/Domain=.sgemed.com/i);assert.match(cookies,/meshkee_customer_access_token/);assert.match(cookies,/meshkee_customer_refresh_token/);assert.match(cookies,/Max-Age=0/);assert.equal((await route.POST(request({},'https://other.example'))).status,403)});
test('blog rich text removes executable HTML while retaining headings',()=>{const blog=load('lib/meshkee/blog.ts',{'./api':{tenantApi:()=>{},safeUrl:()=>{}}});const html=blog.articleHtml({content:'<h2>عنوان</h2><script>alert(1)</script><img src="https://example.com/a.jpg" onerror="alert(1)"><a href="javascript:alert(1)">x</a>'});assert.match(html,/<h2>عنوان<\/h2>/);assert.doesNotMatch(html,/script|onerror|javascript/)});
test('account refresh validates new token and never exposes tokens',async()=>{class ApiError extends Error{constructor(status){super();this.status=status}}const calls=[];const route=load('app/api/account/route.ts',{'next/headers':{cookies:async()=>({get:name=>({value:name.includes('refresh')?'refresh-secret':'expired-secret'})})},'../../../lib/meshkee/api':{ApiError,api:async(p,init)=>{calls.push(p);if(p==='/auth/refresh')return {accessToken:'new-secret',refreshToken:'new-refresh'};if(init.headers.Authorization==='Bearer expired-secret')throw new ApiError(401);return {user:{firstName:'نام',lastName:'کاربر',businesses:[{businessId:3,teamRole:'editor'}]}}},getTenant:async()=>({id:3})},'../../../lib/meshkee/session':session});const response=await route.GET(new Request('https://sgemed.com/api/account',{headers:{host:'sgemed.com'}}));assert.equal(response.status,200);const body=await response.text();assert.match(body,/نام/);assert.match(body,/"isAdmin":true/);assert.doesNotMatch(body,/secret|refresh/);assert.deepEqual(calls,['/auth/me','/auth/refresh','/auth/me']);assert.match(response.headers.get('set-cookie'),/HttpOnly/)});
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateContact, normalizeCell } from '../lib/meshkee/contact.ts';
test('required fields are rejected before submission',()=>{
const result=validateContact({});assert.deepEqual(Object.keys(result.errors).sort(),['name','text','title']);
});
test('Persian and local mobile numbers normalize to E.164',()=>{
assert.equal(normalizeCell('۰۹۱۲ ۵۵۳ ۷۶۳۰'),'+989125537630');
assert.equal(normalizeCell('0098 912 5537630'),'+989125537630');
});
test('valid form strips unknown fields and accepts optional empty contact fields',()=>{
const result=validateContact({title:' عنوان ',name:'نام',text:'پیام',isAdmin:true});
assert.deepEqual(result.errors,{});assert.equal(result.data.title,'عنوان');assert.equal('isAdmin' in result.data,false);
});
test('invalid email, phone and oversized messages remain errors',()=>{
const result=validateContact({title:'a',name:'b',text:'x'.repeat(5001),email:'not-email',cellNumber:'123'});
assert.deepEqual(Object.keys(result.errors).sort(),['cellNumber','email','text']);
});