Compare commits

...

38 Commits

Author SHA1 Message Date
soroush.asadi 1c9d8cdc1b feat(seo): FAQPage structured data on blog posts
CI/CD / CI · dotnet build (push) Successful in 16m5s
CI/CD / Deploy · drsousan (push) Successful in 29s
Extracts Q/A pairs from the post body (an <h3> ending in the Persian
question mark ؟ followed by the next <p>) and emits FAQPage JSON-LD in
<head>. Makes posts with FAQ sections eligible for FAQ rich results in
Google. Non-question <h3> headings are ignored.

Verified: post with 3 h3s emits exactly 2 Question entries (the plain
heading excluded), valid escaped JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 01:02:44 +03:30
soroush.asadi f3701c5893 perf(seo): response compression, security headers, immutable upload cache
CI/CD / CI · dotnet build (push) Successful in 3m13s
CI/CD / Deploy · drsousan (push) Successful in 34s
Now that the origin serves directly (no CDN compressing for us):
- Add gzip + brotli response compression — homepage HTML 84KB -> ~25KB
  (~71% smaller), big Core Web Vitals / crawl-budget win
- Baseline security headers on every response: X-Content-Type-Options,
  X-Frame-Options, Referrer-Policy (no HSTS yet — cert just stabilised)
- Long-cache immutable GUID uploads (Cache-Control max-age=30d,immutable)

Verified locally: gzip+br negotiated, headers present, uploads cached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:46:08 +03:30
soroush.asadi 00a138fe46 feat(seo): complete OG/Twitter/structured-data coverage + clean encoding
CI/CD / CI · dotnet build (push) Successful in 2m1s
CI/CD / Deploy · drsousan (push) Successful in 35s
Blog list (/blog): add robots, full Open Graph + Twitter, Blog +
BreadcrumbList JSON-LD, per-page self-canonical, and rel=prev/next for
paginated pages.

Blog post: add robots, og:site_name, article:published_time /
modified_time / author / section, twitter:image, og:image:alt, and a
BreadcrumbList JSON-LD (Home → Blog → Category → Post).

Gallery (/gallery): add robots, full OG + Twitter (with first image as
og:image), ImageGallery + BreadcrumbList JSON-LD.

Encoding: register HtmlEncoder.Create(UnicodeRanges.All) so Persian text
in meta tags and JSON-LD renders literally instead of &#xXXXX; entities
(smaller, cleaner output; friendlier to SEO validators).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:44:37 +03:30
soroush.asadi 5a1f1a8ccb fix(seo): proper 500 handler + safe JSON-LD escaping
CI/CD / CI · dotnet build (push) Successful in 45s
CI/CD / Deploy · drsousan (push) Successful in 25s
Add UseExceptionHandler(/error) so unhandled exceptions return a
proper HTML 500 instead of a raw response Googlebot was logging as
a server error in Search Console.

Replace manual quote-only escaping in blog JSON-LD with a J() helper
that uses JsonSerializer so newlines, backslashes, and all other
control characters are safely escaped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:01:45 +03:30
soroush.asadi 82d9720e25 feat(analytics): admin field for Google Analytics (GA4) ID
CI/CD / CI · dotnet build (push) Successful in 1m1s
CI/CD / Deploy · drsousan (push) Successful in 25s
Adds a "Measurement ID" input under admin → Site Identity. The value
is stored as identity/ga_id (existing bulk-settings endpoint, no API
change). When set, _Layout injects the GA4 gtag.js snippet into <head>
on every page (home, blog, gallery, posts). Empty value = disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:33:03 +03:30
soroush.asadi 99a54be3ac feat(blog): in-content image carousel/slider
CI/CD / CI · dotnet build (push) Successful in 6m28s
CI/CD / Deploy · drsousan (push) Successful in 29s
Editor: new 🎠 اسلایدر toolbar button — pick multiple images (min 2),
uploads them all, inserts a <div class="post-carousel" data-carousel>
block at the cursor. Editor preview shows a tidy filmstrip with the
non-functional arrows/dots hidden.

Public post page: carousel CSS (scroll-snap track) + JS that wires up
prev/next arrows, clickable dots, and native touch swipe. Single-image
blocks auto-collapse their controls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:55:33 +03:30
soroush.asadi 9c93b4e51a feat(gallery+editor): dedicated /gallery page, homepage teaser, in-content images
CI/CD / CI · dotnet build (push) Successful in 21s
CI/CD / Deploy · drsousan (push) Successful in 28s
Homepage gallery:
- Show only 3 before/after samples as a teaser (was: all items)
- Add "مشاهده گالری کامل (N نمونه)" CTA when more than 3 exist
- Remove the now-pointless category tabs from the teaser

New /gallery page:
- Full before/after grid with category filter tabs (deduped from data)
- Responsive cards with قبل/بعد labels + captions, empty state
- Added to sitemap.xml (priority 0.8)

Blog content editor:
- New 🖼 تصویر toolbar button inserts an uploaded image at the cursor
  (direct upload, no forced crop) — for richer post bodies
- Responsive img styling on the public post page

Note: the filler-lab-soorat cover not showing is a data issue — that
post has an empty featuredImage in the DB (verified); re-upload + save
fixes it. The upload/save path itself is correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:26:35 +03:30
soroush.asadi 872e5c1818 fix(blog): repair pagination on public and admin interfaces
CI/CD / CI · dotnet build (push) Successful in 35s
CI/CD / Deploy · drsousan (push) Successful in 28s
Public /blog: the handler param was named `page`, which is a reserved
route token in Razor Pages and never binds — so every page silently
showed the same first 10 posts. Renamed the query param to `pg`
([FromQuery(Name="pg")]) and updated the pagination links to match.

Admin: the posts table had no pagination and dumped all rows at once.
Added client-side pagination (10/page) with a prev/next + numbered bar
over the already-loaded posts array.

Verified: public page1=10/page2=4 with zero overlap; admin shows
‹ 1 2 › with correct row counts and active state per page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:23:22 +03:30
soroush.asadi 427de7c0cb copy: health CTA «درخواست مراقبت سلامت» → «رزرو نوبت»
CI/CD / CI · dotnet build (push) Successful in 35s
CI/CD / Deploy · drsousan (push) Successful in 28s
Clearer, action-oriented call to action on the general-health card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:02:37 +03:30
soroush.asadi 0c4315063e feat(seo/ux): enrich homepage meta, schema, and add conversion CTAs
CI/CD / CI · dotnet build (push) Successful in 31s
CI/CD / Deploy · drsousan (push) Successful in 24s
SEO:
- Clean keyword-rich meta description (trim stray whitespace from editable subtitle)
- Add robots, author, theme-color meta tags
- Add og:url, og:site_name, og:image:alt + full Twitter card tags
- Enrich MedicalBusiness JSON-LD: image, areaServed, priceRange,
  sameAs (social), aggregateRating (from testimonials), @id
- Add FAQPage JSON-LD for rich results (loops over active FAQs)
- Keyword-rich alt text on hero + about images

UX / conversion:
- Tap-to-call phone (tel:), mailto email, Google Maps link for address
- Floating WhatsApp + Call buttons (sticky, RTL bottom-left)
- Hero image: width/height + fetchpriority=high + decoding=async (LCP/CLS)
- Fix hero-name nowrap overflow risk on small screens

Both JSON-LD blocks validated as well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:53:57 +03:30
soroush.asadi 21769deda6 fix(admin): show error toast instead of success when savePost fails
CI/CD / CI · dotnet build (push) Successful in 1m34s
CI/CD / Deploy · drsousan (push) Successful in 31s
api() returns null on HTTP error; the save block now checks the return
value before closing the modal and showing the success toast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:11:20 +03:30
soroush.asadi e6fe943217 ci cd run
CI/CD / CI · dotnet build (push) Successful in 3m4s
CI/CD / Deploy · drsousan (push) Successful in 7s
2026-06-09 12:09:41 +03:30
soroush.asadi 5ae6bb03a2 fix: resolve two build errors — siteBaseUrl self-reference + ForwardedHeaders
CI/CD / CI · dotnet build (push) Successful in 11m58s
CI/CD / Deploy · drsousan (push) Successful in 13s
- Index.cshtml: siteBaseUrl was referencing itself as fallback; replaced
  with literal default "https://draletaha.ir"
- Program.cs: removed UseForwardedHeaders call — ForwardedHeadersOptions
  unavailable in this SDK config; SITE_BASE_URL env var handles base URL

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-08 23:33:38 +03:30
soroush.asadi d02a5963cf fix: HTTPS URLs in sitemap, robots, canonical + og:image on homepage
CI/CD / CI · dotnet build (push) Failing after 5m22s
CI/CD / Deploy · drsousan (push) Has been skipped
- Add UseForwardedHeaders middleware so Request.Scheme = "https" behind nginx
- Add SITE_BASE_URL env var fallback for sitemap.xml, robots.txt, and all
  Razor page canonical/og URLs — set it to https://draletaha.ir in .env
- Add og:image to homepage using hero photo
- Add SITE_BASE_URL to docker-compose.yml environment block

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-08 22:30:55 +03:30
soroush.asadi 22d0ecb330 feat: doctor reply + diagnosis + tracking code per health request
CI/CD / CI · dotnet build (push) Successful in 45s
CI/CD / Deploy · drsousan (push) Successful in 28s
Backend:
- HealthRequest model: TrackingCode (DR-XXXXXX), Diagnosis,
  DoctorReply, RepliedAt fields
- Runtime migration: ALTER TABLE adds 4 new columns to existing DB
- POST /api/health-request: auto-generates tracking code, returns it
- PUT /api/health-requests/{id}/reply: doctor sets diagnosis + reply
- GET /api/health-request/track/{code}: public lookup by tracking code
- GET /api/health-requests?phone=: filter history by phone number

Admin panel:
- Request table shows tracking code column (gold badge)
- Detail modal (680px): tracking code header, patient info, full message
- Previous doctor reply shown in green box if exists
- Reply form: diagnosis input + textarea for doctor message
- History panel: all requests from same phone, click to switch
- 'پاسخ / مشاهده' button opens reply modal directly

Frontend:
- After form submit: shows tracking code in green box to user
  (format: DR-XXXXXX, stays visible 8 seconds)
- Box auto-hides and form resets after timeout

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 22:03:00 +03:30
soroush.asadi 1e51df406b fix: cropper mime bug + loadSiteIdentity crash + logo|name header
CI/CD / CI · dotnet build (push) Successful in 37s
CI/CD / Deploy · drsousan (push) Successful in 29s
1. applyCrop() — mime variable was declared INSIDE toBlob callback
   but used as an argument to toBlob() (outer scope) → ReferenceError.
   Fix: declare _mime, _quality, _ext BEFORE out.toBlob() call.

2. loadSiteIdentity() — crashed when identity section had no rows
   (data was null/non-array). Fix: safe Array.isArray guard + catch.

3. Header logo: show logo image + | + site name side by side
   when logo is configured (was showing one OR the other).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 18:15:51 +03:30
soroush.asadi 5d6a4a630d fix: preserve original file type on upload — never convert PNG to JPG
CI/CD / CI · dotnet build (push) Successful in 53s
CI/CD / Deploy · drsousan (push) Successful in 28s
Problem: cropper always called out.toBlob(..., 'image/jpeg') regardless
of the original file type, silently converting PNGs to JPGs.

Fix:
- openCropper() now stores file.type and file.name on the cropper object
- applyCrop() uses the stored mime type for toBlob() and the filename
- Quality param only passed for lossy formats (jpeg/webp), not for PNG/GIF
- uploadImage() accept list expanded: svg, ico allowed
- Server-side: .svg and .ico added to allowed extensions

Result: PNG stays PNG, WebP stays WebP, ICO stays ICO.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 18:06:38 +03:30
soroush.asadi e79ccf7e8c feat: logo and favicon management in admin panel
CI/CD / CI · dotnet build (push) Successful in 41s
CI/CD / Deploy · drsousan (push) Successful in 29s
Admin panel:
- New 'هویت سایت' page under تنظیمات in sidebar
- Upload logo (PNG transparent, 200×60px recommended)
- Upload favicon (PNG/ICO, 32×32 or 64×64px)
- Live preview panel shows how logo looks in header
  and how favicon looks in a browser tab mockup
- Saved to SiteSettings with section='identity', key='logo'/'favicon'

Frontend (_Layout.cshtml):
- Injects AppDbContext to load identity settings per request
- If logo is set: shows <img> in header instead of text
- If favicon is set: uses uploaded file as <link rel="icon">
- Falls back to text / favicon.ico when not configured

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 17:47:49 +03:30
soroush.asadi 81838f75ce fix: unify two reservation forms into one — wire booking to API
CI/CD / CI · dotnet build (push) Successful in 41s
CI/CD / Deploy · drsousan (push) Successful in 29s
Problem: two parallel booking systems:
1. 'رزرو نوبت آنلاین' contact form — never saved data (fake submit)
2. Health section inline form — saved to /api/health-request

Fix:
- Contact form now POSTs to /api/health-request (real save)
- Added ids to all form inputs so JS can read them
- Category auto-detected from service dropdown (سلامت عمومی → health)
- Health section 'درخواست...' buttons now scroll to contact form
  and pre-select the right category — no duplicate inline form
- Removed the duplicate healthFormWrap + submitHealthForm()
- All reservations visible in admin under 'درخواست‌ها'

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 16:51:58 +03:30
soroush.asadi 772df0698c feat: health request detail modal + view button always visible
CI/CD / CI · dotnet build (push) Successful in 3m21s
CI/CD / Deploy · drsousan (push) Successful in 29s
- Every row now has a 'مشاهده' button regardless of handled status
- Opens a modal with: name, phone, email, category, date, status,
  and full message text (no truncation)
- Modal includes 'علامت‌گذاری' button if request is still pending
- Message column in table kept short (truncated) as a preview only

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 16:15:01 +03:30
soroush.asadi e73d47a875 fix: remove apt-get curl install — use bash TCP health check instead
CI/CD / CI · dotnet build (push) Successful in 40s
CI/CD / Deploy · drsousan (push) Successful in 15s
archive.ubuntu.com is unreachable from the build server, causing
apt-get to time out and fail every build. curl was only used for
the HEALTHCHECK. Replace with a zero-dependency bash TCP check:
  bash -c 'echo > /dev/tcp/localhost/8080'
No packages needed, no external network access required.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 16:03:08 +03:30
soroush.asadi b3c4615bc7 fix: replace emoji with hero image in blog sidebar doctor card
CI/CD / CI · dotnet build (push) Successful in 51s
CI/CD / Deploy · drsousan (push) Failing after 5m40s
- Post.cshtml.cs: load hero image, tag from SiteSettings in SetViewDataAsync
- Post.cshtml: show <img> with hero image in .doc-avatar when set,
  fall back to emoji only if no image is configured
- .doc-avatar: circular crop with object-fit:cover, gold border
- doc-title now uses HeroTag from settings (not hardcoded)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 15:15:06 +03:30
soroush.asadi ed25bec200 fix: 3 bugs — beauty icon color, appointments on dashboard, blog image edit
CI/CD / CI · dotnet build (push) Successful in 1m28s
CI/CD / Deploy · drsousan (push) Has been cancelled
1. Beauty category icon: was pink (#C2185B), now uses site primary gold
   (var(--gold) / var(--gold-pale)) to match brand color

2. Dashboard now shows health requests:
   - Two new stat cards: total patients + pending requests (clickable)
   - 'آخرین درخواست‌ها' mini-table showing last 6 requests
   - Sidebar badge updates from dashboard load too
   - loadDashboard() now fetches /api/patients + /api/health-requests

3. Blog image edit fix:
   - applyCrop() now captures inputId/previewId BEFORE closeCropper()
     to prevent any potential race condition when replacing images

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 15:09:46 +03:30
soroush.asadi 3780dcccf2 feat: patient management system + health landing page
CI/CD / CI · dotnet build (push) Successful in 59s
CI/CD / Deploy · drsousan (push) Successful in 1m33s
Backend:
- Patient model: name, phone, email, age, weight, height, gender,
  blood type, disease history, allergies, medications, notes, category
- PatientVisit model: title, content, prescription, visit type,
  visit/next-visit dates, linked to patient (cascade delete)
- HealthRequest model: public form submissions for beauty/health care
- Runtime SQLite migrations for all 3 new tables
- Full CRUD API: /api/patients, /api/patients/{id}/visits,
  /api/health-requests (public POST + admin GET/PUT/DELETE)

Admin panel:
- 'پرونده بیماران' page: list, search, filter by category (beauty/health)
- Patient profile page: personal info + medical history + visits timeline
- Add/edit patient modal with all medical fields
- Add visit modal: type, date, clinical notes, prescription, next visit
- 'درخواست‌ها' page: manage public health requests, mark as handled
- Badge counter for unhandled requests in sidebar

Frontend (SEO):
- New #health-care section with Schema.org MedicalClinic markup
- Two category cards: زیبایی پوست and سلامت عمومی
- Feature lists with checkmarks per category
- Inline request form that submits to /api/health-request
- Mobile responsive (single column on small screens)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 12:27:16 +03:30
soroush.asadi 0765d5d3cd fix: move gallery captions and before/after labels below images
CI/CD / CI · dotnet build (push) Successful in 38s
CI/CD / Deploy · drsousan (push) Successful in 57s
Instead of overlaying text on top of the image (hard to read),
restructure each gallery card to flex-column:
- Image section (.gallery-img-wrap) on top with aspect-ratio:4/3
- Before/After labels row (.ba-labels) below the image, full text visible
- Caption (.gallery-caption) below that, with padding and border

Labels now show full text 'قبل از درمان' / 'بعد از درمان' in a clean
row under the split image — never overlapping, always readable.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 11:47:14 +03:30
soroush.asadi 60141b78f0 fix: cropper modal visible on load — add .cropper-overlay.hidden CSS rule
CI/CD / CI · dotnet build (push) Successful in 38s
CI/CD / Deploy · drsousan (push) Successful in 2m52s
The .hidden class only covered .modal-overlay and .fm-overlay.
Without the rule, display:flex on .cropper-overlay overrode .hidden
and the modal showed immediately on every page load.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 11:22:46 +03:30
soroush.asadi b3467fb663 feat: image cropper in admin + fix ba-label responsive centering
CI/CD / CI · dotnet build (push) Successful in 23s
CI/CD / Deploy · drsousan (push) Successful in 41s
- Admin: all upload buttons now open a crop-before-upload modal
  - Canvas-based cropper (no external library)
  - Ratio presets: 1:1, 4:3, 16:9, 3:4, free
  - Drag to move crop box, drag corners to resize
  - Touch support for mobile
  - Crops client-side then uploads the result
- Frontend gallery: ba-label (قبل/بعد) now:
  - Centered horizontally (block + width 100%)
  - Wraps to multiple lines (white-space:normal)
  - Responsive — never overflows or gets clipped

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 11:01:12 +03:30
soroush.asadi 6f39e47aaa ci: backup DB before every deploy, fix deploy conflict error
CI/CD / CI · dotnet build (push) Successful in 25s
CI/CD / Deploy · drsousan (push) Successful in 12s
- Add "Backup database" step that copies drsousan.db out of the
  running container to /opt/drsousan-backups/ before any container
  changes, keeping the last 10 backups
- Replace --force-recreate (broken on this Docker version) with
  explicit docker stop + docker rm before docker compose up

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 08:22:34 +03:30
soroush.asadi e4ad440c15 fix(ci): stop & remove old container before deploying new one
CI/CD / CI · dotnet build (push) Successful in 24s
CI/CD / Deploy · drsousan (push) Successful in 12s
docker compose up --force-recreate only works when Compose owns the
container. If the container was started outside Compose (e.g. manually
via docker restart), Compose can't recreate it and errors with
"container name already in use". Explicitly stopping and removing it
first handles both cases cleanly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 01:59:16 +03:30
soroush.asadi dd5afde5df fix: add --force-recreate to docker compose deploy step
CI/CD / CI · dotnet build (push) Successful in 23s
CI/CD / Deploy · drsousan (push) Failing after 2s
Without this flag, the deploy fails with "container name already in use"
when a container with the same name exists from a previous run.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-02 00:04:14 +03:30
soroush.asadi 7f5444085b fix: render before/after gallery images from API with tab filtering
CI/CD / CI · dotnet build (push) Successful in 40s
CI/CD / Deploy · drsousan (push) Failing after 52s
- Gallery section now fetches /api/gallery and renders real items
  instead of hardcoded placeholders
- Before+after pairs render as side-by-side split with قبل/بعد labels
- Single imageUrl items render as a standard gallery card
- Tab buttons now filter items by category via data-cat attribute
- CSS added for .before-after, .ba-half, .ba-divider, .ba-label, .gallery-caption
- Fixes applied to correct file (Index.cshtml Razor page, not root index.html)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-01 23:59:57 +03:30
soroush.asadi f034f70ae3 fix: lock compose project name to 'drsousan', fix mirrors typo in image ref
CI/CD / CI · dotnet build (push) Successful in 3m21s
CI/CD / Deploy · drsousan (push) Failing after 2s
Prevents runner workspace directory name from being used as project name,
which caused Meezi containers to be treated as orphans and stopped on deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:41:20 +03:30
soroush.asadi 8fa3131344 ci: scope image prune to drsousan only, never touch other projects
CI/CD / CI · dotnet build (push) Successful in 23s
CI/CD / Deploy · drsousan (push) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:37:06 +03:30
soroush.asadi 14f902cdad fix: replace dotnet healthcheck with curl probe for reliable self-healing
CI/CD / CI · dotnet build (push) Successful in 35s
CI/CD / Deploy · drsousan (push) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 19:39:04 +03:30
soroush.asadi d5bb724b3f fix:multiline header
CI/CD / CI · dotnet build (push) Successful in 33s
CI/CD / Deploy · drsousan (push) Successful in 12s
2026-05-31 11:01:36 +03:30
soroush.asadi 56f1311b3b fix: use mirror.soroushasadi.com for base images in Dockerfile
CI/CD / CI · dotnet build (push) Successful in 23s
CI/CD / Deploy · drsousan (push) Successful in 42s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:59:23 +03:30
soroush.asadi deb37f6935 PLZ 4
CI/CD / CI · dotnet build (push) Successful in 23s
CI/CD / Deploy · drsousan (push) Failing after 1s
2026-05-31 10:56:26 +03:30
soroush.asadi 15dc1189b4 ci: remove Nexus push step, build image locally only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 10:55:28 +03:30
17 changed files with 2199 additions and 206 deletions
+28 -19
View File
@@ -23,8 +23,6 @@ concurrency:
# #
# Required Gitea secrets: # Required Gitea secrets:
# ENV_FILE → contents of .env # ENV_FILE → contents of .env
# REGISTRY_PASSWORD → Nexus admin password
# REGISTRY_USER → Nexus username (usually: admin)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
jobs: jobs:
@@ -58,7 +56,7 @@ jobs:
working-directory: DrSousan.Api working-directory: DrSousan.Api
run: dotnet build DrSousan.Api.csproj --no-restore -c Release run: dotnet build DrSousan.Api.csproj --no-restore -c Release
# ── CD: build image → push to Nexus → deploy (push to main only) ──────────── # ── CD: build image → deploy locally (push to main only) ───────────────────
deploy: deploy:
name: "Deploy · drsousan" name: "Deploy · drsousan"
runs-on: self-hosted runs-on: self-hosted
@@ -87,27 +85,32 @@ jobs:
env: env:
ENV_FILE: ${{ secrets.ENV_FILE }} ENV_FILE: ${{ secrets.ENV_FILE }}
- name: Login to Nexus registry
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build image - name: Build image
run: | run: docker compose build api
docker compose build api
docker tag mirror.soroushasadi.com/drsousan/api:latest \
mirror.soroushasadi.com/drsousan/api:${{ github.sha }}
env: env:
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
- name: Push image to Nexus - name: Backup database
run: | run: |
docker push mirror.soroushasadi.com/drsousan/api:latest BACKUP_DIR="/opt/drsousan-backups"
docker push mirror.soroushasadi.com/drsousan/api:${{ github.sha }} mkdir -p "$BACKUP_DIR"
STAMP=$(date +%Y%m%d-%H%M%S)
# Copy DB out of volume before any container changes
if docker ps -q --filter name=drsousan_api | grep -q .; then
docker cp drsousan_api:/data/drsousan.db "$BACKUP_DIR/drsousan-$STAMP.db" && \
echo "✅ DB backed up → $BACKUP_DIR/drsousan-$STAMP.db" || \
echo "⚠️ DB backup failed (non-fatal)"
else
echo "️ Container not running — skipping backup"
fi
# Keep last 10 backups only
ls -t "$BACKUP_DIR"/*.db 2>/dev/null | tail -n +11 | xargs -r rm
- name: Deploy - name: Deploy
run: docker compose up -d --no-deps api run: |
docker stop drsousan_api 2>/dev/null || true
docker rm drsousan_api 2>/dev/null || true
docker compose up -d --no-deps api
- name: Wait for healthy - name: Wait for healthy
run: | run: |
@@ -125,6 +128,12 @@ jobs:
if: always() if: always()
run: docker compose ps run: docker compose ps
- name: Prune old images - name: Prune old drsousan images
if: success() if: success()
run: docker image prune -f # Only remove untagged (dangling) drsousan images — never touches other projects
run: |
docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
| grep '^mirror\.soroushasadi\.com/drsousan/' \
| grep '<none>' \
| awk '{print $2}' \
| xargs -r docker rmi || true
+3
View File
@@ -13,6 +13,9 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<BlogPost> BlogPosts => Set<BlogPost>(); public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
public DbSet<Comment> Comments => Set<Comment>(); public DbSet<Comment> Comments => Set<Comment>();
public DbSet<Faq> Faqs => Set<Faq>(); public DbSet<Faq> Faqs => Set<Faq>();
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<PatientVisit> PatientVisits => Set<PatientVisit>();
public DbSet<HealthRequest> HealthRequests => Set<HealthRequest>();
protected override void OnModelCreating(ModelBuilder mb) protected override void OnModelCreating(ModelBuilder mb)
{ {
+3 -4
View File
@@ -1,5 +1,5 @@
# ── Stage 1: Build ──────────────────────────────────────────────────────────── # ── Stage 1: Build ────────────────────────────────────────────────────────────
FROM 171.22.25.73:8087/dotnet/sdk:10.0 AS build FROM mirror.soroushasadi.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Restore dependencies first (layer-cache friendly) # Restore dependencies first (layer-cache friendly)
@@ -19,7 +19,7 @@ RUN dotnet publish DrSousan.Api.csproj \
--no-restore --no-restore
# ── Stage 2: Runtime ────────────────────────────────────────────────────────── # ── Stage 2: Runtime ──────────────────────────────────────────────────────────
FROM 171.22.25.73:8087/dotnet/aspnet:10.0 AS runtime FROM mirror.soroushasadi.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# Create directories for persistent volumes and set ownership # Create directories for persistent volumes and set ownership
@@ -37,8 +37,7 @@ VOLUME ["/data", "/app/wwwroot/uploads"]
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
# Self-probe via the app's own runtime — the aspnet image has no curl/wget.
HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \
CMD ["dotnet", "DrSousan.Api.dll", "--healthcheck"] CMD bash -c 'echo > /dev/tcp/localhost/8080' || exit 1
ENTRYPOINT ["dotnet", "DrSousan.Api.dll"] ENTRYPOINT ["dotnet", "DrSousan.Api.dll"]
+56
View File
@@ -130,8 +130,64 @@ public class Faq
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
// ─── Patient ──────────────────────────────────────────────────────────────────
public class Patient
{
public int Id { get; set; }
[MaxLength(150)] public string FullName { get; set; } = "";
[MaxLength(20)] public string PhoneNumber { get; set; } = "";
[MaxLength(200)] public string Email { get; set; } = "";
public int Age { get; set; }
public decimal Weight { get; set; } // kg
public decimal Height { get; set; } // cm
[MaxLength(10)] public string Gender { get; set; } = ""; // مرد / زن
[MaxLength(10)] public string BloodType { get; set; } = "";
public string DiseaseHistory { get; set; } = "";
public string Allergies { get; set; } = "";
public string Medications { get; set; } = "";
public string Notes { get; set; } = "";
[MaxLength(20)] public string Category { get; set; } = "beauty"; // beauty | health
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<PatientVisit> Visits { get; set; } = new List<PatientVisit>();
}
// ─── Patient Visit / Doctor Note ──────────────────────────────────────────────
public class PatientVisit
{
public int Id { get; set; }
public int PatientId { get; set; }
public Patient? Patient { get; set; }
[MaxLength(300)] public string Title { get; set; } = "";
public string Content { get; set; } = "";
public string Prescription { get; set; } = ""; // دارو / تجویز
[MaxLength(50)] public string VisitType { get; set; } = "ویزیت"; // ویزیت | آزمایش | پروسیجر
public DateTime VisitDate { get; set; } = DateTime.UtcNow;
public DateTime? NextVisitDate { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── Health / Appointment Request (public form) ───────────────────────────────
public class HealthRequest
{
public int Id { get; set; }
[MaxLength(20)] public string TrackingCode { get; set; } = ""; // e.g. DR-A3F7K2
[MaxLength(150)] public string FullName { get; set; } = "";
[MaxLength(20)] public string PhoneNumber { get; set; } = "";
[MaxLength(200)] public string Email { get; set; } = "";
public string Message { get; set; } = "";
[MaxLength(20)] public string Category { get; set; } = "beauty"; // beauty | health
public bool IsHandled { get; set; } = false;
// Doctor response
public string Diagnosis { get; set; } = ""; // پزشک: تشخیص
public string DoctorReply { get; set; } = ""; // پزشک: پاسخ/توضیح
public DateTime? RepliedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── DTOs ───────────────────────────────────────────────────────────────────── // ─── DTOs ─────────────────────────────────────────────────────────────────────
public record LoginRequest(string Username, string Password); public record LoginRequest(string Username, string Password);
public record DoctorReplyDto(string? Diagnosis, string? DoctorReply);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword); public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record SettingDto(string Key, string Value); public record SettingDto(string Key, string Value);
public record BulkSettingsDto(Dictionary<string, string> Settings); public record BulkSettingsDto(Dictionary<string, string> Settings);
+53 -5
View File
@@ -1,10 +1,58 @@
@page "/blog" @page "/blog"
@model DrSousan.Api.Pages.Blog.BlogIndexModel @model DrSousan.Api.Pages.Blog.BlogIndexModel
@{
var blogBase = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host);
var catQs = string.IsNullOrEmpty(Model.ActiveCat) ? "" : "&category=" + Model.ActiveCat;
string PageUrl(int p) => p <= 1
? blogBase + "/blog" + (string.IsNullOrEmpty(Model.ActiveCat) ? "" : "?category=" + Model.ActiveCat)
: blogBase + "/blog?pg=" + p + catQs;
var blogDesc = "مقالات تخصصی دکتر سوسن آل‌طه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست.";
}
@section Head { @section Head {
<title>@ViewData["Title"]</title> <title>@ViewData["Title"]</title>
<meta name="description" content="مقالات تخصصی دکتر سوسن آل‌طه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست." /> <meta name="description" content="@blogDesc" />
<link rel="canonical" href="@(Request.Scheme + "://" + Request.Host + "/blog")" /> <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@ViewData["SiteName"]" />
<meta name="theme-color" content="#B8955A" />
<link rel="canonical" href="@PageUrl(Model.CurrentPage)" />
@if (Model.CurrentPage > 1) { <link rel="prev" href="@PageUrl(Model.CurrentPage - 1)" /> }
@if (Model.CurrentPage < Model.TotalPages) { <link rel="next" href="@PageUrl(Model.CurrentPage + 1)" /> }
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="@ViewData["SiteName"]" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@blogDesc" />
<meta property="og:url" content="@PageUrl(Model.CurrentPage)" />
<meta property="og:locale" content="fa_IR" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@blogDesc" />
<!-- Structured data: Blog + breadcrumb -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Blog",
"name": "@ViewData["Title"]",
"description": "@blogDesc",
"url": "@(blogBase)/blog",
"inLanguage": "fa-IR",
"publisher": { "@@type": "Organization", "name": "@ViewData["SiteName"]", "url": "@blogBase" }
}
</script>
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{ "@@type": "ListItem", "position": 1, "name": "خانه", "item": "@blogBase/" },
{ "@@type": "ListItem", "position": 2, "name": "وبلاگ", "item": "@blogBase/blog" }
]
}
</script>
<style> <style>
/* ─── Blog Hero ─────────────────────────────────────────────── */ /* ─── Blog Hero ─────────────────────────────────────────────── */
.blog-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:6rem 2rem 3rem;text-align:center} .blog-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:6rem 2rem 3rem;text-align:center}
@@ -101,16 +149,16 @@
<div class="pagination"> <div class="pagination">
@if (Model.CurrentPage > 1) @if (Model.CurrentPage > 1)
{ {
<a class="page-btn" href="/blog?page=@(Model.CurrentPage - 1)@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")"></a> <a class="page-btn" href="/blog?pg=@(Model.CurrentPage - 1)@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")"></a>
} }
@for (int p = 1; p <= Model.TotalPages; p++) @for (int p = 1; p <= Model.TotalPages; p++)
{ {
<a class="page-btn @(p == Model.CurrentPage ? "active" : "")" <a class="page-btn @(p == Model.CurrentPage ? "active" : "")"
href="/blog?page=@p@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")">@p</a> href="/blog?pg=@p@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")">@p</a>
} }
@if (Model.CurrentPage < Model.TotalPages) @if (Model.CurrentPage < Model.TotalPages)
{ {
<a class="page-btn" href="/blog?page=@(Model.CurrentPage + 1)@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")"></a> <a class="page-btn" href="/blog?pg=@(Model.CurrentPage + 1)@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")"></a>
} }
</div> </div>
} }
+4 -2
View File
@@ -20,9 +20,11 @@ public class BlogIndexModel : PageModel
public int TotalPosts { get; private set; } = 0; public int TotalPosts { get; private set; } = 0;
public string? ActiveCat { get; private set; } public string? ActiveCat { get; private set; }
public async Task<IActionResult> OnGetAsync(int page = 1, string? category = null) // NOTE: the query param is "pg", not "page" — "page" is a reserved route token in
// Razor Pages and never binds here, which silently pins every request to page 1.
public async Task<IActionResult> OnGetAsync([FromQuery(Name = "pg")] int pg = 1, string? category = null)
{ {
CurrentPage = page < 1 ? 1 : page; CurrentPage = pg < 1 ? 1 : pg;
ActiveCat = category; ActiveCat = category;
var q = _db.BlogPosts.Include(p => p.Category).Where(p => p.IsPublished); var q = _db.BlogPosts.Include(p => p.Category).Where(p => p.IsPublished);
+109 -9
View File
@@ -2,12 +2,14 @@
@model DrSousan.Api.Pages.Blog.PostModel @model DrSousan.Api.Pages.Blog.PostModel
@{ @{
var post = Model.Post!; var post = Model.Post!;
var baseUrl = Request.Scheme + "://" + Request.Host; var baseUrl = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host);
var canonicalUrl = baseUrl + "/blog/" + post.Slug; var canonicalUrl = baseUrl + "/blog/" + post.Slug;
var ogImage = ViewData["OgImage"]?.ToString() ?? ""; var ogImage = ViewData["OgImage"]?.ToString() ?? "";
var articleType = ViewData["ArticleType"]?.ToString() ?? "MedicalWebPage"; var articleType = ViewData["ArticleType"]?.ToString() ?? "MedicalWebPage";
var pubDate = post.PublishedAt?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? ""; var pubDate = post.PublishedAt?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "";
var updDate = post.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"); var updDate = post.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ");
// Escape a string for safe embedding inside a JSON string literal.
static string J(string? s) => System.Text.Json.JsonSerializer.Serialize(s ?? "")[1..^1];
} }
@section Head { @section Head {
@@ -16,29 +18,40 @@
@if (!string.IsNullOrEmpty(ViewData["Keywords"]?.ToString())) { @if (!string.IsNullOrEmpty(ViewData["Keywords"]?.ToString())) {
<meta name="keywords" content="@ViewData["Keywords"]" /> <meta name="keywords" content="@ViewData["Keywords"]" />
} }
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@post.Author" />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:site_name" content="@ViewData["SiteName"]" />
<meta property="og:title" content="@ViewData["Title"]" /> <meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["MetaDesc"]" /> <meta property="og:description" content="@ViewData["MetaDesc"]" />
<meta property="og:url" content="@canonicalUrl" /> <meta property="og:url" content="@canonicalUrl" />
<meta property="og:locale" content="fa_IR" /> <meta property="og:locale" content="fa_IR" />
@if (!string.IsNullOrEmpty(pubDate)) { <meta property="article:published_time" content="@pubDate" /> }
<meta property="article:modified_time" content="@updDate" />
<meta property="article:author" content="@post.Author" />
@if (post.Category != null) { <meta property="article:section" content="@post.Category.Name" /> }
@if (!string.IsNullOrEmpty(ogImage)) { @if (!string.IsNullOrEmpty(ogImage)) {
<meta property="og:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" /> <meta property="og:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" />
<meta property="og:image:alt" content="@post.Title" />
} }
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@ViewData["Title"]" /> <meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@ViewData["MetaDesc"]" /> <meta name="twitter:description" content="@ViewData["MetaDesc"]" />
@if (!string.IsNullOrEmpty(ogImage)) {
<meta name="twitter:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" />
}
<link rel="canonical" href="@canonicalUrl" /> <link rel="canonical" href="@canonicalUrl" />
<script type="application/ld+json"> <script type="application/ld+json">
{ {
"@@context": "https://schema.org", "@@context": "https://schema.org",
"@@type": "@articleType", "@@type": "@J(articleType)",
"headline": "@post.Title.Replace("\"","\\\"") ", "headline": "@J(post.Title)",
"description": "@(ViewData["MetaDesc"]?.ToString()?.Replace("\"","\\\""))", "description": "@J(ViewData["MetaDesc"]?.ToString())",
"author": { "@@type": "Person", "name": "@post.Author" }, "author": { "@@type": "Person", "name": "@J(post.Author)" },
"publisher": { "publisher": {
"@@type": "Organization", "@@type": "Organization",
"name": "@ViewData["SiteName"]", "name": "@J(ViewData["SiteName"]?.ToString())",
"url": "@baseUrl" "url": "@baseUrl"
}, },
"datePublished": "@pubDate", "datePublished": "@pubDate",
@@ -50,6 +63,41 @@
} }
</script> </script>
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{ "@@type": "ListItem", "position": 1, "name": "خانه", "item": "@baseUrl/" },
{ "@@type": "ListItem", "position": 2, "name": "وبلاگ", "item": "@baseUrl/blog" }@(post.Category != null ? "," : "")
@if (post.Category != null) {
@:{ "@@type": "ListItem", "position": 3, "name": "@J(post.Category.Name)", "item": "@baseUrl/blog?category=@post.Category.Slug" },
@:{ "@@type": "ListItem", "position": 4, "name": "@J(post.Title)", "item": "@canonicalUrl" }
}
else {
@:{ "@@type": "ListItem", "position": 3, "name": "@J(post.Title)", "item": "@canonicalUrl" }
}
]
}
</script>
@if (Model.Faqs.Any())
{
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "FAQPage",
"mainEntity": [
@for (int i = 0; i < Model.Faqs.Count; i++)
{
var f = Model.Faqs[i];
@:{ "@@type": "Question", "name": "@J(f.Q)", "acceptedAnswer": { "@@type": "Answer", "text": "@J(f.A)" } }@(i < Model.Faqs.Count - 1 ? "," : "")
}
]
}
</script>
}
<style> <style>
/* ─── Post Layout ──────────────────────────────────────────────── */ /* ─── Post Layout ──────────────────────────────────────────────── */
.post-layout{max-width:1100px;margin:0 auto;padding:5rem 2rem 3rem;display:grid;grid-template-columns:1fr 320px;gap:3rem;align-items:start} .post-layout{max-width:1100px;margin:0 auto;padding:5rem 2rem 3rem;display:grid;grid-template-columns:1fr 320px;gap:3rem;align-items:start}
@@ -71,6 +119,19 @@
.article-content strong{color:var(--dark);font-weight:600} .article-content strong{color:var(--dark);font-weight:600}
.article-content a{color:var(--gold);border-bottom:1px solid var(--gold-pale)} .article-content a{color:var(--gold);border-bottom:1px solid var(--gold-pale)}
.article-content blockquote{border-right:4px solid var(--gold);padding:.8rem 1.2rem;background:var(--gold-pale);border-radius:0 8px 8px 0;margin:1.2rem 0;font-style:italic;color:var(--mid)} .article-content blockquote{border-right:4px solid var(--gold);padding:.8rem 1.2rem;background:var(--gold-pale);border-radius:0 8px 8px 0;margin:1.2rem 0;font-style:italic;color:var(--mid)}
.article-content img{max-width:100%;height:auto;border-radius:12px;margin:1.2rem 0;display:block}
/* ─── In-content image carousel ───────────────────────────────── */
.post-carousel{position:relative;margin:1.5rem 0;border-radius:14px;overflow:hidden;background:#111}
.post-carousel .pc-track{display:flex;direction:ltr;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;scrollbar-width:none}
.post-carousel .pc-track::-webkit-scrollbar{display:none}
.post-carousel .pc-track img{flex:0 0 100%;width:100%;max-height:480px;object-fit:cover;scroll-snap-align:center;display:block;margin:0 !important;border-radius:0 !important}
.post-carousel .pc-prev,.post-carousel .pc-next{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.45);color:#fff;border:none;width:40px;height:40px;border-radius:50%;font-size:1.5rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;transition:background .2s}
.post-carousel .pc-prev:hover,.post-carousel .pc-next:hover{background:rgba(0,0,0,.72)}
.post-carousel .pc-prev{left:10px}
.post-carousel .pc-next{right:10px}
.post-carousel .pc-dots{position:absolute;bottom:10px;left:0;right:0;display:flex;gap:6px;justify-content:center;z-index:2}
.post-carousel .pc-dots span{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.5);cursor:pointer;transition:background .2s}
.post-carousel .pc-dots span.active{background:#fff;width:20px;border-radius:4px}
/* ─── Tags ─────────────────────────────────────────────────────── */ /* ─── Tags ─────────────────────────────────────────────────────── */
.article-tags{margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border);display:flex;gap:.5rem;flex-wrap:wrap} .article-tags{margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border);display:flex;gap:.5rem;flex-wrap:wrap}
.tag{background:var(--bg);border:1px solid var(--border);padding:.25rem .75rem;border-radius:50px;font-size:.78rem;color:var(--mid)} .tag{background:var(--bg);border:1px solid var(--border);padding:.25rem .75rem;border-radius:50px;font-size:.78rem;color:var(--mid)}
@@ -99,7 +160,9 @@
.recent-title:hover{color:var(--gold)} .recent-title:hover{color:var(--gold)}
.recent-date{font-size:.73rem;color:var(--light);margin-top:.2rem} .recent-date{font-size:.73rem;color:var(--light);margin-top:.2rem}
.doctor-card{text-align:center} .doctor-card{text-align:center}
.doc-avatar{width:80px;height:80px;border-radius:50%;background:var(--gold-pale);margin:0 auto .8rem;display:flex;align-items:center;justify-content:center;font-size:2rem} .doc-avatar{width:90px;height:90px;border-radius:50%;background:var(--gold-pale);margin:0 auto .8rem;overflow:hidden;border:3px solid var(--gold)}
.doc-avatar img{width:100%;height:100%;object-fit:cover;object-position:top}
.doc-avatar-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem}
.doc-name{font-size:.95rem;font-weight:700;color:var(--dark)} .doc-name{font-size:.95rem;font-weight:700;color:var(--dark)}
.doc-title{font-size:.78rem;color:var(--light);margin:.2rem 0 .8rem} .doc-title{font-size:.78rem;color:var(--light);margin:.2rem 0 .8rem}
.doc-btn{background:var(--gold);color:#fff;padding:.5rem 1.2rem;border-radius:50px;font-family:'Vazirmatn',sans-serif;font-size:.82rem;border:none;cursor:pointer;width:100%;text-decoration:none;display:block;text-align:center} .doc-btn{background:var(--gold);color:#fff;padding:.5rem 1.2rem;border-radius:50px;font-family:'Vazirmatn',sans-serif;font-size:.82rem;border:none;cursor:pointer;width:100%;text-decoration:none;display:block;text-align:center}
@@ -254,9 +317,18 @@
<!-- ── Sidebar ── --> <!-- ── Sidebar ── -->
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-card doctor-card"> <div class="sidebar-card doctor-card">
<div class="doc-avatar">👩‍⚕️</div> <div class="doc-avatar">
@if (!string.IsNullOrEmpty(ViewData["HeroImage"]?.ToString()))
{
<img src="@ViewData["HeroImage"]" alt="@post.Author"/>
}
else
{
<div class="doc-avatar-placeholder">👩‍⚕️</div>
}
</div>
<div class="doc-name">@post.Author</div> <div class="doc-name">@post.Author</div>
<div class="doc-title">پزشک عمومی | متخصص زیبایی پوست</div> <div class="doc-title">@ViewData["HeroTag"]</div>
<a href="/#contact" class="doc-btn">رزرو نوبت</a> <a href="/#contact" class="doc-btn">رزرو نوبت</a>
</div> </div>
@@ -288,3 +360,31 @@
</div> </div>
</aside> </aside>
</div> </div>
@section Scripts {
<script>
// Initialise any in-content image carousels (swipe + arrows + dots)
document.querySelectorAll('[data-carousel]').forEach(c => {
const track = c.querySelector('.pc-track');
if (!track) return;
const imgs = [...track.querySelectorAll('img')];
const dots = c.querySelector('.pc-dots');
if (imgs.length <= 1) {
c.querySelector('.pc-prev')?.remove();
c.querySelector('.pc-next')?.remove();
dots?.remove();
return;
}
if (dots) dots.innerHTML = imgs.map((_, i) => `<span data-i="${i}"></span>`).join('');
const dotEls = dots ? [...dots.querySelectorAll('span')] : [];
const current = () => Math.round(track.scrollLeft / track.clientWidth);
const update = () => { const i = current(); dotEls.forEach((d, di) => d.classList.toggle('active', di === i)); };
const go = (i) => { i = Math.max(0, Math.min(imgs.length - 1, i)); track.scrollTo({ left: i * track.clientWidth, behavior: 'smooth' }); };
c.querySelector('.pc-prev')?.addEventListener('click', () => go(current() - 1));
c.querySelector('.pc-next')?.addEventListener('click', () => go(current() + 1));
dotEls.forEach((d, di) => d.addEventListener('click', () => go(di)));
track.addEventListener('scroll', () => { clearTimeout(track._t); track._t = setTimeout(update, 60); });
update();
});
</script>
}
+32 -3
View File
@@ -16,6 +16,8 @@ public class PostModel : PageModel
public BlogPost? Post { get; private set; } public BlogPost? Post { get; private set; }
public List<CommentVm> Comments { get; private set; } = new(); public List<CommentVm> Comments { get; private set; } = new();
public bool CommentSent { get; private set; } = false; public bool CommentSent { get; private set; } = false;
// (question, answer) pairs extracted from the post body for FAQPage JSON-LD
public List<(string Q, string A)> Faqs { get; private set; } = new();
// Comment form binding // Comment form binding
[BindProperty] [BindProperty]
@@ -45,11 +47,35 @@ public class PostModel : PageModel
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
Post = post; Post = post;
Faqs = ExtractFaqs(post.Content);
await LoadCommentsAsync(post.Id); await LoadCommentsAsync(post.Id);
await SetViewDataAsync(post); await SetViewDataAsync(post);
return Page(); return Page();
} }
// Pull FAQ pairs from the body: an <h3> whose text ends with the Persian
// question mark (؟) followed by the next <p>. Drives FAQPage rich results.
private static List<(string, string)> ExtractFaqs(string html)
{
var list = new List<(string, string)>();
if (string.IsNullOrEmpty(html)) return list;
var rx = new System.Text.RegularExpressions.Regex(
@"<h3[^>]*>(?<q>.*?)</h3>\s*<p[^>]*>(?<a>.*?)</p>",
System.Text.RegularExpressions.RegexOptions.Singleline |
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in rx.Matches(html))
{
var q = Strip(m.Groups["q"].Value);
var a = Strip(m.Groups["a"].Value);
if (q.EndsWith("؟") && a.Length > 0) list.Add((q, a));
}
return list;
}
private static string Strip(string s) =>
System.Net.WebUtility.HtmlDecode(
System.Text.RegularExpressions.Regex.Replace(s, "<[^>]*>", "")).Trim();
public async Task<IActionResult> OnPostAsync(string slug) public async Task<IActionResult> OnPostAsync(string slug)
{ {
var post = await _db.BlogPosts var post = await _db.BlogPosts
@@ -135,9 +161,12 @@ public class PostModel : PageModel
ViewData["ArticleType"] = post.ArticleType; ViewData["ArticleType"] = post.ArticleType;
ViewData["Slug"] = post.Slug; ViewData["Slug"] = post.Slug;
var s = await _db.SiteSettings var heroSettings = await _db.SiteSettings
.FirstOrDefaultAsync(x => x.Section == "hero" && x.Key == "name"); .Where(x => x.Section == "hero" && (x.Key == "name" || x.Key == "image" || x.Key == "tag"))
ViewData["SiteName"] = s?.Value ?? "دکتر سوسن آل‌طه"; .ToListAsync();
ViewData["SiteName"] = heroSettings.FirstOrDefault(x => x.Key == "name")?.Value ?? "دکتر سوسن آل‌طه";
ViewData["HeroImage"] = heroSettings.FirstOrDefault(x => x.Key == "image")?.Value ?? "";
ViewData["HeroTag"] = heroSettings.FirstOrDefault(x => x.Key == "tag")?.Value ?? "پزشک عمومی و متخصص زیبایی پوست";
} }
// View model for comments // View model for comments
+157
View File
@@ -0,0 +1,157 @@
@page "/gallery"
@model DrSousan.Api.Pages.GalleryModel
@{
var galBase = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? (Request.Scheme + "://" + Request.Host);
var galUrl = galBase + "/gallery";
var galDesc = "گالری نتایج واقعی قبل و بعد درمان‌های زیبایی پوست دکتر سوسن آل‌طه — بوتاکس، فیلر، لیزر، مزوتراپی و پاکسازی پوست.";
var firstImg = Model.Items
.Select(i => !string.IsNullOrEmpty(i.BeforeImageUrl) ? i.BeforeImageUrl : i.ImageUrl)
.FirstOrDefault(u => !string.IsNullOrEmpty(u)) ?? "";
var absFirstImg = string.IsNullOrEmpty(firstImg) ? "" : (firstImg.StartsWith("http") ? firstImg : galBase + firstImg);
}
@section Head {
<title>@ViewData["Title"]</title>
<meta name="description" content="@galDesc" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@ViewData["SiteName"]" />
<meta name="theme-color" content="#B8955A" />
<link rel="canonical" href="@galUrl" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="@ViewData["SiteName"]" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@galDesc" />
<meta property="og:url" content="@galUrl" />
<meta property="og:locale" content="fa_IR" />
@if (!string.IsNullOrEmpty(absFirstImg)) {
<meta property="og:image" content="@absFirstImg" />
}
<!-- Twitter -->
<meta name="twitter:card" content="@(string.IsNullOrEmpty(absFirstImg) ? "summary" : "summary_large_image")" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@galDesc" />
@if (!string.IsNullOrEmpty(absFirstImg)) {
<meta name="twitter:image" content="@absFirstImg" />
}
<!-- Structured data: ImageGallery + breadcrumb -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "ImageGallery",
"name": "@ViewData["Title"]",
"description": "@galDesc",
"url": "@galUrl",
"inLanguage": "fa-IR"
}
</script>
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{ "@@type": "ListItem", "position": 1, "name": "خانه", "item": "@galBase/" },
{ "@@type": "ListItem", "position": 2, "name": "گالری", "item": "@galUrl" }
]
}
</script>
<style>
.gal-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:6rem 2rem 3rem;text-align:center}
.gal-hero h1{font-size:clamp(1.8rem,4vw,2.5rem);font-weight:700;color:var(--dark);margin-bottom:.6rem}
.gal-hero p{font-size:1rem;color:var(--mid);max-width:560px;margin:0 auto}
.gal-tabs{display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap;max-width:1100px;margin:2rem auto 0;padding:0 2rem}
.gal-tab{background:transparent;border:1.5px solid var(--border);color:var(--mid);padding:.45rem 1.2rem;border-radius:50px;font-family:'Vazirmatn',sans-serif;font-size:.85rem;cursor:pointer;transition:all .2s}
.gal-tab.active,.gal-tab:hover{background:var(--gold);border-color:var(--gold);color:#fff}
.gal-grid{max-width:1100px;margin:2.5rem auto;padding:0 2rem;display:grid;grid-template-columns:repeat(3,1fr);gap:1.2rem}
@@media(max-width:900px){.gal-grid{grid-template-columns:repeat(2,1fr)}}
@@media(max-width:600px){.gal-grid{grid-template-columns:repeat(2,1fr);gap:.8rem}}
@@media(max-width:380px){.gal-grid{grid-template-columns:1fr}}
.gal-item{border-radius:16px;overflow:hidden;background:var(--white);border:1px solid var(--border);display:flex;flex-direction:column}
.gal-imgwrap{aspect-ratio:4/3;position:relative;overflow:hidden;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA)}
.gal-imgwrap img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .4s}
.gal-item:hover .gal-imgwrap img{transform:scale(1.05)}
.gal-ba{display:flex;flex-direction:row;height:100%}
.gal-ba .half{flex:1;position:relative;overflow:hidden}
.gal-ba .divider{width:2px;background:var(--border);flex-shrink:0}
.gal-labels{display:flex}
.gal-labels span{flex:1;text-align:center;padding:6px 8px;font-size:.78rem;font-weight:600;color:var(--mid);border-top:1px solid var(--border)}
.gal-labels span:first-child{border-left:1px solid var(--border)}
.gal-caption{padding:.55rem .8rem;font-size:.8rem;color:var(--mid);text-align:center;border-top:1px solid var(--border);line-height:1.5}
.gal-empty{text-align:center;padding:4rem 2rem;color:var(--light)}
.gal-back{text-align:center;padding:1rem 2rem 4rem}
</style>
}
<div class="gal-hero">
<h1>گالری نتایج قبل و بعد</h1>
<p>نمونه‌ای از نتایج واقعی درمان‌های انجام‌شده توسط دکتر سوسن آل‌طه. روی هر تصویر، قبل و بعد درمان قابل مشاهده است.</p>
</div>
@if (Model.Categories.Any())
{
<div class="gal-tabs">
<button class="gal-tab active" data-filter="">همه</button>
@foreach (var cat in Model.Categories)
{
<button class="gal-tab" data-filter="@cat">@cat</button>
}
</div>
}
<div class="gal-grid" id="galGrid">
@if (!Model.Items.Any())
{
<div class="gal-empty" style="grid-column:1/-1"><p>هنوز نمونه‌ای ثبت نشده است.</p></div>
}
else
{
@foreach (var item in Model.Items)
{
var hasBoth = !string.IsNullOrEmpty(item.BeforeImageUrl) && !string.IsNullOrEmpty(item.AfterImageUrl);
var hasImg = !string.IsNullOrEmpty(item.ImageUrl);
<div class="gal-item" data-cat="@item.Category">
<div class="gal-imgwrap">
@if (hasBoth)
{
<div class="gal-ba">
<div class="half"><img src="@item.BeforeImageUrl" alt="قبل از درمان @item.Caption" loading="lazy"/></div>
<div class="divider"></div>
<div class="half"><img src="@item.AfterImageUrl" alt="بعد از درمان @item.Caption" loading="lazy"/></div>
</div>
}
else if (hasImg)
{
<img src="@item.ImageUrl" alt="@item.Caption" loading="lazy"/>
}
</div>
@if (hasBoth)
{
<div class="gal-labels"><span>قبل از درمان</span><span>بعد از درمان</span></div>
}
@if (!string.IsNullOrEmpty(item.Caption)) { <div class="gal-caption">@item.Caption</div> }
</div>
}
}
</div>
<div class="gal-back">
<a href="/#contact" class="btn-primary">رزرو نوبت و مشاوره</a>
</div>
@section Scripts {
<script>
document.querySelectorAll('.gal-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.gal-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const f = btn.dataset.filter || '';
document.querySelectorAll('#galGrid .gal-item').forEach(item => {
const match = f === '' || (item.dataset.cat || '') === f;
item.style.display = match ? '' : 'none';
});
});
});
</script>
}
+36
View File
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DrSousan.Api.Data;
using DrSousan.Api.Models;
namespace DrSousan.Api.Pages;
public class GalleryModel : PageModel
{
private readonly AppDbContext _db;
public GalleryModel(AppDbContext db) => _db = db;
public List<GalleryItem> Items { get; private set; } = new();
public List<string> Categories { get; private set; } = new();
public async Task OnGetAsync()
{
Items = await _db.GalleryItems
.Where(g => g.IsActive)
.OrderBy(g => g.Order)
.ToListAsync();
Categories = Items
.Where(i => !string.IsNullOrWhiteSpace(i.Category))
.Select(i => i.Category.Trim())
.Distinct()
.ToList();
var siteName = (await _db.SiteSettings
.FirstOrDefaultAsync(x => x.Section == "hero" && x.Key == "name"))?.Value
?? "دکتر سوسن آل‌طه";
ViewData["SiteName"] = siteName;
ViewData["Title"] = $"گالری نتایج قبل و بعد | {siteName}";
}
}
+315 -53
View File
@@ -1,6 +1,7 @@
@page @page
@model IndexModel @model IndexModel
@{ @{
var siteBaseUrl = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? "https://draletaha.ir";
var h = Model.Hero; var h = Model.Hero;
var a = Model.About; var a = Model.About;
var c = Model.Contact; var c = Model.Contact;
@@ -14,30 +15,104 @@
var ig = c.GetValueOrDefault("instagram",""); var ig = c.GetValueOrDefault("instagram","");
var wa = c.GetValueOrDefault("whatsapp",""); var wa = c.GetValueOrDefault("whatsapp","");
var tg = c.GetValueOrDefault("telegram",""); var tg = c.GetValueOrDefault("telegram","");
var phone = c.GetValueOrDefault("phone","");
var email = c.GetValueOrDefault("email","");
var address = c.GetValueOrDefault("address","");
// Clean, keyword-rich meta description (trim stray whitespace/newlines from the editable subtitle)
var rawSubtitle = (h.GetValueOrDefault("subtitle","") ?? "").Replace("\n"," ").Replace("\r"," ").Trim();
while (rawSubtitle.Contains(" ")) rawSubtitle = rawSubtitle.Replace(" "," ");
var metaDesc = string.IsNullOrWhiteSpace(rawSubtitle)
? "دکتر سوسن آل‌طه، متخصص زیبایی پوست در تهران. خدمات بوتاکس، فیلر، لیزر موهای زائد، مزوتراپی و پاکسازی پوست با تمرکز بر نتایج طبیعی. رزرو نوبت و مشاوره."
: rawSubtitle;
if (metaDesc.Length > 160) metaDesc = metaDesc.Substring(0, 157).TrimEnd() + "…";
var absHero = string.IsNullOrEmpty(heroImg) ? "" : (heroImg.StartsWith("http") ? heroImg : siteBaseUrl + heroImg);
// Aggregate rating from active testimonials (for rich results)
var ratedReviews = Model.Testimonials.Where(t => t.Rating > 0).ToList();
var reviewCount = ratedReviews.Count;
var avgRating = reviewCount > 0 ? Math.Round(ratedReviews.Average(t => t.Rating), 1) : 0d;
// Social profiles for schema sameAs
var socials = new[] { ig, tg }.Where(s => !string.IsNullOrEmpty(s) && s.StartsWith("http")).ToList();
} }
@section Head { @section Head {
<title>@ViewData["Title"]</title> <title>@ViewData["Title"]</title>
<meta name="description" content="@h.GetValueOrDefault("subtitle","")" /> <meta name="description" content="@metaDesc" />
<meta name="keywords" content="دکتر پوست تهران,بوتاکس,لیزر موهای زائد,فیلر,مزوتراپی,زیبایی پوست" /> <meta name="keywords" content="دکتر پوست تهران,متخصص پوست تهران,بوتاکس,لیزر موهای زائد,فیلر,مزوتراپی,پاکسازی پوست,جوانسازی پوست,دکتر سوسن آل‌طه" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
<meta name="author" content="@siteName" />
<meta name="theme-color" content="#B8955A" />
<link rel="canonical" href="@(siteBaseUrl + "/")" />
<!-- Open Graph -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="@siteName" />
<meta property="og:title" content="@ViewData["Title"]" /> <meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@h.GetValueOrDefault("subtitle","")" /> <meta property="og:description" content="@metaDesc" />
<meta property="og:url" content="@(siteBaseUrl + "/")" />
<meta property="og:locale" content="fa_IR" /> <meta property="og:locale" content="fa_IR" />
<link rel="canonical" href="@(Request.Scheme + "://" + Request.Host + "/")" /> @if (!string.IsNullOrEmpty(absHero))
{
<meta property="og:image" content="@absHero" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="@siteName" />
}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@metaDesc" />
@if (!string.IsNullOrEmpty(absHero))
{
<meta name="twitter:image" content="@absHero" />
}
<!-- Structured data: medical practice -->
<script type="application/ld+json"> <script type="application/ld+json">
{ {
"@@context":"https://schema.org", "@@context":"https://schema.org",
"@@type":["MedicalBusiness","LocalBusiness"], "@@type":["MedicalBusiness","LocalBusiness"],
"@@id":"@(siteBaseUrl)/#business",
"name":"@siteName", "name":"@siteName",
"description":"@h.GetValueOrDefault("subtitle","")", "description":"@metaDesc",
"url":"@(Request.Scheme + "://" + Request.Host)", "url":"@(siteBaseUrl)",
"telephone":"@c.GetValueOrDefault("phone","")", "telephone":"@phone",
"address":{"@@type":"PostalAddress","addressLocality":"تهران","addressCountry":"IR","streetAddress":"@c.GetValueOrDefault("address","")"}, "priceRange":"$$",
"image":"@(string.IsNullOrEmpty(absHero) ? siteBaseUrl : absHero)",
"address":{"@@type":"PostalAddress","addressLocality":"تهران","addressRegion":"تهران","addressCountry":"IR","streetAddress":"@address"},
"areaServed":{"@@type":"City","name":"تهران"},
"openingHours":"@c.GetValueOrDefault("hours","")", "openingHours":"@c.GetValueOrDefault("hours","")",
"medicalSpecialty":"Dermatology" "medicalSpecialty":"Dermatology"@(socials.Any() ? "," : "")
@if (socials.Any())
{
@Html.Raw("\"sameAs\":[" + string.Join(",", socials.Select(s => "\"" + s + "\"")) + "]")
}@(reviewCount > 0 ? "," : "")
@if (reviewCount > 0)
{
@Html.Raw("\"aggregateRating\":{\"@type\":\"AggregateRating\",\"ratingValue\":\"" + avgRating + "\",\"reviewCount\":\"" + reviewCount + "\",\"bestRating\":\"5\"}")
}
} }
</script> </script>
<!-- Structured data: FAQ (rich results) -->
@if (Model.Faqs.Any())
{
<script type="application/ld+json">
{
"@@context":"https://schema.org",
"@@type":"FAQPage",
"mainEntity":[
@Html.Raw(string.Join(",", Model.Faqs.Select(f =>
"{\"@type\":\"Question\",\"name\":" + System.Text.Json.JsonSerializer.Serialize(f.Question) +
",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":" + System.Text.Json.JsonSerializer.Serialize(f.Answer) + "}}")))
]
}
</script>
}
<style> <style>
/* ─── Hero ─────────────────────────────────────────────────── */ /* ─── Hero ─────────────────────────────────────────────────── */
#hero { min-height:100svh; display:flex; align-items:center; padding:100px 0 3rem; position:relative; overflow:hidden; } #hero { min-height:100svh; display:flex; align-items:center; padding:100px 0 3rem; position:relative; overflow:hidden; }
@@ -111,14 +186,28 @@
.tab-btn { background:transparent; border:1.5px solid var(--border); color:var(--mid); padding:0.45rem 1.2rem; border-radius:50px; font-family:'Vazirmatn',sans-serif; font-size:0.85rem; cursor:pointer; transition:all 0.25s; } .tab-btn { background:transparent; border:1.5px solid var(--border); color:var(--mid); padding:0.45rem 1.2rem; border-radius:50px; font-family:'Vazirmatn',sans-serif; font-size:0.85rem; cursor:pointer; transition:all 0.25s; }
.tab-btn.active, .tab-btn:hover { background:var(--gold); border-color:var(--gold); color:var(--white); } .tab-btn.active, .tab-btn:hover { background:var(--gold); border-color:var(--gold); color:var(--white); }
.gallery-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.2rem; } .gallery-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.2rem; }
.gallery-item { border-radius:16px; overflow:hidden; aspect-ratio:4/3; background:linear-gradient(135deg, var(--gold-pale), #EDE0CA); position:relative; cursor:pointer; } .gallery-item { border-radius:16px; overflow:hidden; background:var(--white); border:1px solid var(--border); cursor:pointer; display:flex; flex-direction:column; }
.gallery-item img { width:100%; height:100%; object-fit:cover; transition:transform 0.4s; } .gallery-img-wrap { aspect-ratio:4/3; position:relative; overflow:hidden; background:linear-gradient(135deg, var(--gold-pale), #EDE0CA); flex-shrink:0; }
.gallery-item:hover img { transform:scale(1.05); } .gallery-img-wrap img { width:100%; height:100%; object-fit:cover; transition:transform 0.4s; display:block; }
.gallery-item:hover .gallery-img-wrap img { transform:scale(1.05); }
.gallery-item-overlay { position:absolute; inset:0; background:rgba(184,149,90,0); transition:background 0.3s; }
.gallery-item:hover .gallery-item-overlay { background:rgba(184,149,90,0.15); }
.gallery-placeholder { width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0.6rem; color:var(--gold); opacity:0.45; } .gallery-placeholder { width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0.6rem; color:var(--gold); opacity:0.45; }
.gallery-placeholder svg { width:36px; height:36px; } .gallery-placeholder svg { width:36px; height:36px; }
.gallery-placeholder p { font-size:0.75rem; } .gallery-placeholder p { font-size:0.75rem; }
.gallery-item-overlay { position:absolute; inset:0; background:rgba(184,149,90,0); display:flex; align-items:center; justify-content:center; transition:background 0.3s; } /* Before/After layout */
.gallery-item:hover .gallery-item-overlay { background:rgba(184,149,90,0.15); } .gallery-item.before-after .gallery-img-wrap { display:flex; flex-direction:row; }
.gallery-item.before-after .ba-half { flex:1; position:relative; overflow:hidden; }
.gallery-item.before-after .ba-half img { width:100%; height:100%; object-fit:cover; display:block; transition:transform 0.4s; }
.gallery-item.before-after:hover .ba-half img { transform:scale(1.05); }
.gallery-item.before-after .ba-divider { width:2px; background:var(--border); flex-shrink:0; }
/* Labels row — BELOW the image */
.ba-labels { display:flex; flex-shrink:0; }
.ba-label { flex:1; text-align:center; padding:6px 8px; font-size:0.78rem; font-weight:600; color:var(--mid); border-top:1px solid var(--border); line-height:1.3; word-break:break-word; }
.ba-label:first-child { border-left:1px solid var(--border); }
/* Caption row — BELOW image (or below labels for before/after) */
.gallery-caption { padding:0.55rem 0.8rem; font-size:0.8rem; color:var(--mid); text-align:center; border-top:1px solid var(--border); line-height:1.5; word-break:break-word; }
.gallery-item[style*="display:none"] { display:none !important; }
/* ─── Testimonials ─────────────────────────────────────────── */ /* ─── Testimonials ─────────────────────────────────────────── */
#testimonials { background:var(--section-bg); } #testimonials { background:var(--section-bg); }
.testimonials-header { text-align:center; margin-bottom:3rem; } .testimonials-header { text-align:center; margin-bottom:3rem; }
@@ -158,6 +247,29 @@
.faq-item[open] summary::after { content:""; } .faq-item[open] summary::after { content:""; }
.faq-answer { padding:0 1.5rem 1.25rem; color:var(--mid); font-size:0.92rem; line-height:1.85; border-top:1px solid var(--border); } .faq-answer { padding:0 1.5rem 1.25rem; color:var(--mid); font-size:0.92rem; line-height:1.85; border-top:1px solid var(--border); }
/* ─── Contact ──────────────────────────────────────────────── */ /* ─── Contact ──────────────────────────────────────────────── */
/* ─── Health Care Landing ─────────────────────────────────── */
#health-care { background:var(--section-bg); }
.health-header { text-align:center; margin-bottom:3rem; }
.health-cats { display:grid; grid-template-columns:1fr 1fr; gap:2rem; margin-bottom:2.5rem; }
.health-cat-card { background:var(--white); border-radius:24px; padding:2.2rem; border:1px solid var(--border); display:flex; flex-direction:column; gap:1rem; }
.health-cat-icon { width:60px; height:60px; border-radius:18px; display:flex; align-items:center; justify-content:center; }
.health-cat-icon svg { width:28px; height:28px; }
.health-cat-icon.beauty { background:var(--gold-pale); color:var(--gold); }
.health-cat-icon.health { background:#E3F2FD; color:#1565C0; }
.health-cat-card h3 { font-size:1.15rem; font-weight:700; color:var(--dark); }
.health-cat-card p { font-size:.88rem; color:var(--mid); line-height:1.8; }
.health-cat-list { list-style:none; padding:0; display:flex; flex-direction:column; gap:.45rem; flex:1; }
.health-cat-list li { font-size:.85rem; color:var(--mid); padding-right:1.2rem; position:relative; }
.health-cat-list li::before { content:"✓"; position:absolute; right:0; color:var(--gold); font-weight:700; }
.health-cta-btn { background:var(--gold); color:#fff; border:none; border-radius:12px; padding:.85rem 1.5rem; font-family:'Vazirmatn',sans-serif; font-size:.9rem; font-weight:600; cursor:pointer; transition:background .25s; margin-top:auto; }
.health-cta-btn:hover { background:#a07840; }
.health-cta-btn.health-btn { background:#1565C0; }
.health-cta-btn.health-btn:hover { background:#0d47a1; }
.health-form-wrap { margin-top:1rem; }
.health-form-card { background:var(--white); border-radius:24px; padding:2.5rem; border:1px solid var(--border); max-width:680px; margin:0 auto; }
.health-form-card h3 { font-size:1.1rem; font-weight:700; margin-bottom:.5rem; }
#health-care .hidden { display:none; }
/* ─── Contact ─────────────────────────────────────────────── */
#contact { background:var(--white); } #contact { background:var(--white); }
.contact-grid { display:grid; grid-template-columns:1fr 1.4fr; gap:4rem; align-items:start; } .contact-grid { display:grid; grid-template-columns:1fr 1.4fr; gap:4rem; align-items:start; }
.contact-info-list { display:flex; flex-direction:column; gap:1.5rem; margin-top:2rem; } .contact-info-list { display:flex; flex-direction:column; gap:1.5rem; margin-top:2rem; }
@@ -181,6 +293,16 @@
.form-group textarea { resize:vertical; min-height:110px; } .form-group textarea { resize:vertical; min-height:110px; }
.form-submit { width:100%; background:var(--gold); color:var(--white); border:none; padding:0.9rem; border-radius:12px; font-family:'Vazirmatn',sans-serif; font-size:0.95rem; font-weight:600; cursor:pointer; transition:background 0.25s, transform 0.2s; } .form-submit { width:100%; background:var(--gold); color:var(--white); border:none; padding:0.9rem; border-radius:12px; font-family:'Vazirmatn',sans-serif; font-size:0.95rem; font-weight:600; cursor:pointer; transition:background 0.25s, transform 0.2s; }
.form-submit:hover { background:var(--gold-light); transform:translateY(-2px); } .form-submit:hover { background:var(--gold-light); transform:translateY(-2px); }
/* ─── Floating contact buttons ─────────────────────────────── */
.fab-stack { position:fixed; bottom:1.5rem; left:1.5rem; z-index:90; display:flex; flex-direction:column; gap:0.7rem; }
.fab { width:54px; height:54px; border-radius:50%; display:flex; align-items:center; justify-content:center; box-shadow:0 6px 20px rgba(0,0,0,0.18); color:#fff; transition:transform 0.2s, box-shadow 0.2s; position:relative; }
.fab:hover { transform:translateY(-3px) scale(1.05); box-shadow:0 10px 28px rgba(0,0,0,0.25); }
.fab svg { width:26px; height:26px; }
.fab-wa { background:#25D366; }
.fab-call { background:var(--gold); }
.fab-pulse::after { content:''; position:absolute; inset:0; border-radius:50%; background:inherit; opacity:0.55; animation:fabPulse 2s ease-out infinite; z-index:-1; }
@@keyframes fabPulse { 0% { transform:scale(1); opacity:0.5; } 100% { transform:scale(1.9); opacity:0; } }
@@media (max-width:600px) { .fab-stack { bottom:1rem; left:1rem; gap:0.6rem; } .fab { width:50px; height:50px; } }
/* ─── Responsive ───────────────────────────────────────────── */ /* ─── Responsive ───────────────────────────────────────────── */
@@media (max-width:900px) { @@media (max-width:900px) {
.hero-inner { grid-template-columns:1fr; text-align:center; gap:2.5rem; } .hero-inner { grid-template-columns:1fr; text-align:center; gap:2.5rem; }
@@ -200,6 +322,7 @@
@@media (max-width:600px) { @@media (max-width:600px) {
section { padding:3.5rem 0; } section { padding:3.5rem 0; }
.container { padding:0 1.2rem; } .container { padding:0 1.2rem; }
.hero-name { white-space:normal; }
.hero-inner { padding:0 1.2rem; gap:2rem; } .hero-inner { padding:0 1.2rem; gap:2rem; }
.hero-image { max-width:260px; } .hero-image { max-width:260px; }
.hero-stats { gap:1.5rem; } .hero-stats { gap:1.5rem; }
@@ -213,6 +336,7 @@
.about-image-wrap { text-align:center; } .about-image-wrap { text-align:center; }
.testimonials-grid { grid-template-columns:1fr; } .testimonials-grid { grid-template-columns:1fr; }
.section-title { font-size:1.5rem; } .section-title { font-size:1.5rem; }
.health-cats { grid-template-columns:1fr; }
.faq-item summary { font-size:.9rem; padding:1rem 1.2rem; } .faq-item summary { font-size:.9rem; padding:1rem 1.2rem; }
.faq-answer { padding:0 1.2rem 1rem; font-size:.88rem; } .faq-answer { padding:0 1.2rem 1rem; font-size:.88rem; }
} }
@@ -255,7 +379,7 @@
<div class="hero-image-frame"> <div class="hero-image-frame">
@if (!string.IsNullOrEmpty(heroImg)) @if (!string.IsNullOrEmpty(heroImg))
{ {
<img src="@heroImg" alt="@siteName" /> <img src="@heroImg" alt="@siteName — متخصص زیبایی پوست در تهران" width="640" height="800" fetchpriority="high" decoding="async" />
} }
else else
{ {
@@ -294,7 +418,7 @@
<div class="about-img-box"> <div class="about-img-box">
@if (!string.IsNullOrEmpty(aboutImg)) @if (!string.IsNullOrEmpty(aboutImg))
{ {
<img src="@aboutImg" alt="@siteName" /> <img src="@aboutImg" alt="درباره @siteName — پزشک و متخصص زیبایی پوست" width="600" height="800" loading="lazy" decoding="async" />
} }
else else
{ {
@@ -405,35 +529,43 @@
<div class="divider"></div> <div class="divider"></div>
<p class="section-desc">نمونه‌ای از نتایج فوق‌العاده درمان‌های انجام‌شده توسط دکتر آل‌طه.</p> <p class="section-desc">نمونه‌ای از نتایج فوق‌العاده درمان‌های انجام‌شده توسط دکتر آل‌طه.</p>
</div> </div>
<div class="gallery-tabs fade-in"> <div class="gallery-grid" id="galleryGrid">
<button class="tab-btn active">همه</button>
<button class="tab-btn">بوتاکس</button>
<button class="tab-btn">لیزر</button>
<button class="tab-btn">مزوتراپی</button>
<button class="tab-btn">پاکسازی</button>
</div>
<div class="gallery-grid">
@if (Model.Gallery.Any()) @if (Model.Gallery.Any())
{ {
@foreach (var item in Model.Gallery) @foreach (var item in Model.Gallery)
{ {
<div class="gallery-item fade-in"> var hasBoth = !string.IsNullOrEmpty(item.BeforeImageUrl) && !string.IsNullOrEmpty(item.AfterImageUrl);
@if (!string.IsNullOrEmpty(item.ImageUrl)) var hasImg = !string.IsNullOrEmpty(item.ImageUrl);
if (hasBoth)
{ {
<img src="@item.ImageUrl" alt="@item.Caption" loading="lazy" /> <div class="gallery-item before-after fade-in" data-cat="@item.Category">
} <div class="gallery-img-wrap">
else <div class="ba-half">
{ <img src="@item.BeforeImageUrl" alt="قبل از درمان" loading="lazy"/>
<div class="gallery-placeholder"> </div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <div class="ba-divider"></div>
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/> <div class="ba-half">
<polyline points="21 15 16 10 5 21"/> <img src="@item.AfterImageUrl" alt="بعد از درمان" loading="lazy"/>
</svg>
<p>تصویر قبل و بعد</p>
</div> </div>
}
<div class="gallery-item-overlay"></div> <div class="gallery-item-overlay"></div>
</div> </div>
<div class="ba-labels">
<span class="ba-label">قبل از درمان</span>
<span class="ba-label">بعد از درمان</span>
</div>
@if (!string.IsNullOrEmpty(item.Caption)) { <div class="gallery-caption">@item.Caption</div> }
</div>
}
else if (hasImg)
{
<div class="gallery-item fade-in" data-cat="@item.Category">
<div class="gallery-img-wrap">
<img src="@item.ImageUrl" alt="@item.Caption" loading="lazy"/>
<div class="gallery-item-overlay"></div>
</div>
@if (!string.IsNullOrEmpty(item.Caption)) { <div class="gallery-caption">@item.Caption</div> }
</div>
}
} }
} }
else else
@@ -453,6 +585,12 @@
} }
} }
</div> </div>
@if (Model.GalleryTotal > 3)
{
<div class="fade-in" style="text-align:center;margin-top:2.5rem">
<a href="/gallery" class="btn-primary">مشاهده گالری کامل (@Model.GalleryTotal نمونه)</a>
</div>
}
</div> </div>
</section> </section>
@@ -552,6 +690,54 @@
</div> </div>
</section> </section>
<!-- ══════ HEALTH CARE LANDING ══════ -->
<section id="health-care" itemscope itemtype="https://schema.org/MedicalClinic">
<div class="container">
<div class="health-header fade-in">
<span class="section-label">مراقبت سلامت</span>
<h2 class="section-title" itemprop="name">خدمات پزشکی دکتر آل‌طه</h2>
<div class="divider"></div>
<p class="section-desc" itemprop="description">ما در دو حوزه تخصصی <strong>زیبایی پوست</strong> و <strong>سلامت عمومی</strong> در کنار شما هستیم. از مشاوره اولیه تا پیگیری درمان، تیم ما آماده پاسخگویی است.</p>
</div>
<!-- Category Cards -->
<div class="health-cats fade-in">
<div class="health-cat-card" itemprop="availableService" itemscope itemtype="https://schema.org/MedicalTherapy">
<div class="health-cat-icon beauty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
</div>
<h3 itemprop="name">زیبایی پوست</h3>
<p itemprop="description">بوتاکس، فیلر، لیزر، مزوتراپی، پاکسازی عمیق پوست و درمان تخصصی انواع مشکلات پوستی توسط متخصص.</p>
<ul class="health-cat-list">
<li>تزریق بوتاکس و فیلر</li>
<li>لیزر موهای زائد و جوانسازی</li>
<li>مزوتراپی و PRP</li>
<li>درمان جای جوش و لک</li>
<li>پاکسازی عمیق پوست</li>
</ul>
<button class="health-cta-btn" onclick="openHealthForm('beauty')">درخواست مشاوره زیبایی</button>
</div>
<div class="health-cat-card" itemprop="availableService" itemscope itemtype="https://schema.org/MedicalTherapy">
<div class="health-cat-icon health">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<h3 itemprop="name">سلامت عمومی</h3>
<p itemprop="description">معاینه، تشخیص و پیگیری بیماری‌های عمومی، مشاوره تغذیه، مدیریت وزن و برنامه‌ریزی سلامت فردی.</p>
<ul class="health-cat-list">
<li>معاینه و ویزیت تخصصی</li>
<li>مشاوره و مدیریت وزن</li>
<li>پیگیری بیماری‌های مزمن</li>
<li>برنامه سلامت شخصی‌سازی‌شده</li>
<li>آزمایشات تخصصی</li>
</ul>
<button class="health-cta-btn health-btn" onclick="openHealthForm('health')">رزرو نوبت</button>
</div>
</div>
</div>
</section>
<!-- ══════ CONTACT ══════ --> <!-- ══════ CONTACT ══════ -->
<section id="contact"> <section id="contact">
<div class="container"> <div class="container">
@@ -572,7 +758,7 @@
</div> </div>
<div class="info-text"> <div class="info-text">
<strong>تلفن تماس</strong> <strong>تلفن تماس</strong>
<p>@c.GetValueOrDefault("phone","")</p> <p><a href="tel:@(new string(phone.Where(ch => char.IsDigit(ch) || ch=='+').ToArray()))" style="color:inherit">@phone</a></p>
</div> </div>
</div> </div>
} }
@@ -587,7 +773,7 @@
</div> </div>
<div class="info-text"> <div class="info-text">
<strong>ایمیل</strong> <strong>ایمیل</strong>
<p>@c.GetValueOrDefault("email","")</p> <p><a href="mailto:@email" style="color:inherit">@email</a></p>
</div> </div>
</div> </div>
} }
@@ -602,7 +788,7 @@
</div> </div>
<div class="info-text"> <div class="info-text">
<strong>آدرس مطب</strong> <strong>آدرس مطب</strong>
<p>@c.GetValueOrDefault("address","")</p> <p><a href="https://www.google.com/maps/search/?api=1&query=@Uri.EscapeDataString(address)" target="_blank" rel="noopener" style="color:inherit">@address</a></p>
</div> </div>
</div> </div>
} }
@@ -656,43 +842,62 @@
<div class="contact-form fade-in"> <div class="contact-form fade-in">
<div class="form-title">رزرو نوبت آنلاین</div> <div class="form-title">رزرو نوبت آنلاین</div>
<p class="form-sub">فرم زیر را پر کنید، در اسرع وقت با شما تماس می‌گیریم.</p> <p class="form-sub">فرم زیر را پر کنید، در اسرع وقت با شما تماس می‌گیریم.</p>
<form onsubmit="handleSubmit(event)"> <form id="bookingForm" onsubmit="handleSubmit(event)">
<input type="hidden" id="booking-category" value="beauty"/>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>نام</label> <label>نام</label>
<input type="text" placeholder="نام شما" required /> <input id="booking-firstname" type="text" placeholder="نام شما" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>نام خانوادگی</label> <label>نام خانوادگی</label>
<input type="text" placeholder="نام خانوادگی" required /> <input id="booking-lastname" type="text" placeholder="نام خانوادگی" required />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>شماره موبایل</label> <label>شماره موبایل</label>
<input type="tel" placeholder="09XX-XXX-XXXX" required /> <input id="booking-phone" type="tel" placeholder="09XX-XXX-XXXX" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>نوع خدمت مورد نظر</label> <label>نوع خدمت مورد نظر</label>
<select> <select id="booking-service" onchange="document.getElementById('booking-category').value=this.value==='سلامت عمومی'?'health':'beauty'">
<option value="" disabled selected>انتخاب خدمت</option> <option value="" disabled selected>انتخاب خدمت</option>
@foreach (var svc in Model.Services) @foreach (var svc in Model.Services)
{ {
<option>@svc.Title</option> <option>@svc.Title</option>
} }
<option value="سلامت عمومی">سلامت عمومی</option>
<option>سایر</option> <option>سایر</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>توضیحات (اختیاری)</label> <label>توضیحات (اختیاری)</label>
<textarea placeholder="مشکل پوستی یا سوالات خود را اینجا بنویسید..."></textarea> <textarea id="booking-message" placeholder="مشکل پوستی، سوال یا درخواست خود را بنویسید..."></textarea>
</div> </div>
<button type="submit" class="form-submit">ارسال و رزرو نوبت</button> <button type="submit" class="form-submit" id="booking-submit">ارسال و رزرو نوبت</button>
</form> </form>
<div id="booking-tracking" style="display:none"></div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- ══════ FLOATING CONTACT BUTTONS ══════ -->
<div class="fab-stack">
@if (!string.IsNullOrEmpty(wa))
{
<a href="@wa" class="fab fab-wa fab-pulse" target="_blank" rel="noopener" aria-label="تماس از طریق واتساپ" title="واتساپ">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.946C.16 5.335 5.495 0 12.05 0a11.82 11.82 0 0 1 8.413 3.488 11.82 11.82 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884a9.86 9.86 0 0 0 1.51 5.26l-.999 3.648 3.737-.961zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z"/></svg>
</a>
}
@if (!string.IsNullOrEmpty(phone))
{
<a href="tel:@(new string(phone.Where(ch => char.IsDigit(ch) || ch=='+').ToArray()))" class="fab fab-call" aria-label="تماس تلفنی" title="تماس تلفنی">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13.6a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 3h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 10.6a16 16 0 0 0 6 6l.91-.91a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</a>
}
</div>
@section Scripts { @section Scripts {
<script> <script>
// Before/After toggle // Before/After toggle
@@ -703,25 +908,82 @@
btns[1].classList.toggle('active'); btns[1].classList.toggle('active');
} }
// Tab buttons // Tab buttons — filter gallery by category
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
const cat = btn.textContent.trim();
document.querySelectorAll('#galleryGrid .gallery-item').forEach(item => {
const match = cat === 'همه' || (item.dataset.cat || '') === cat;
item.style.display = match ? '' : 'none';
});
}); });
}); });
// Form submission // Main booking form submits to /api/health-request
function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('.form-submit'); const btn = document.getElementById('booking-submit');
btn.textContent = '✓ درخواست شما ثبت شد'; const firstName = document.getElementById('booking-firstname').value.trim();
const lastName = document.getElementById('booking-lastname').value.trim();
const phone = document.getElementById('booking-phone').value.trim();
const service = document.getElementById('booking-service').value;
const message = document.getElementById('booking-message').value.trim();
const category = document.getElementById('booking-category').value || 'beauty';
btn.textContent = '...در حال ارسال';
btn.disabled = true;
try {
const res = await fetch('/api/health-request', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
fullName: firstName + ' ' + lastName,
phoneNumber: phone,
email: '',
message: (service ? 'خدمت: ' + service + '\n' : '') + message,
category: category
})
});
if (!res.ok) throw new Error();
const data = await res.json();
const code = data.trackingCode || '';
btn.textContent = '✓ درخواست ثبت شد';
btn.style.background = '#2D7A4F'; btn.style.background = '#2D7A4F';
// Show tracking code to user
const trackBox = document.getElementById('booking-tracking');
if (trackBox && code) {
trackBox.innerHTML = `
<div style="background:#E8F5E9;border-radius:14px;padding:1rem 1.4rem;border-right:4px solid #388E3C;margin-top:1rem">
<p style="font-size:.85rem;color:#2D7A4F;margin-bottom:.4rem;font-weight:600">✓ درخواست شما با موفقیت ثبت شد</p>
<p style="font-size:.82rem;color:#555;margin-bottom:.5rem">کد رهگیری شما برای پیگیری پاسخ پزشک:</p>
<div style="font-size:1.3rem;font-weight:700;letter-spacing:.1em;color:#1a1a1a;font-family:monospace;background:#fff;display:inline-block;padding:.3rem .8rem;border-radius:8px;border:1.5px solid #a5d6a7">${code}</div>
<p style="font-size:.75rem;color:#777;margin-top:.5rem">این کد را نزد خود نگه دارید. در اسرع وقت با شما تماس می‌گیریم.</p>
</div>`;
trackBox.style.display = 'block';
}
setTimeout(() => { setTimeout(() => {
btn.textContent = 'ارسال و رزرو نوبت'; btn.textContent = 'ارسال و رزرو نوبت';
btn.style.background = ''; btn.style.background = '';
btn.disabled = false;
e.target.reset(); e.target.reset();
}, 3000); document.getElementById('booking-category').value = 'beauty';
if (trackBox) { trackBox.innerHTML=''; trackBox.style.display='none'; }
}, 8000);
} catch {
btn.textContent = 'خطا — دوباره تلاش کنید';
btn.style.background = '#c62828';
setTimeout(() => { btn.textContent='ارسال و رزرو نوبت'; btn.style.background=''; btn.disabled=false; }, 2500);
}
}
// Health section buttons scroll to contact form and pre-select category
function openHealthForm(category) {
document.getElementById('booking-category').value = category;
// Pre-select matching service if health
const sel = document.getElementById('booking-service');
if (category === 'health') sel.value = 'سلامت عمومی';
document.getElementById('contact').scrollIntoView({behavior:'smooth', block:'start'});
} }
// Active nav link on scroll // Active nav link on scroll
+6 -2
View File
@@ -19,6 +19,7 @@ public class IndexModel : PageModel
// Collections // Collections
public List<Service> Services { get; private set; } = new(); public List<Service> Services { get; private set; } = new();
public List<GalleryItem> Gallery { get; private set; } = new(); public List<GalleryItem> Gallery { get; private set; } = new();
public int GalleryTotal { get; private set; } = 0;
public List<Testimonial> Testimonials { get; private set; } = new(); public List<Testimonial> Testimonials { get; private set; } = new();
public List<BlogPost> RecentPosts { get; private set; } = new(); public List<BlogPost> RecentPosts { get; private set; } = new();
public List<Faq> Faqs { get; private set; } = new(); public List<Faq> Faqs { get; private set; } = new();
@@ -39,9 +40,12 @@ public class IndexModel : PageModel
.OrderBy(s => s.Order) .OrderBy(s => s.Order)
.ToListAsync(); .ToListAsync();
Gallery = await _db.GalleryItems // Homepage shows only a teaser of 3; full set lives on /gallery
.Where(g => g.IsActive) var galleryQuery = _db.GalleryItems.Where(g => g.IsActive);
GalleryTotal = await galleryQuery.CountAsync();
Gallery = await galleryQuery
.OrderBy(g => g.Order) .OrderBy(g => g.Order)
.Take(3)
.ToListAsync(); .ToListAsync();
Testimonials = await _db.Testimonials Testimonials = await _db.Testimonials
+37 -1
View File
@@ -1,14 +1,43 @@
@using Microsoft.EntityFrameworkCore
@inject DrSousan.Api.Data.AppDbContext _layoutDb
@{
var _identity = await _layoutDb.SiteSettings
.Where(s => s.Section == "identity")
.ToListAsync();
var _logoUrl = _identity.FirstOrDefault(s => s.Key == "logo")?.Value ?? "";
var _faviconUrl = _identity.FirstOrDefault(s => s.Key == "favicon")?.Value ?? "";
var _gaId = (_identity.FirstOrDefault(s => s.Key == "ga_id")?.Value ?? "").Trim();
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@if (!string.IsNullOrEmpty(_gaId))
{
<!-- Google Analytics (GA4) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=@_gaId"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '@Html.Raw(_gaId)');
</script>
}
@RenderSection("Head", required: false) @RenderSection("Head", required: false)
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" onload="this.rel='stylesheet'" /> <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" onload="this.rel='stylesheet'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" /></noscript>
@if (!string.IsNullOrEmpty(_faviconUrl))
{
<link rel="icon" href="@_faviconUrl" type="image/png" />
<link rel="shortcut icon" href="@_faviconUrl" />
}
else
{
<link rel="icon" href="/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/favicon.ico" type="image/x-icon" />
}
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
@@ -122,7 +151,14 @@
<body> <body>
<header> <header>
<a class="logo" href="/">@(ViewData["SiteName"] ?? "دکتر سوسن آل‌طه")</a> <a class="logo" href="/" style="display:flex;align-items:center;gap:.6rem">
@if (!string.IsNullOrEmpty(_logoUrl))
{
<img src="@_logoUrl" alt="@(ViewData["SiteName"] ?? "دکتر سوسن آل‌طه")" style="height:36px;width:auto;object-fit:contain;flex-shrink:0" />
<span style="color:var(--border);font-weight:300;font-size:1.1rem;line-height:1">|</span>
}
<span>@(ViewData["SiteName"] ?? "دکتر سوسن آل‌طه")</span>
</a>
<nav> <nav>
<a href="/#about">درباره من</a> <a href="/#about">درباره من</a>
<a href="/#services">خدمات</a> <a href="/#services">خدمات</a>
+291 -4
View File
@@ -1,6 +1,8 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -49,16 +51,63 @@ builder.Services.AddCors(opt =>
// Razor Pages for SSR public pages // Razor Pages for SSR public pages
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
// Don't entity-encode non-ASCII (Persian) or chars like '+' in markup output.
// Default encoder turns "application/ld+json" into "application/ld&#x2B;json" and
// Persian text into \&#x06XX; entities — valid but bloated and trips some validators.
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// Fix circular JSON references (BlogPost ↔ BlogCategory) // Fix circular JSON references (BlogPost ↔ BlogCategory)
builder.Services.ConfigureHttpJsonOptions(opts => builder.Services.ConfigureHttpJsonOptions(opts =>
opts.SerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles); opts.SerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles);
// Response compression (gzip + brotli) — origin nginx no longer sits behind a
// compressing CDN, so HTML/CSS/JS were served uncompressed (~80KB). Cuts payload ~75%.
builder.Services.AddResponseCompression(opts =>
{
opts.EnableForHttps = true;
opts.Providers.Add<Microsoft.AspNetCore.ResponseCompression.BrotliCompressionProvider>();
opts.Providers.Add<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProvider>();
opts.MimeTypes = Microsoft.AspNetCore.ResponseCompression.ResponseCompressionDefaults.MimeTypes
.Concat(new[] { "application/ld+json", "image/svg+xml" });
});
builder.Services.Configure<Microsoft.AspNetCore.ResponseCompression.BrotliCompressionProviderOptions>(
o => o.Level = System.IO.Compression.CompressionLevel.Fastest);
builder.Services.Configure<Microsoft.AspNetCore.ResponseCompression.GzipCompressionProviderOptions>(
o => o.Level = System.IO.Compression.CompressionLevel.Fastest);
// ── Build ───────────────────────────────────────────────────────────────────── // ── Build ─────────────────────────────────────────────────────────────────────
var app = builder.Build(); var app = builder.Build();
// In production return a clean 500 page rather than an unhandled exception dump
// (Googlebot seeing raw 5xx responses causes GSC "Server error" indexing failures).
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/error");
app.UseResponseCompression();
// Baseline security headers on every response (safe defaults; no HSTS yet — the
// cert was just stabilised, so we avoid forcing HTTPS pinning until it's proven).
app.Use(async (ctx, next) =>
{
var h = ctx.Response.Headers;
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "SAMEORIGIN";
h["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next();
});
app.UseCors(); app.UseCors();
app.UseDefaultFiles(); // serves /admin/index.html for /admin/ (wwwroot/index.html deleted → no conflict with Razor Pages) app.UseDefaultFiles(); // serves /admin/index.html for /admin/ (wwwroot/index.html deleted → no conflict with Razor Pages)
app.UseStaticFiles(); app.UseStaticFiles(new StaticFileOptions
{
// Uploaded files use immutable GUID names → safe to cache aggressively.
OnPrepareResponse = ctx =>
{
var p = ctx.Context.Request.Path.Value ?? "";
if (p.StartsWith("/uploads/", StringComparison.OrdinalIgnoreCase))
ctx.Context.Response.Headers["Cache-Control"] = "public,max-age=2592000,immutable";
}
});
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
@@ -89,6 +138,75 @@ await using (var scope = app.Services.CreateAsyncScope())
"""); """);
} }
catch { /* already exists */ } catch { /* already exists */ }
// Ensure Patient tables exist
try {
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "Patients" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"FullName" TEXT NOT NULL DEFAULT '',
"PhoneNumber" TEXT NOT NULL DEFAULT '',
"Email" TEXT NOT NULL DEFAULT '',
"Age" INTEGER NOT NULL DEFAULT 0,
"Weight" REAL NOT NULL DEFAULT 0,
"Height" REAL NOT NULL DEFAULT 0,
"Gender" TEXT NOT NULL DEFAULT '',
"BloodType" TEXT NOT NULL DEFAULT '',
"DiseaseHistory" TEXT NOT NULL DEFAULT '',
"Allergies" TEXT NOT NULL DEFAULT '',
"Medications" TEXT NOT NULL DEFAULT '',
"Notes" TEXT NOT NULL DEFAULT '',
"Category" TEXT NOT NULL DEFAULT 'beauty',
"IsActive" INTEGER NOT NULL DEFAULT 1,
"CreatedAt" TEXT NOT NULL DEFAULT ''
)
""");
} catch { }
try {
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PatientVisits" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"PatientId" INTEGER NOT NULL,
"Title" TEXT NOT NULL DEFAULT '',
"Content" TEXT NOT NULL DEFAULT '',
"Prescription" TEXT NOT NULL DEFAULT '',
"VisitType" TEXT NOT NULL DEFAULT 'ویزیت',
"VisitDate" TEXT NOT NULL DEFAULT '',
"NextVisitDate" TEXT,
"CreatedAt" TEXT NOT NULL DEFAULT '',
FOREIGN KEY ("PatientId") REFERENCES "Patients"("Id") ON DELETE CASCADE
)
""");
} catch { }
try {
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "HealthRequests" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"TrackingCode" TEXT NOT NULL DEFAULT '',
"FullName" TEXT NOT NULL DEFAULT '',
"PhoneNumber" TEXT NOT NULL DEFAULT '',
"Email" TEXT NOT NULL DEFAULT '',
"Message" TEXT NOT NULL DEFAULT '',
"Category" TEXT NOT NULL DEFAULT 'beauty',
"IsHandled" INTEGER NOT NULL DEFAULT 0,
"Diagnosis" TEXT NOT NULL DEFAULT '',
"DoctorReply" TEXT NOT NULL DEFAULT '',
"RepliedAt" TEXT,
"CreatedAt" TEXT NOT NULL DEFAULT ''
)
""");
} catch { }
// Add new columns to existing HealthRequests table (safe migration)
foreach (var col in new[] {
("TrackingCode", "TEXT NOT NULL DEFAULT ''"),
("Diagnosis", "TEXT NOT NULL DEFAULT ''"),
("DoctorReply", "TEXT NOT NULL DEFAULT ''"),
("RepliedAt", "TEXT") })
{
try { await db.Database.ExecuteSqlRawAsync(
$"ALTER TABLE HealthRequests ADD COLUMN \"{col.Item1}\" {col.Item2}"); }
catch { }
}
await SeedAsync(db); await SeedAsync(db);
} }
@@ -535,7 +653,7 @@ app.MapPost("/api/upload", async (HttpRequest request, IWebHostEnvironment env)
if (!request.HasFormContentType || !request.Form.Files.Any()) if (!request.HasFormContentType || !request.Form.Files.Any())
return Results.BadRequest("No file provided."); return Results.BadRequest("No file provided.");
var file = request.Form.Files[0]; var file = request.Form.Files[0];
var allowed = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif" }; var allowed = new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".ico" };
var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!allowed.Contains(ext)) return Results.BadRequest("File type not allowed."); if (!allowed.Contains(ext)) return Results.BadRequest("File type not allowed.");
var uploadsDir = Path.Combine(env.WebRootPath, "uploads"); var uploadsDir = Path.Combine(env.WebRootPath, "uploads");
@@ -566,10 +684,154 @@ app.MapDelete("/api/upload/{filename}", (string filename, IWebHostEnvironment en
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(); }).RequireAuthorization();
// ── Patients (admin) ──────────────────────────────────────────────────────────
var patientsGroup = app.MapGroup("/api/patients").RequireAuthorization();
patientsGroup.MapGet("/", async (string? category, string? search, AppDbContext db) =>
{
var q = db.Patients.Where(p => p.IsActive);
if (!string.IsNullOrEmpty(category)) q = q.Where(p => p.Category == category);
if (!string.IsNullOrEmpty(search))
q = q.Where(p => p.FullName.Contains(search) || p.PhoneNumber.Contains(search));
return Results.Ok(await q.OrderByDescending(p => p.CreatedAt)
.Select(p => new { p.Id, p.FullName, p.PhoneNumber, p.Email, p.Age, p.Gender,
p.Category, p.BloodType, p.CreatedAt,
VisitCount = db.PatientVisits.Count(v => v.PatientId == p.Id) })
.ToListAsync());
});
patientsGroup.MapGet("/{id:int}", async (int id, AppDbContext db) =>
{
var p = await db.Patients.Include(x => x.Visits.OrderByDescending(v => v.VisitDate))
.FirstOrDefaultAsync(x => x.Id == id);
return p is null ? Results.NotFound() : Results.Ok(p);
});
patientsGroup.MapPost("/", async (Patient patient, AppDbContext db) =>
{
patient.CreatedAt = DateTime.UtcNow;
db.Patients.Add(patient);
await db.SaveChangesAsync();
return Results.Created($"/api/patients/{patient.Id}", patient);
});
patientsGroup.MapPut("/{id:int}", async (int id, Patient updated, AppDbContext db) =>
{
var p = await db.Patients.FindAsync(id);
if (p is null) return Results.NotFound();
p.FullName = updated.FullName; p.PhoneNumber = updated.PhoneNumber;
p.Email = updated.Email; p.Age = updated.Age; p.Weight = updated.Weight;
p.Height = updated.Height; p.Gender = updated.Gender; p.BloodType = updated.BloodType;
p.DiseaseHistory = updated.DiseaseHistory; p.Allergies = updated.Allergies;
p.Medications = updated.Medications; p.Notes = updated.Notes;
p.Category = updated.Category; p.IsActive = updated.IsActive;
await db.SaveChangesAsync();
return Results.Ok(p);
});
patientsGroup.MapDelete("/{id:int}", async (int id, AppDbContext db) =>
{
var p = await db.Patients.FindAsync(id);
if (p is null) return Results.NotFound();
p.IsActive = false; // soft delete
await db.SaveChangesAsync();
return Results.NoContent();
});
// Patient visits/notes
patientsGroup.MapGet("/{id:int}/visits", async (int id, AppDbContext db) =>
Results.Ok(await db.PatientVisits.Where(v => v.PatientId == id)
.OrderByDescending(v => v.VisitDate).ToListAsync()));
patientsGroup.MapPost("/{id:int}/visits", async (int id, PatientVisit visit, AppDbContext db) =>
{
var patient = await db.Patients.FindAsync(id);
if (patient is null) return Results.NotFound();
visit.PatientId = id;
visit.CreatedAt = DateTime.UtcNow;
if (visit.VisitDate == default) visit.VisitDate = DateTime.UtcNow;
db.PatientVisits.Add(visit);
await db.SaveChangesAsync();
return Results.Created($"/api/patients/{id}/visits/{visit.Id}", visit);
});
patientsGroup.MapDelete("/visits/{visitId:int}", async (int visitId, AppDbContext db) =>
{
var v = await db.PatientVisits.FindAsync(visitId);
if (v is null) return Results.NotFound();
db.PatientVisits.Remove(v);
await db.SaveChangesAsync();
return Results.NoContent();
});
// ── Health Requests (public submit / admin manage) ────────────────────────────
app.MapPost("/api/health-request", async (HealthRequest req, AppDbContext db) =>
{
req.CreatedAt = DateTime.UtcNow;
req.IsHandled = false;
req.TrackingCode = "DR-" + GenerateTrackingCode();
db.HealthRequests.Add(req);
await db.SaveChangesAsync();
return Results.Ok(new { message = "درخواست شما ثبت شد", trackingCode = req.TrackingCode, id = req.Id });
});
// Public: look up own request by tracking code
app.MapGet("/api/health-request/track/{code}", async (string code, AppDbContext db) =>
{
var r = await db.HealthRequests.FirstOrDefaultAsync(x => x.TrackingCode == code);
if (r is null) return Results.NotFound(new { message = "کد رهگیری یافت نشد" });
return Results.Ok(new {
r.TrackingCode, r.FullName, r.Category, r.Message, r.IsHandled,
r.Diagnosis, r.DoctorReply, r.RepliedAt, r.CreatedAt
});
});
app.MapGet("/api/health-requests", async (bool? handled, string? phone, AppDbContext db) =>
{
var q = db.HealthRequests.AsQueryable();
if (handled.HasValue) q = q.Where(r => r.IsHandled == handled);
if (!string.IsNullOrEmpty(phone)) q = q.Where(r => r.PhoneNumber == phone);
return Results.Ok(await q.OrderByDescending(r => r.CreatedAt).ToListAsync());
}).RequireAuthorization();
// Doctor reply: set diagnosis + reply text + mark handled
app.MapPut("/api/health-requests/{id:int}/reply", async (int id, DoctorReplyDto dto, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
if (r is null) return Results.NotFound();
r.Diagnosis = dto.Diagnosis ?? r.Diagnosis;
r.DoctorReply = dto.DoctorReply ?? r.DoctorReply;
r.IsHandled = true;
r.RepliedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(r);
}).RequireAuthorization();
// Mark handled without reply
app.MapPut("/api/health-requests/{id:int}", async (int id, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
if (r is null) return Results.NotFound();
r.IsHandled = true;
await db.SaveChangesAsync();
return Results.Ok(r);
}).RequireAuthorization();
app.MapDelete("/api/health-requests/{id:int}", async (int id, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
if (r is null) return Results.NotFound();
db.HealthRequests.Remove(r);
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization();
// ── Sitemap ─────────────────────────────────────────────────────────────────── // ── Sitemap ───────────────────────────────────────────────────────────────────
app.MapGet("/sitemap.xml", async (AppDbContext db, HttpContext ctx) => app.MapGet("/sitemap.xml", async (AppDbContext db, HttpContext ctx) =>
{ {
var baseUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}"; // SITE_BASE_URL env var wins (e.g. "https://draletaha.ir") — falls back to request scheme+host
var baseUrl = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/')
?? $"{ctx.Request.Scheme}://{ctx.Request.Host}";
var published = await db.BlogPosts.Where(p => p.IsPublished) var published = await db.BlogPosts.Where(p => p.IsPublished)
.Select(p => new { p.Slug, p.UpdatedAt }).ToListAsync(); .Select(p => new { p.Slug, p.UpdatedAt }).ToListAsync();
@@ -588,6 +850,7 @@ app.MapGet("/sitemap.xml", async (AppDbContext db, HttpContext ctx) =>
} }
Url(baseUrl + "/", "1.0", "weekly", DateTime.UtcNow); Url(baseUrl + "/", "1.0", "weekly", DateTime.UtcNow);
Url(baseUrl + "/gallery", "0.8", "weekly", DateTime.UtcNow);
Url(baseUrl + "/blog", "0.9", "daily", DateTime.UtcNow); Url(baseUrl + "/blog", "0.9", "daily", DateTime.UtcNow);
foreach (var p in published) foreach (var p in published)
Url($"{baseUrl}/blog/{p.Slug}", "0.8", "monthly", p.UpdatedAt); Url($"{baseUrl}/blog/{p.Slug}", "0.8", "monthly", p.UpdatedAt);
@@ -603,7 +866,8 @@ app.MapGet("/healthz", () => Results.Ok(new { status = "healthy", utc = DateTime
// ── Robots.txt ──────────────────────────────────────────────────────────────── // ── Robots.txt ────────────────────────────────────────────────────────────────
app.MapGet("/robots.txt", (HttpContext ctx) => app.MapGet("/robots.txt", (HttpContext ctx) =>
{ {
var host = $"{ctx.Request.Scheme}://{ctx.Request.Host}"; var host = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/')
?? $"{ctx.Request.Scheme}://{ctx.Request.Host}";
var body = $"User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\n\nSitemap: {host}/sitemap.xml"; var body = $"User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\n\nSitemap: {host}/sitemap.xml";
ctx.Response.ContentType = "text/plain"; ctx.Response.ContentType = "text/plain";
return ctx.Response.WriteAsync(body); return ctx.Response.WriteAsync(body);
@@ -622,6 +886,20 @@ app.MapGet("/api/seo/stats", async (AppDbContext db) =>
return Results.Ok(new { total, views, topPosts, noMeta }); return Results.Ok(new { total, views, topPosts, noMeta });
}).RequireAuthorization(); }).RequireAuthorization();
// Generic error page — returns 500 with a minimal HTML body so Googlebot
// gets a proper HTTP 500 (not a connection-reset) and retries cleanly.
app.Map("/error", (HttpContext ctx) =>
{
ctx.Response.StatusCode = 500;
ctx.Response.ContentType = "text/html; charset=utf-8";
return ctx.Response.WriteAsync(
"<!DOCTYPE html><html lang='fa'><head><meta charset='utf-8'>" +
"<title>خطای سرور</title></head><body dir='rtl'>" +
"<h1>خطای موقت سرور</h1>" +
"<p>مشکلی پیش آمده. لطفاً دقایقی دیگر مجدداً تلاش کنید.</p>" +
"</body></html>");
});
app.MapRazorPages(); app.MapRazorPages();
app.Run(); app.Run();
return 0; return 0;
@@ -639,6 +917,15 @@ static string Slugify(string text)
return text.Trim('-'); return text.Trim('-');
} }
static string GenerateTrackingCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
var bytes = new byte[6];
rng.GetBytes(bytes);
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
}
static int EstimateReadingTime(string content) static int EstimateReadingTime(string content)
{ {
if (string.IsNullOrEmpty(content)) return 1; if (string.IsNullOrEmpty(content)) return 1;
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,8 +1,10 @@
name: drsousan # Lock project name — prevents runner workspace from overriding it
services: services:
# ── .NET API + Razor Pages + Static Files ──────────────────────────────────── # ── .NET API + Razor Pages + Static Files ────────────────────────────────────
api: api:
image: mirrors.soroushasadi.com/drsousan/api:${API_TAG:-latest} image: mirror.soroushasadi.com/drsousan/api:${API_TAG:-latest}
build: build:
context: ./DrSousan.Api context: ./DrSousan.Api
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -21,6 +23,7 @@ services:
Admin__Username: "${ADMIN_USERNAME:-admin}" Admin__Username: "${ADMIN_USERNAME:-admin}"
Admin__Password: "${ADMIN_PASSWORD:-admin123}" Admin__Password: "${ADMIN_PASSWORD:-admin123}"
ASPNETCORE_ENVIRONMENT: "Production" ASPNETCORE_ENVIRONMENT: "Production"
SITE_BASE_URL: "${SITE_BASE_URL:-}"
volumes: volumes:
db_data: db_data:
+106 -63
View File
@@ -609,6 +609,56 @@
.gallery-item:hover .gallery-item-overlay { background: rgba(184,149,90,0.15); } .gallery-item:hover .gallery-item-overlay { background: rgba(184,149,90,0.15); }
/* Before/After split layout */
.gallery-item.before-after {
display: flex;
flex-direction: row;
}
.gallery-item.before-after .ba-half {
flex: 1;
position: relative;
overflow: hidden;
}
.gallery-item.before-after .ba-half img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.4s;
}
.gallery-item.before-after:hover .ba-half img { transform: scale(1.05); }
.gallery-item.before-after .ba-label {
position: absolute;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 20px;
white-space: nowrap;
pointer-events: none;
}
.gallery-item.before-after .ba-divider {
width: 2px;
background: rgba(255,255,255,0.7);
position: relative;
z-index: 2;
flex-shrink: 0;
}
.gallery-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.5));
color: #fff;
font-size: 0.75rem;
padding: 1.2rem 0.8rem 0.5rem;
text-align: center;
}
/* ─── Testimonials ──────────────────────────────────────────── */ /* ─── Testimonials ──────────────────────────────────────────── */
#testimonials { background: var(--section-bg); } #testimonials { background: var(--section-bg); }
@@ -921,7 +971,7 @@
<div class="hero-inner"> <div class="hero-inner">
<div class="hero-text"> <div class="hero-text">
<span class="hero-tag">پزشک عمومی و متخصص زیبایی پوست</span> <span class="hero-tag">پزشک عمومی و متخصص زیبایی پوست</span>
<h1 class="hero-name">دکتر <span>سوسن</span><br>آل‌طه</h1> <h1 class="hero-name">دکتر <span>سوسن</span> آل‌طه</h1>
<p class="hero-subtitle"> <p class="hero-subtitle">
با بیش از یک دهه تجربه در حوزه‌ی زیبایی و مراقبت از پوست،<br> با بیش از یک دهه تجربه در حوزه‌ی زیبایی و مراقبت از پوست،<br>
زیبایی واقعی شما را با علم و هنر همراه می‌کنیم. زیبایی واقعی شما را با علم و هنر همراه می‌کنیم.
@@ -1145,68 +1195,8 @@
<button class="tab-btn">پاکسازی</button> <button class="tab-btn">پاکسازی</button>
</div> </div>
<div class="gallery-grid"> <div class="gallery-grid" id="galleryGrid">
<!-- Replace placeholders with actual before/after images --> <!-- Loaded dynamically from API -->
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -1426,11 +1416,64 @@
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el)); document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
// Gallery — load from API
let allGalleryItems = [];
function renderGallery(items) {
const grid = document.getElementById('galleryGrid');
if (!items.length) {
grid.innerHTML = '<p style="color:var(--mid);text-align:center;grid-column:1/-1;padding:2rem">تصویری یافت نشد.</p>';
return;
}
grid.innerHTML = items.map(g => {
const hasBoth = g.beforeImageUrl && g.afterImageUrl;
const hasImg = g.imageUrl;
if (hasBoth) {
return `<div class="gallery-item before-after fade-in" data-cat="${g.category||''}">
<div class="ba-half">
<img src="${g.beforeImageUrl}" alt="قبل" loading="lazy"/>
<span class="ba-label">قبل</span>
</div>
<div class="ba-divider"></div>
<div class="ba-half">
<img src="${g.afterImageUrl}" alt="بعد" loading="lazy"/>
<span class="ba-label">بعد</span>
</div>
${g.caption ? `<div class="gallery-caption">${g.caption}</div>` : ''}
<div class="gallery-item-overlay"></div>
</div>`;
}
if (hasImg) {
return `<div class="gallery-item fade-in" data-cat="${g.category||''}">
<img src="${g.imageUrl}" alt="${g.caption||'گالری'}" loading="lazy"/>
${g.caption ? `<div class="gallery-caption">${g.caption}</div>` : ''}
<div class="gallery-item-overlay"></div>
</div>`;
}
return '';
}).join('');
grid.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
}
async function loadGallery(category) {
try {
const url = category && category !== 'همه' ? `/api/gallery?category=${encodeURIComponent(category)}` : '/api/gallery';
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
allGalleryItems = data;
renderGallery(data);
} catch(e) { /* silent — placeholders stay hidden */ }
}
loadGallery();
// Tab buttons // Tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
loadGallery(btn.textContent.trim());
}); });
}); });