diff --git a/.openai/hosting.json b/.openai/hosting.json index 70ca8b6..3379fde 100644 --- a/.openai/hosting.json +++ b/.openai/hosting.json @@ -1 +1,5 @@ -{"project_id":"appgprj_6a9ba2dadd448191bc030f95aa2edb04","static":{"directory":"out"},"d1":null,"r2":null} +{ + "project_id": "appgprj_6a9ba2dadd448191bc030f95aa2edb04", + "d1": null, + "r2": null +} diff --git a/CONTEXT.md b/CONTEXT.md index 3c0dbf2..05e7967 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/app/api/account/route.ts b/app/api/account/route.ts new file mode 100644 index 0000000..321c955 --- /dev/null +++ b/app/api/account/route.ts @@ -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})} +} diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..19b71b2 --- /dev/null +++ b/app/api/contact/route.ts @@ -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})} +} diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts new file mode 100644 index 0000000..5d7a412 --- /dev/null +++ b/app/api/logout/route.ts @@ -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; +} diff --git a/app/blog/[id]/[slug]/page.tsx b/app/blog/[id]/[slug]/page.tsx new file mode 100644 index 0000000..95cf032 --- /dev/null +++ b/app/blog/[id]/[slug]/page.tsx @@ -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{ + 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 <>
مجله سلامت گستر ←

{post.title}

{cover(post)&&{post.title}/}
بازگشت به مجله ←
; +} diff --git a/app/blog/error.tsx b/app/blog/error.tsx new file mode 100644 index 0000000..f011532 --- /dev/null +++ b/app/blog/error.tsx @@ -0,0 +1,2 @@ +'use client'; +export default function ErrorPage({reset}:{reset:()=>void}) {return

دریافت مطلب ممکن نشد

ارتباط با مجله برقرار نشد. لطفاً دوباره تلاش کنید.

} diff --git a/app/blog/loading.tsx b/app/blog/loading.tsx new file mode 100644 index 0000000..52b3587 --- /dev/null +++ b/app/blog/loading.tsx @@ -0,0 +1 @@ +export default function Loading(){return
در حال دریافت مطالب…
} diff --git a/app/blog/not-found.tsx b/app/blog/not-found.tsx new file mode 100644 index 0000000..88f6836 --- /dev/null +++ b/app/blog/not-found.tsx @@ -0,0 +1,2 @@ +import Link from 'next/link'; +export default function Missing(){return

مطلب پیدا نشد

این مطلب در دسترس نیست یا هنوز منتشر نشده است.

بازگشت به مجله
} diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..0294e2d --- /dev/null +++ b/app/blog/page.tsx @@ -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 <>

دریافت مطالب ممکن نشد

لطفاً کمی بعد دوباره تلاش کنید.

تلاش دوباره
} + return <>
{data.items.length ?
{data.items.map(post=>{cover(post)?{post.title}/:
}

{post.title}

{summary(post)}

مطالعه مطلب
)}
:

به‌زودی با مطالب تازه همراه شما هستیم

هنوز مطلبی منتشر نشده است.

}{data.total>12&&}
; +} +function Banner(){return
SGE JOURNAL

مجله سلامت گستر

دانش و تازه‌های استریلیزاسیون و کنترل عفونت

} diff --git a/app/globals.css b/app/globals.css index 580f454..a89ab38 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,80 @@ @font-face{font-family:Iran;src:url('/iran-sans.ttf') format('truetype');font-display:swap}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:30px}body{margin:0;color:#17323c;background:#fff;font-family:Iran,Tahoma,sans-serif;font-size:16px;line-height:1.9}a{color:inherit;text-decoration:none}button,a,summary{-webkit-tap-highlight-color:transparent}a:focus-visible,summary:focus-visible{outline:3px solid #057e99;outline-offset:5px}a:hover{color:#009cbe}img{max-width:100%;display:block}.wrap{width:min(1280px,90%);margin:auto}.topbar{background:#f0f7f8;font-size:12px;color:#4e6971}.topbar .wrap{display:flex;justify-content:space-between;padding:8px 0}.topbar b{margin-right:18px}.header{display:flex;align-items:center;justify-content:space-between;height:110px;gap:30px}.brand img{width:148px;height:82px;object-fit:contain}.header nav{display:flex;gap:40px;font-size:14px}.button{display:inline-flex;align-items:center;justify-content:center;gap:27px;background:#009fc0;color:white;padding:15px 25px;font-size:14px;border:1px solid #009fc0}.button:hover{background:#087f98;color:white}.button.small{padding:10px 19px}.hero{min-height:540px;display:grid;grid-template-columns:1fr 1fr;background:#eef7f9;direction:ltr}.hero-photo{position:relative;min-height:540px}.hero-photo>img{width:100%;height:100%;object-fit:cover;position:absolute}.hero-photo:after{content:'';position:absolute;inset:0;background:linear-gradient(0deg,#12374160,transparent 60%)}.photo-label{position:absolute;bottom:35px;left:8%;z-index:1;display:flex;align-items:center;gap:20px;color:#fff;direction:rtl}.photo-label small{display:block;font:10px Arial;letter-spacing:2px;margin-top:7px}.cross{font:60px Arial;font-weight:200}.hero-copy{padding:60px 10% 30px 9%;direction:rtl}.eyebrow{display:flex;align-items:center;gap:12px;color:#008eaa;font-size:12px;letter-spacing:1px;font-weight:bold}.eyebrow i{width:28px;height:2px;background:#00a1bf}h1{font-size:46px;line-height:1.75;margin:19px 0;font-weight:700;letter-spacing:-1.5px}h1 em{font-style:normal;color:#009fbe}p{color:#637780;line-height:2.1}.hero-copy>p{font-size:16px}.actions{display:flex;gap:24px;align-items:center;margin-top:30px}.textlink{display:inline-flex;gap:12px;align-items:center;font-size:14px}.hero-bottom{display:flex;gap:15px;align-items:center;border-top:1px solid #d1e4e9;padding-top:24px;margin-top:40px;color:#4d747f;font-size:13px}.hero-bottom small{display:block;color:#78919a;font-size:11px}.categories{display:grid;grid-template-columns:repeat(4,1fr);padding:31px 0;border-bottom:1px solid #dce7e9}.categories>a{display:flex;gap:14px;align-items:center;padding:9px 23px;border-left:1px solid #dce7e9}.categories>a:first-child{padding-right:0}.categories>a:last-child{border:0}.categories svg{color:#009cba;flex-shrink:0}.categories strong{font-size:13px;font-weight:500}.categories small{font:11px Arial;color:#849399;display:block;margin-top:5px}.categories>a>svg:last-child{margin-right:auto}.section{padding:75px 0}.section-heading{display:flex;justify-content:space-between;gap:30px;align-items:center;margin-bottom:30px}h2{font-size:29px;line-height:1.8;margin:12px 0 22px}h3{font-size:17px}.section-heading p{font-size:14px}.catalog{border-top:1px solid #cbdde1}details{border-bottom:1px solid #cbdde1;scroll-margin-top:30px}summary{padding:24px 0;cursor:pointer;display:flex;justify-content:space-between;gap:16px;align-items:center;list-style:none}summary::-webkit-details-marker{display:none}summary>span:last-child{font-size:12px;color:#69818b}.group-title{display:flex;align-items:center;gap:18px}.group-title svg{color:#009fbe}.group-title small{font:12px Arial;color:#82949c}.product-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:#dce7e9;border:1px solid #dce7e9;margin-bottom:25px}.product{background:#fff;padding:22px;display:flex;flex-direction:column;min-height:175px}.product:hover{background:#f1fafb}.pdf{font:10px Arial;border:1px solid #b9dce3;width:35px;padding:5px;color:#008fac}.product h3{font-size:14px;font-weight:500;line-height:1.9;margin:18px 0}.download{font-size:11px;color:#008dab;display:flex;justify-content:space-between;margin-top:auto}.education{background:#f0f7f8;padding:65px 0}.split{display:grid;grid-template-columns:1fr 1fr;gap:80px;align-items:center}.split>img{width:100%;height:350px;object-fit:cover}.split p{font-size:15px}.split .textlink{color:#008fab;margin-top:12px}.info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:40px;margin-top:35px}.info-grid article{border-top:2px solid #00a0bd;padding-top:25px}.info-grid article>span{font:11px Arial;color:#008fa9;letter-spacing:1px}.info-grid p{font-size:14px}.info-grid a{font-size:13px;color:#008fab}.contactband{background:#009fbd;color:white;padding:35px 0}.contactband>.wrap{display:flex;align-items:center;justify-content:space-between;gap:25px}.contactband h2{font-size:24px;margin:0}.contactband p{color:#d4f1f6;font-size:14px;margin:8px 0}.button.white{background:white;color:#007f98;border-color:white;white-space:nowrap}footer{background:#f6f8f9;padding-top:55px}.footergrid{display:grid;grid-template-columns:1.3fr 1fr 1fr;gap:60px;padding-bottom:40px}.footerlogo{width:110px;height:60px;object-fit:contain}.footergrid h3{font-size:14px}.footergrid p,.footergrid a{font-size:13px;color:#69808a}.footergrid a{display:block;margin:10px 0}.copyright{border-top:1px solid #dce5e8;display:flex;justify-content:space-between;padding:20px 0;color:#7a9099;font-size:11px}.pagehead{background:#edf7f9;padding:55px 0}.pagehead h1{font-size:38px;margin-bottom:4px}.pagehead p{margin:0}.prose{max-width:900px}.prose p{line-height:2.5}.quote{border-right:3px solid #009fbd;padding:15px 30px;background:#f0f8fa;margin:35px 0;font-size:22px}.contactcards{display:grid;grid-template-columns:repeat(2,1fr);gap:24px}.contactcard{padding:35px;background:#f1f7f8;border-top:2px solid #009fbd}.contactcard a{display:block;color:#0088a4;font-size:20px;margin:12px 0}.contactcard small{font-size:14px;color:#627b85;display:block}.contactcard h2{font-size:21px} @media(min-width:1600px){.hero-copy{padding-right:18%}}@media(max-width:1000px){.header nav{gap:20px}.hero-copy{padding:35px 7%}h1{font-size:35px}.actions{gap:14px;flex-wrap:wrap}.categories{grid-template-columns:repeat(2,1fr);gap:15px}.categories>a:nth-child(2){border:0}.categories>a:first-child{padding-right:23px}.product-grid{grid-template-columns:repeat(3,1fr)}.split{gap:35px}}@media(max-width:650px){.topbar .wrap>span{display:none}.topbar .wrap{justify-content:center}.header{height:auto;flex-wrap:wrap;gap:10px;padding-top:12px;padding-bottom:15px}.brand img{width:115px;height:60px}.header nav{order:3;width:100%;justify-content:space-between;font-size:13px;gap:8px;margin-top:10px}.button.small{font-size:12px;padding:8px 12px}.hero{display:flex;flex-direction:column-reverse}.hero-copy{padding:35px 6%}.hero-photo{min-height:280px}h1{font-size:34px}.hero-copy>p{font-size:14px}.hero-bottom{margin-top:25px}.categories{padding:22px 0;gap:10px}.categories>a{padding:8px!important;gap:8px}.categories strong{font-size:11px}.categories small{font-size:9px}.categories svg{width:23px}.categories>a>svg:last-child{display:none}.section{padding:45px 0}.section-heading{display:block}h2{font-size:24px}.group-title{gap:10px;font-size:14px}.group-title small{display:none}summary>span:last-child{font-size:10px}.product-grid{grid-template-columns:repeat(2,1fr)}.product{padding:15px;min-height:180px}.split{grid-template-columns:1fr;gap:25px}.split>img{height:260px}.education{padding:35px 0}.info-grid{grid-template-columns:1fr;gap:20px}.contactband>.wrap{display:block}.contactband h2{font-size:22px}.contactband .button{margin-top:15px}.footergrid{grid-template-columns:1fr 1fr;gap:25px}.footergrid>div:first-child{grid-column:1/-1}.copyright{gap:20px;font-size:9px}.contactcards{grid-template-columns:1fr}.pagehead{padding:30px 0}.pagehead h1{font-size:30px}} @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} + +/* Compact title banners with a quiet medical-cross pattern. */ +.pagehead{padding:25px 0;position:relative;isolation:isolate;overflow:hidden;background:#f8fcfd} +.pagehead:before{content:'';position:absolute;inset:0;z-index:-1;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='42' height='42' viewBox='0 0 42 42'%3E%3Cpath d='M21 16v10m-5-5h10' fill='none' stroke='%23009fbd' stroke-opacity='.34' stroke-width='1.5'/%3E%3C/svg%3E");mask-image:linear-gradient(to right,#000 12%,rgba(0,0,0,.75) 45%,rgba(0,0,0,.12) 80%,transparent)} +.pagehead h1{font-size:32px;line-height:1.65;margin:8px 0 4px} +.pagehead .eyebrow{font-size:11px}.pagehead p{font-size:14px} +.header{position:relative;z-index:20}.header nav{align-items:center} +.product-menu-trigger{display:inline-flex;align-items:center;gap:6px;border:0;background:none;color:inherit;font:inherit;padding:10px 0;cursor:pointer} +.product-menu-trigger:hover,.product-menu-trigger[aria-expanded=true]{color:#008eaa} +.product-menu-trigger[aria-expanded=true] svg{transform:rotate(180deg)} +.product-menu-trigger:focus-visible{outline:3px solid #057e99;outline-offset:5px} +.mega-menu{position:absolute;top:calc(100% - 8px);inset-inline:0;background:#fff;border:1px solid #d7e6e9;border-top:3px solid #009fbd;box-shadow:0 18px 35px #17323c16;max-height:min(650px,75dvh);overflow:auto} +.mega-menu[hidden]{display:none} +.mega-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;background:#eff7f9;padding:18px 25px;border-bottom:1px solid #d7e6e9} +.mega-heading strong{font-size:15px}.mega-heading span{display:block;color:#71858c;font-size:12px;margin-top:3px} +.mega-heading>a{display:flex;align-items:center;gap:10px;color:#008aa6;font-size:12px} +.mega-columns{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));padding:25px;gap:25px} +.mega-columns section{min-width:0}.mega-columns h2{display:flex;align-items:center;gap:9px;font-size:14px;margin:0 0 12px;padding-bottom:14px;border-bottom:1px solid #d7e6e9}.mega-columns h2 svg{color:#009fbd;flex-shrink:0} +.mega-columns ul{list-style:none;margin:0;padding:0}.mega-columns li a{display:flex;align-items:baseline;justify-content:space-between;gap:12px;font-size:12px;line-height:1.9;padding:7px 5px}.mega-columns li a:hover{background:#eff7f9}.mega-columns li small{font:10px Iran,Tahoma,sans-serif;color:#008aa6;flex-shrink:0} +@media(max-width:1000px){.mega-columns{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:650px){.pagehead{padding:19px 0}.pagehead h1{font-size:25px}.pagehead p{font-size:13px}.mega-menu{top:100%;max-height:70dvh}.mega-heading{padding:15px;gap:10px}.mega-heading strong{font-size:13px}.mega-heading span{font-size:11px}.mega-heading>a{font-size:11px}.mega-columns{grid-template-columns:1fr;padding:18px;gap:22px}.product-menu-trigger{padding:0}} + +/* English titles use local Montserrat; Persian glyphs retain Iran Sans. */ +@font-face{font-family:Montserrat;src:url('/fonts/montserrat-regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap;unicode-range:U+0000-024F,U+1E00-1EFF,U+2000-206F} +@font-face{font-family:Montserrat;src:url('/fonts/montserrat-bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap;unicode-range:U+0000-024F,U+1E00-1EFF,U+2000-206F} +.eyebrow,.photo-label small,.categories small,.group-title small,.info-grid article>span,.copyright span[dir=ltr],.product h3,.mega-columns li a>span{font-family:Montserrat,Iran,Tahoma,sans-serif} + +/* Contact directory: paired labels and numbers, with compact secondary channels. */ +.contact-section{padding-top:48px;padding-bottom:60px} +.contact-section .section-heading{margin-bottom:28px} +.contact-section .section-heading h2{margin-bottom:8px} +.contact-primary{display:grid;grid-template-columns:1fr 1fr;gap:22px} +.contact-panel{padding:28px 30px;border:1px solid #dbe7eb;background:#fff;min-width:0} +.office-panel{background:#f0f8fa;border-color:#e0eef1} +.contact-panel-heading{display:flex;align-items:center;gap:16px;margin-bottom:22px} +.contact-icon{display:flex;align-items:center;justify-content:center;width:50px;height:50px;background:#fff;color:#008ca7;flex-shrink:0} +.expert-panel .contact-icon{background:#edf7f9} +.contact-panel-heading .eyebrow{font-size:11px;letter-spacing:1.5px} +.contact-panel-heading h2{font-size:21px;line-height:1.7;margin:4px 0 0} +.contact-line{display:flex;align-items:baseline;justify-content:space-between;gap:18px;padding:17px 0;border-top:1px solid #d9e7eb} +.contact-line>span{font-size:14px;color:#516c77} +.contact-line a{display:flex;align-items:center;gap:12px;color:#0c596d;min-height:32px} +.contact-line a:hover{color:#009fbd} +.contact-line a svg{color:#0095b2;flex-shrink:0} +.contact-line bdi,.contact-channel bdi{font-family:Montserrat,Iran,sans-serif;direction:ltr;unicode-bidi:isolate;display:inline-block;font-size:18px;font-weight:400;letter-spacing:.2px} +.contact-numbers{display:flex;flex-direction:column;gap:7px} +.contact-secondary{display:grid;grid-template-columns:1.2fr 1fr 1fr;margin-top:22px;border:1px solid #dbe7eb} +.contact-channel{display:flex;align-items:center;gap:15px;padding:24px;min-width:0;border-inline-end:1px solid #dbe7eb} +.contact-channel:last-child{border-inline-end:0} +a.contact-channel:hover{background:#f0f8fa} +.channel-icon{display:flex;color:#008eaa;flex-shrink:0} +.contact-channel h3{font-size:13px;font-weight:400;color:#657d87;margin:0 0 6px} +.contact-channel bdi{font-size:14px;color:#174554;overflow-wrap:anywhere} +.contact-channel>div{min-width:0}.channel-arrow{margin-inline-start:auto;color:#0095b2;flex-shrink:0} +@media(max-width:1000px){.contact-panel{padding:24px}.contact-line{flex-direction:column;gap:7px;align-items:flex-start}.contact-panel-heading h2{font-size:18px}.contact-secondary{grid-template-columns:1fr}.contact-channel{border-inline-end:0;border-bottom:1px solid #dbe7eb;padding:20px 24px}.contact-channel:last-child{border-bottom:0}} +@media(max-width:650px){.contact-section{padding-top:30px;padding-bottom:40px}.contact-section .section-heading>p{display:none}.contact-section .section-heading h2{font-size:22px}.contact-primary{grid-template-columns:1fr;gap:16px}.contact-panel{padding:22px}.contact-panel-heading{gap:12px;margin-bottom:18px}.contact-line{flex-direction:row;align-items:baseline;gap:12px;flex-wrap:wrap}.contact-line>span{font-size:13px}.contact-line bdi{font-size:16px}.contact-secondary{margin-top:16px}.contact-icon{width:44px;height:44px}} + +/* Explicit Persian heading face in the product disclosure. */ +@font-face{font-family:Iran;src:url('/fonts/iran-sans-bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap} +.mega-menu{font-family:Iran,Tahoma,sans-serif} +.mega-menu .mega-columns h2,.mega-menu .mega-heading strong{font-family:Iran,Tahoma,sans-serif;font-weight:700;font-style:normal;font-synthesis:none} +.mega-menu .mega-columns h2{font-size:14px;line-height:1.9} + +/* Sequenced hero: image reveal, then heading, description and actions. */ +.hero-slider{overflow:hidden}.hero-slider .hero-copy{padding-top:42px;padding-bottom:20px}.hero-images{position:absolute;inset:0;overflow:hidden;background:#17323c}.hero-images img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transform:scale(1.065);clip-path:inset(0 100% 0 0);transition:opacity 1s ease,clip-path 1.2s cubic-bezier(.22,1,.36,1),transform 6.5s ease-out}.hero-images img.active{opacity:1;clip-path:inset(0 0 0 0);transform:scale(1)} +.hero-slider h1 span{display:block}.hero-sequence>*,.hero-sequence h1 span{animation:hero-enter .85s cubic-bezier(.22,1,.36,1) both}.hero-sequence>.eyebrow{animation-delay:.12s}.hero-sequence>h1{animation:none}.hero-sequence h1 span:first-child{animation-delay:.23s}.hero-sequence h1 span:last-child{animation-delay:.34s}.hero-sequence>p{animation-delay:.46s}.hero-sequence>.actions{animation-delay:.58s}.hero-slider .photo-label{animation:hero-enter .9s .4s both}.hero-slider .hero-bottom{margin-top:28px;padding-top:20px} +.slider-controls{display:flex;align-items:center;gap:17px;margin-top:18px;direction:rtl}.slider-dots{display:flex;gap:4px}.slider-dots button{border:0;background:none;padding:12px 4px;cursor:pointer;min-height:40px}.slider-dots span{display:block;width:20px;height:3px;background:#b7d5dc;transition:width .3s,background .3s}.slider-dots .selected{width:40px;background:#009fbd}.slide-count{font:11px Montserrat,sans-serif;color:#5e7e88}.slider-arrows{display:flex;gap:6px;margin-inline-start:auto}.slider-arrows button{display:flex;align-items:center;justify-content:center;width:38px;height:38px;background:transparent;border:1px solid #c8e0e6;color:#14758b;cursor:pointer}.slider-arrows button:hover{background:#fff}.slider-arrows button:disabled{opacity:.4;cursor:default}.slider-controls button:focus-visible{outline:3px solid #009fbd;outline-offset:3px} +@keyframes hero-enter{from{opacity:0;transform:translateY(22px)}to{opacity:1;transform:translateY(0)}} +@media(max-width:650px){.hero-slider .hero-copy{padding:30px 6% 20px}.hero-slider h1{font-size:30px}.hero-slider .hero-sequence>p{font-size:14px;min-height:59px}.hero-slider .actions{min-height:60px}.hero-slider .photo-label{bottom:22px}.hero-slider .hero-photo{min-height:280px}} +@media(prefers-reduced-motion:reduce){.hero-images img,.slider-dots span{transition:none}.hero-sequence>*,.hero-sequence h1 span,.hero-slider .photo-label{animation:none}.hero-images img{transform:none}} + +/* Backend-connected content and account controls. */ +.account-login{display:flex;align-items:center;gap:8px;white-space:nowrap;font:13px Iran,Tahoma,sans-serif;color:#176579;background:transparent;border:0;cursor:pointer;padding:8px 0}.account-menu{position:relative}.account-dropdown{position:absolute;top:100%;inset-inline-end:0;min-width:190px;background:#fff;border:1px solid #d7e6e9;padding:8px;box-shadow:0 12px 25px #17323c15}.account-dropdown a,.account-dropdown button{display:block;width:100%;text-align:right;border:0;background:none;padding:10px;font:14px Iran,sans-serif;cursor:pointer}.account-dropdown a:hover,.account-dropdown button:hover{background:#eef8fa}.account-dropdown p{font-size:12px}.header{gap:22px}.header nav{gap:25px} +.backend-empty{text-align:center;padding:60px 20px;color:#007f98}.backend-empty>svg{margin:auto}.backend-empty h2{font-size:23px}.blog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:25px}.blog-card{border:1px solid #d7e6e9}.blog-card>img,.blog-placeholder{height:220px;width:100%;object-fit:cover}.blog-placeholder{display:flex;align-items:center;justify-content:center;background:#eff8fa;color:#0096b3}.blog-card-copy{padding:24px}.blog-card h2{font-size:20px}.blog-card p{font-size:14px}.blog-card-copy>span{display:flex;align-items:center;gap:12px;color:#0093af;font-size:13px}.blog-pagination{display:flex;justify-content:center;gap:30px;margin-top:35px}.blog-article{max-width:900px}.article-cover{width:100%;max-height:480px;object-fit:cover;margin-bottom:35px}.article-body{line-height:2.3;overflow-wrap:anywhere}.article-body img{max-width:100%;height:auto}.article-body table{display:block;overflow:auto;max-width:100%}.article-body a{color:#007f98;text-decoration:underline}.article-body pre{overflow:auto;direction:ltr}.article-body blockquote{border-inline-start:3px solid #009fbd;padding:15px 25px;background:#f0f8fa} +.contact-form-section{margin-top:48px;padding-top:35px;border-top:1px solid #d7e6e9;display:grid;grid-template-columns:1fr 1.6fr;gap:55px}.contact-form-section p{font-size:14px}.contact-form-section fieldset{margin:0;padding:0;border:0;min-width:0}.contact-form-section label{display:block;font-size:14px;margin-bottom:18px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.contact-form-section input,.contact-form-section textarea{width:100%;margin-top:8px;background:#f8fcfd;border:1px solid #cbdfe5;padding:12px;font:16px Iran,sans-serif;color:#17323c;border-radius:0}.contact-form-section textarea{resize:vertical}.contact-form-section input:focus,.contact-form-section textarea:focus{outline:2px solid #009fbd;outline-offset:1px}.contact-form-section .button{cursor:pointer;font-family:Iran,sans-serif}.contact-form-section fieldset:disabled{opacity:.65}.field-error{color:#a72c37;font-size:13px}.form-success{color:#17694c;background:#eef8f2;padding:12px}.success-toast{position:fixed;bottom:24px;left:24px;z-index:100;display:flex;align-items:center;gap:12px;background:#fff;border:1px solid #b8ded0;border-right:4px solid #21815e;box-shadow:0 10px 30px #17323c18;padding:18px;color:#17694c;font-size:14px;max-width:calc(100% - 32px)}.success-toast button{background:none;border:0;cursor:pointer;color:inherit;display:flex} +@media(max-width:1000px){.header{gap:12px}.header nav{gap:14px}.header>.button.small{display:none}.blog-grid{grid-template-columns:repeat(2,1fr)}.contact-form-section{gap:30px;grid-template-columns:1fr}} +@media(max-width:650px){.header nav{font-size:12px}.header>.account-menu,.header>.account-login{margin-inline-start:auto}.blog-grid,.form-grid{grid-template-columns:1fr}.form-grid{gap:0}.contact-form-section{margin-top:30px}.success-toast{bottom:16px;left:16px}.account-login{font-size:12px;max-width:210px;white-space:normal}} diff --git a/app/layout.tsx b/app/layout.tsx index e2ca71e..36cb549 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 { return { ...baseMetadata, icons: { icon: await getFavicon() } }; } export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return
مشاور و مجری استریل و کنترل عفونتدفتر تهران ۰۲۱ − ۶۶۱۵۷۶۰۹
SGE Medical Groupمشاوره و خرید ↖
{children}
+ return
مشاور و مجری استریل و کنترل عفونتدفتر تهران ۰۲۱ − ۶۶۱۵۷۶۰۹
SGE Medical Groupمشاوره و خرید ↖
{children}
} diff --git a/app/page.tsx b/app/page.tsx index c11ed38..fe1c4c4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 <>
محیط آزمایشگاهی و تجهیزات تخصصی
+
دانش، دقت، اعتمادSCIENCE IN SERVICE OF HEALTH
SGE MEDICAL GROUP

اعتماد، نخستین گام
در مسیر سلامت.

راهکارهای تخصصی استریلیزاسیون و کنترل عفونت؛
همراه مراکز درمانی، از انتخاب تا استفاده.

محصولات تخصصی ما آشنایی با سلامت گستر
شرکت مهندسی سلامت گستر اعتمادمشاور و مجری استریل و کنترل عفونت
{groups.map((g,i)=>
{g.title}{g.en}
)}
محصولات تخصصی ما

دقت در انتخاب؛ اطمینان در عملکرد

معرفی محصولات و دریافت کاتالوگ‌های تخصصی
استریل، بسته‌بندی و کنترل عفونت

{groups.map((g,i)=>
{g.title}{g.en}مشاهده محصولات +
)}
فعالیت متخصصان در محیط پزشکی
دانش، پایهٔ مراقبت بهتر

آموزش، کلید توسعه
و تخصص ما است.

دانش استریل و بازفرآوری ابزار پزشکی، دانشی چندبعدی و تخصصی است. کتاب راهنمای جامع استریلیزاسیون، ترجمه گایدلاین ST79، برای فعالان حوزه استریل و کارشناسان کنترل عفونت معرفی شده است.

برای تهیه کتاب با ما تماس بگیرید
استریل و کنترل عفونت

شناخت بهتر، انتخاب آگاهانه‌تر

۰۱ / CHEMICAL

اندیکاتور شیمیایی چیست؟

این نشانگرها معمولاً جامد و کاغذی‌شکل هستند، داخل پک‌های استریل قرار می‌گیرند و در طول فرایند استریلیزاسیون تغییر رنگ می‌دهند.

۰۲ / BIOLOGICAL

اندیکاتور بیولوژیکال چیست؟

این اندیکاتورها حاوی میکروارگانیسم‌های زنده هستند. نتیجه کشت آن‌ها در محیط مرتبط، برای ارزیابی عملکرد فرایند استریلیزاسیون استفاده می‌شود.

۰۳ / DOCUMENTATION

مستندسازی و ردیابی استریل

نرم‌افزار CSSD-DOC برای مستندسازی و ردیابی استریل؛ گذار از دنیای کاغذی به عصر الکترونیک.

دریافت اطلاعات بیشتر ←

برای انتخاب محصول مناسب، همراه شما هستیم.

مشاوره و خرید محصولات تخصصی کنترل عفونت و استریلیزاسیون

ارتباط با کارشناسان
} +export default function Home(){return <>
{groups.map((g,i)=>
{g.title}{g.en}
)}
محصولات تخصصی ما

دقت در انتخاب؛ اطمینان در عملکرد

معرفی محصولات و دریافت کاتالوگ‌های تخصصی
استریل، بسته‌بندی و کنترل عفونت

{groups.map((g,i)=>
{g.title}{g.en}مشاهده محصولات +
)}
فعالیت متخصصان در محیط پزشکی
دانش، پایهٔ مراقبت بهتر

آموزش، کلید توسعه
و تخصص ما است.

دانش استریل و بازفرآوری ابزار پزشکی، دانشی چندبعدی و تخصصی است. کتاب راهنمای جامع استریلیزاسیون، ترجمه گایدلاین ST79، برای فعالان حوزه استریل و کارشناسان کنترل عفونت معرفی شده است.

برای تهیه کتاب با ما تماس بگیرید
استریل و کنترل عفونت

شناخت بهتر، انتخاب آگاهانه‌تر

۰۱ / CHEMICAL

اندیکاتور شیمیایی چیست؟

این نشانگرها معمولاً جامد و کاغذی‌شکل هستند، داخل پک‌های استریل قرار می‌گیرند و در طول فرایند استریلیزاسیون تغییر رنگ می‌دهند.

۰۲ / BIOLOGICAL

اندیکاتور بیولوژیکال چیست؟

این اندیکاتورها حاوی میکروارگانیسم‌های زنده هستند. نتیجه کشت آن‌ها در محیط مرتبط، برای ارزیابی عملکرد فرایند استریلیزاسیون استفاده می‌شود.

۰۳ / DOCUMENTATION

مستندسازی و ردیابی استریل

نرم‌افزار CSSD-DOC برای مستندسازی و ردیابی استریل؛ گذار از دنیای کاغذی به عصر الکترونیک.

دریافت اطلاعات بیشتر ←

برای انتخاب محصول مناسب، همراه شما هستیم.

مشاوره و خرید محصولات تخصصی کنترل عفونت و استریلیزاسیون

ارتباط با کارشناسان
} diff --git a/app/tmas-bama/page.tsx b/app/tmas-bama/page.tsx index 120a5ea..eb55e48 100644 --- a/app/tmas-bama/page.tsx +++ b/app/tmas-bama/page.tsx @@ -1,2 +1,31 @@ -export const metadata={title:'تماس با ما'}; -export default function Contact(){return <>
CONTACT US

با ما در تماس باشید

برای مشاوره و خرید محصولات کنترل عفونت و استریلیزاسیون، همراه شما هستیم.

راه‌های ارتباطی

گفت‌وگو، آغاز یک انتخاب مطمئن

شرکت مهندسی سلامت گستر اعتماد
مشاور و مجری استریل و کنترل عفونت

۰۲ / MOBILE

ارتباط با کارشناسان

آقای مهندس اویسی0912 553 7630خانم مهندس یعقوبی راد0938 922 1460
۰۴ / FAX

شماره فکس

ارسال مکاتبات و اسناد

025 3655 0166
} +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 <> +
CONTACT US

با ما در تماس باشید

برای مشاوره و خرید محصولات کنترل عفونت و استریلیزاسیون، همراه شما هستیم.

+
+
راه‌های ارتباطی

گفت‌وگو، آغاز یک انتخاب مطمئن

شرکت مهندسی سلامت گستر اعتماد
مشاور و مجری استریل و کنترل عفونت

+
+ +
+
OUR EXPERTS

ارتباط مستقیم با کارشناسان

+
آقای مهندس اویسی0912 553 7630
+
خانم مهندس یعقوبی راد0938 922 1460
+
+
+
+

پست الکترونیکی

sale.steril@gmail.com
+

همراه ما در تلگرام

@CCSD1
+

فکس · مکاتبات و اسناد

025 3655 0166
+
+ +
+ ; +} diff --git a/components/backend/account-menu.tsx b/components/backend/account-menu.tsx new file mode 100644 index 0000000..f4a719c --- /dev/null +++ b/components/backend/account-menu.tsx @@ -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(null);const button=useRef(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 ورود; + return
{if(!e.currentTarget.contains(e.relatedTarget as Node))setOpen(false)}}>{open&&
پروفایل من{user.isAdmin&&مدیریت وبسایت}{error&&

{error}

}
}
+} diff --git a/components/backend/contact-form.tsx b/components/backend/contact-form.tsx new file mode 100644 index 0000000..a62492f --- /dev/null +++ b/components/backend/contact-form.tsx @@ -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>({});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){ + 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
SEND A MESSAGE

برای ما بنویسید

پرسش یا درخواست خود را با کارشناسان ما در میان بگذارید.

{[{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=>)}