Compare commits
36 Commits
deb37f6935
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9d8cdc1b | |||
| f3701c5893 | |||
| 00a138fe46 | |||
| 5a1f1a8ccb | |||
| 82d9720e25 | |||
| 99a54be3ac | |||
| 9c93b4e51a | |||
| 872e5c1818 | |||
| 427de7c0cb | |||
| 0c4315063e | |||
| 21769deda6 | |||
| e6fe943217 | |||
| 5ae6bb03a2 | |||
| d02a5963cf | |||
| 22d0ecb330 | |||
| 1e51df406b | |||
| 5d6a4a630d | |||
| e79ccf7e8c | |||
| 81838f75ce | |||
| 772df0698c | |||
| e73d47a875 | |||
| b3c4615bc7 | |||
| ed25bec200 | |||
| 3780dcccf2 | |||
| 0765d5d3cd | |||
| 60141b78f0 | |||
| b3467fb663 | |||
| 6f39e47aaa | |||
| e4ad440c15 | |||
| dd5afde5df | |||
| 7f5444085b | |||
| f034f70ae3 | |||
| 8fa3131344 | |||
| 14f902cdad | |||
| d5bb724b3f | |||
| 56f1311b3b |
@@ -56,7 +56,7 @@ jobs:
|
||||
working-directory: DrSousan.Api
|
||||
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:
|
||||
name: "Deploy · drsousan"
|
||||
runs-on: self-hosted
|
||||
@@ -90,8 +90,27 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
|
||||
- name: Backup database
|
||||
run: |
|
||||
BACKUP_DIR="/opt/drsousan-backups"
|
||||
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
|
||||
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
|
||||
run: |
|
||||
@@ -109,6 +128,12 @@ jobs:
|
||||
if: always()
|
||||
run: docker compose ps
|
||||
|
||||
- name: Prune old images
|
||||
- name: Prune old drsousan images
|
||||
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
|
||||
|
||||
@@ -12,7 +12,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
public DbSet<BlogCategory> BlogCategories => Set<BlogCategory>();
|
||||
public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ── 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
|
||||
|
||||
# Restore dependencies first (layer-cache friendly)
|
||||
@@ -19,7 +19,7 @@ RUN dotnet publish DrSousan.Api.csproj \
|
||||
--no-restore
|
||||
|
||||
# ── 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
|
||||
|
||||
# Create directories for persistent volumes and set ownership
|
||||
@@ -37,8 +37,7 @@ VOLUME ["/data", "/app/wwwroot/uploads"]
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
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 \
|
||||
CMD ["dotnet", "DrSousan.Api.dll", "--healthcheck"]
|
||||
CMD bash -c 'echo > /dev/tcp/localhost/8080' || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "DrSousan.Api.dll"]
|
||||
|
||||
@@ -130,8 +130,64 @@ public class Faq
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public record DoctorReplyDto(string? Diagnosis, string? DoctorReply);
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
public record SettingDto(string Key, string Value);
|
||||
public record BulkSettingsDto(Dictionary<string, string> Settings);
|
||||
|
||||
@@ -1,10 +1,58 @@
|
||||
@page "/blog"
|
||||
@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 {
|
||||
<title>@ViewData["Title"]</title>
|
||||
<meta name="description" content="مقالات تخصصی دکتر سوسن آلطه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست." />
|
||||
<link rel="canonical" href="@(Request.Scheme + "://" + Request.Host + "/blog")" />
|
||||
<meta name="description" content="@blogDesc" />
|
||||
<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>
|
||||
/* ─── Blog Hero ─────────────────────────────────────────────── */
|
||||
.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">
|
||||
@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++)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ public class BlogIndexModel : PageModel
|
||||
public int TotalPosts { get; private set; } = 0;
|
||||
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;
|
||||
|
||||
var q = _db.BlogPosts.Include(p => p.Category).Where(p => p.IsPublished);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
@model DrSousan.Api.Pages.Blog.PostModel
|
||||
@{
|
||||
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 ogImage = ViewData["OgImage"]?.ToString() ?? "";
|
||||
var articleType = ViewData["ArticleType"]?.ToString() ?? "MedicalWebPage";
|
||||
var pubDate = post.PublishedAt?.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 {
|
||||
@@ -16,29 +18,40 @@
|
||||
@if (!string.IsNullOrEmpty(ViewData["Keywords"]?.ToString())) {
|
||||
<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:site_name" content="@ViewData["SiteName"]" />
|
||||
<meta property="og:title" content="@ViewData["Title"]" />
|
||||
<meta property="og:description" content="@ViewData["MetaDesc"]" />
|
||||
<meta property="og:url" content="@canonicalUrl" />
|
||||
<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)) {
|
||||
<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:title" content="@ViewData["Title"]" />
|
||||
<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" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "@articleType",
|
||||
"headline": "@post.Title.Replace("\"","\\\"") ",
|
||||
"description": "@(ViewData["MetaDesc"]?.ToString()?.Replace("\"","\\\""))",
|
||||
"author": { "@@type": "Person", "name": "@post.Author" },
|
||||
"@@type": "@J(articleType)",
|
||||
"headline": "@J(post.Title)",
|
||||
"description": "@J(ViewData["MetaDesc"]?.ToString())",
|
||||
"author": { "@@type": "Person", "name": "@J(post.Author)" },
|
||||
"publisher": {
|
||||
"@@type": "Organization",
|
||||
"name": "@ViewData["SiteName"]",
|
||||
"name": "@J(ViewData["SiteName"]?.ToString())",
|
||||
"url": "@baseUrl"
|
||||
},
|
||||
"datePublished": "@pubDate",
|
||||
@@ -50,6 +63,41 @@
|
||||
}
|
||||
</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>
|
||||
/* ─── 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}
|
||||
@@ -71,6 +119,19 @@
|
||||
.article-content strong{color:var(--dark);font-weight:600}
|
||||
.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 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 ─────────────────────────────────────────────────────── */
|
||||
.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)}
|
||||
@@ -99,7 +160,9 @@
|
||||
.recent-title:hover{color:var(--gold)}
|
||||
.recent-date{font-size:.73rem;color:var(--light);margin-top:.2rem}
|
||||
.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-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}
|
||||
@@ -254,9 +317,18 @@
|
||||
<!-- ── Sidebar ── -->
|
||||
<aside class="sidebar">
|
||||
<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-title">پزشک عمومی | متخصص زیبایی پوست</div>
|
||||
<div class="doc-title">@ViewData["HeroTag"]</div>
|
||||
<a href="/#contact" class="doc-btn">رزرو نوبت</a>
|
||||
</div>
|
||||
|
||||
@@ -288,3 +360,31 @@
|
||||
</div>
|
||||
</aside>
|
||||
</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>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ public class PostModel : PageModel
|
||||
public BlogPost? Post { get; private set; }
|
||||
public List<CommentVm> Comments { get; private set; } = new();
|
||||
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
|
||||
[BindProperty]
|
||||
@@ -45,11 +47,35 @@ public class PostModel : PageModel
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
Post = post;
|
||||
Faqs = ExtractFaqs(post.Content);
|
||||
await LoadCommentsAsync(post.Id);
|
||||
await SetViewDataAsync(post);
|
||||
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)
|
||||
{
|
||||
var post = await _db.BlogPosts
|
||||
@@ -135,9 +161,12 @@ public class PostModel : PageModel
|
||||
ViewData["ArticleType"] = post.ArticleType;
|
||||
ViewData["Slug"] = post.Slug;
|
||||
|
||||
var s = await _db.SiteSettings
|
||||
.FirstOrDefaultAsync(x => x.Section == "hero" && x.Key == "name");
|
||||
ViewData["SiteName"] = s?.Value ?? "دکتر سوسن آلطه";
|
||||
var heroSettings = await _db.SiteSettings
|
||||
.Where(x => x.Section == "hero" && (x.Key == "name" || x.Key == "image" || x.Key == "tag"))
|
||||
.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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
+323
-61
@@ -1,6 +1,7 @@
|
||||
@page
|
||||
@model IndexModel
|
||||
@{
|
||||
var siteBaseUrl = Environment.GetEnvironmentVariable("SITE_BASE_URL")?.TrimEnd('/') ?? "https://draletaha.ir";
|
||||
var h = Model.Hero;
|
||||
var a = Model.About;
|
||||
var c = Model.Contact;
|
||||
@@ -14,30 +15,104 @@
|
||||
var ig = c.GetValueOrDefault("instagram","");
|
||||
var wa = c.GetValueOrDefault("whatsapp","");
|
||||
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 {
|
||||
<title>@ViewData["Title"]</title>
|
||||
<meta name="description" content="@h.GetValueOrDefault("subtitle","")" />
|
||||
<meta name="keywords" content="دکتر پوست تهران,بوتاکس,لیزر موهای زائد,فیلر,مزوتراپی,زیبایی پوست" />
|
||||
<meta name="description" content="@metaDesc" />
|
||||
<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:site_name" content="@siteName" />
|
||||
<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" />
|
||||
<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">
|
||||
{
|
||||
"@@context":"https://schema.org",
|
||||
"@@type":["MedicalBusiness","LocalBusiness"],
|
||||
"@@id":"@(siteBaseUrl)/#business",
|
||||
"name":"@siteName",
|
||||
"description":"@h.GetValueOrDefault("subtitle","")",
|
||||
"url":"@(Request.Scheme + "://" + Request.Host)",
|
||||
"telephone":"@c.GetValueOrDefault("phone","")",
|
||||
"address":{"@@type":"PostalAddress","addressLocality":"تهران","addressCountry":"IR","streetAddress":"@c.GetValueOrDefault("address","")"},
|
||||
"description":"@metaDesc",
|
||||
"url":"@(siteBaseUrl)",
|
||||
"telephone":"@phone",
|
||||
"priceRange":"$$",
|
||||
"image":"@(string.IsNullOrEmpty(absHero) ? siteBaseUrl : absHero)",
|
||||
"address":{"@@type":"PostalAddress","addressLocality":"تهران","addressRegion":"تهران","addressCountry":"IR","streetAddress":"@address"},
|
||||
"areaServed":{"@@type":"City","name":"تهران"},
|
||||
"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>
|
||||
|
||||
<!-- 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>
|
||||
/* ─── Hero ─────────────────────────────────────────────────── */
|
||||
#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.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-item { border-radius:16px; overflow:hidden; aspect-ratio:4/3; background:linear-gradient(135deg, var(--gold-pale), #EDE0CA); position:relative; cursor:pointer; }
|
||||
.gallery-item img { width:100%; height:100%; object-fit:cover; transition:transform 0.4s; }
|
||||
.gallery-item:hover img { transform:scale(1.05); }
|
||||
.gallery-item { border-radius:16px; overflow:hidden; background:var(--white); border:1px solid var(--border); cursor:pointer; display:flex; flex-direction:column; }
|
||||
.gallery-img-wrap { aspect-ratio:4/3; position:relative; overflow:hidden; background:linear-gradient(135deg, var(--gold-pale), #EDE0CA); flex-shrink:0; }
|
||||
.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 svg { width:36px; height:36px; }
|
||||
.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; }
|
||||
.gallery-item:hover .gallery-item-overlay { background:rgba(184,149,90,0.15); }
|
||||
/* Before/After layout */
|
||||
.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 { background:var(--section-bg); }
|
||||
.testimonials-header { text-align:center; margin-bottom:3rem; }
|
||||
@@ -158,6 +247,29 @@
|
||||
.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); }
|
||||
/* ─── 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-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; }
|
||||
@@ -181,6 +293,16 @@
|
||||
.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: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 ───────────────────────────────────────────── */
|
||||
@@media (max-width:900px) {
|
||||
.hero-inner { grid-template-columns:1fr; text-align:center; gap:2.5rem; }
|
||||
@@ -200,6 +322,7 @@
|
||||
@@media (max-width:600px) {
|
||||
section { padding:3.5rem 0; }
|
||||
.container { padding:0 1.2rem; }
|
||||
.hero-name { white-space:normal; }
|
||||
.hero-inner { padding:0 1.2rem; gap:2rem; }
|
||||
.hero-image { max-width:260px; }
|
||||
.hero-stats { gap:1.5rem; }
|
||||
@@ -213,6 +336,7 @@
|
||||
.about-image-wrap { text-align:center; }
|
||||
.testimonials-grid { grid-template-columns:1fr; }
|
||||
.section-title { font-size:1.5rem; }
|
||||
.health-cats { grid-template-columns:1fr; }
|
||||
.faq-item summary { font-size:.9rem; padding:1rem 1.2rem; }
|
||||
.faq-answer { padding:0 1.2rem 1rem; font-size:.88rem; }
|
||||
}
|
||||
@@ -255,7 +379,7 @@
|
||||
<div class="hero-image-frame">
|
||||
@if (!string.IsNullOrEmpty(heroImg))
|
||||
{
|
||||
<img src="@heroImg" alt="@siteName" />
|
||||
<img src="@heroImg" alt="@siteName — متخصص زیبایی پوست در تهران" width="640" height="800" fetchpriority="high" decoding="async" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -294,7 +418,7 @@
|
||||
<div class="about-img-box">
|
||||
@if (!string.IsNullOrEmpty(aboutImg))
|
||||
{
|
||||
<img src="@aboutImg" alt="@siteName" />
|
||||
<img src="@aboutImg" alt="درباره @siteName — پزشک و متخصص زیبایی پوست" width="600" height="800" loading="lazy" decoding="async" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -405,35 +529,43 @@
|
||||
<div class="divider"></div>
|
||||
<p class="section-desc">نمونهای از نتایج فوقالعاده درمانهای انجامشده توسط دکتر آلطه.</p>
|
||||
</div>
|
||||
<div class="gallery-tabs fade-in">
|
||||
<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">
|
||||
<div class="gallery-grid" id="galleryGrid">
|
||||
@if (Model.Gallery.Any())
|
||||
{
|
||||
@foreach (var item in Model.Gallery)
|
||||
{
|
||||
<div class="gallery-item fade-in">
|
||||
@if (!string.IsNullOrEmpty(item.ImageUrl))
|
||||
{
|
||||
<img src="@item.ImageUrl" alt="@item.Caption" loading="lazy" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
var hasBoth = !string.IsNullOrEmpty(item.BeforeImageUrl) && !string.IsNullOrEmpty(item.AfterImageUrl);
|
||||
var hasImg = !string.IsNullOrEmpty(item.ImageUrl);
|
||||
if (hasBoth)
|
||||
{
|
||||
<div class="gallery-item before-after fade-in" data-cat="@item.Category">
|
||||
<div class="gallery-img-wrap">
|
||||
<div class="ba-half">
|
||||
<img src="@item.BeforeImageUrl" alt="قبل از درمان" loading="lazy"/>
|
||||
</div>
|
||||
<div class="ba-divider"></div>
|
||||
<div class="ba-half">
|
||||
<img src="@item.AfterImageUrl" alt="بعد از درمان" loading="lazy"/>
|
||||
</div>
|
||||
<div class="gallery-item-overlay"></div>
|
||||
</div>
|
||||
}
|
||||
<div class="gallery-item-overlay"></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
|
||||
@@ -453,6 +585,12 @@
|
||||
}
|
||||
}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -552,6 +690,54 @@
|
||||
</div>
|
||||
</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 ══════ -->
|
||||
<section id="contact">
|
||||
<div class="container">
|
||||
@@ -572,7 +758,7 @@
|
||||
</div>
|
||||
<div class="info-text">
|
||||
<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>
|
||||
}
|
||||
@@ -587,7 +773,7 @@
|
||||
</div>
|
||||
<div class="info-text">
|
||||
<strong>ایمیل</strong>
|
||||
<p>@c.GetValueOrDefault("email","")</p>
|
||||
<p><a href="mailto:@email" style="color:inherit">@email</a></p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -602,7 +788,7 @@
|
||||
</div>
|
||||
<div class="info-text">
|
||||
<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>
|
||||
}
|
||||
@@ -656,43 +842,62 @@
|
||||
<div class="contact-form fade-in">
|
||||
<div class="form-title">رزرو نوبت آنلاین</div>
|
||||
<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-group">
|
||||
<label>نام</label>
|
||||
<input type="text" placeholder="نام شما" required />
|
||||
<input id="booking-firstname" type="text" placeholder="نام شما" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>نام خانوادگی</label>
|
||||
<input type="text" placeholder="نام خانوادگی" required />
|
||||
<input id="booking-lastname" type="text" placeholder="نام خانوادگی" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>شماره موبایل</label>
|
||||
<input type="tel" placeholder="09XX-XXX-XXXX" required />
|
||||
<input id="booking-phone" type="tel" placeholder="09XX-XXX-XXXX" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>نوع خدمت مورد نظر</label>
|
||||
<select>
|
||||
<select id="booking-service" onchange="document.getElementById('booking-category').value=this.value==='سلامت عمومی'?'health':'beauty'">
|
||||
<option value="" disabled selected>انتخاب خدمت</option>
|
||||
@foreach (var svc in Model.Services)
|
||||
{
|
||||
<option>@svc.Title</option>
|
||||
}
|
||||
<option value="سلامت عمومی">سلامت عمومی</option>
|
||||
<option>سایر</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>توضیحات (اختیاری)</label>
|
||||
<textarea placeholder="مشکل پوستی یا سوالات خود را اینجا بنویسید..."></textarea>
|
||||
<textarea id="booking-message" placeholder="مشکل پوستی، سوال یا درخواست خود را بنویسید..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="form-submit">ارسال و رزرو نوبت</button>
|
||||
<button type="submit" class="form-submit" id="booking-submit">ارسال و رزرو نوبت</button>
|
||||
</form>
|
||||
<div id="booking-tracking" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 {
|
||||
<script>
|
||||
// Before/After toggle
|
||||
@@ -703,25 +908,82 @@
|
||||
btns[1].classList.toggle('active');
|
||||
}
|
||||
|
||||
// Tab buttons
|
||||
// Tab buttons — filter gallery by category
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('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
|
||||
function handleSubmit(e) {
|
||||
// Main booking form — submits to /api/health-request
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('.form-submit');
|
||||
btn.textContent = '✓ درخواست شما ثبت شد';
|
||||
btn.style.background = '#2D7A4F';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'ارسال و رزرو نوبت';
|
||||
btn.style.background = '';
|
||||
e.target.reset();
|
||||
}, 3000);
|
||||
const btn = document.getElementById('booking-submit');
|
||||
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';
|
||||
// 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(() => {
|
||||
btn.textContent = 'ارسال و رزرو نوبت';
|
||||
btn.style.background = '';
|
||||
btn.disabled = false;
|
||||
e.target.reset();
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@ public class IndexModel : PageModel
|
||||
// Collections
|
||||
public List<Service> Services { 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<BlogPost> RecentPosts { get; private set; } = new();
|
||||
public List<Faq> Faqs { get; private set; } = new();
|
||||
@@ -39,9 +40,12 @@ public class IndexModel : PageModel
|
||||
.OrderBy(s => s.Order)
|
||||
.ToListAsync();
|
||||
|
||||
Gallery = await _db.GalleryItems
|
||||
.Where(g => g.IsActive)
|
||||
// Homepage shows only a teaser of 3; full set lives on /gallery
|
||||
var galleryQuery = _db.GalleryItems.Where(g => g.IsActive);
|
||||
GalleryTotal = await galleryQuery.CountAsync();
|
||||
Gallery = await galleryQuery
|
||||
.OrderBy(g => g.Order)
|
||||
.Take(3)
|
||||
.ToListAsync();
|
||||
|
||||
Testimonials = await _db.Testimonials
|
||||
|
||||
@@ -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>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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)
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
@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" />
|
||||
}
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
@@ -122,7 +151,14 @@
|
||||
<body>
|
||||
|
||||
<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>
|
||||
<a href="/#about">درباره من</a>
|
||||
<a href="/#services">خدمات</a>
|
||||
|
||||
+291
-4
@@ -1,6 +1,8 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Unicode;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -49,16 +51,63 @@ builder.Services.AddCors(opt =>
|
||||
|
||||
// Razor Pages for SSR public pages
|
||||
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+json" and
|
||||
// Persian text into \XX; entities — valid but bloated and trips some validators.
|
||||
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
|
||||
|
||||
// Fix circular JSON references (BlogPost ↔ BlogCategory)
|
||||
builder.Services.ConfigureHttpJsonOptions(opts =>
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
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.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.UseAuthorization();
|
||||
|
||||
@@ -89,6 +138,75 @@ await using (var scope = app.Services.CreateAsyncScope())
|
||||
""");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -535,7 +653,7 @@ app.MapPost("/api/upload", async (HttpRequest request, IWebHostEnvironment env)
|
||||
if (!request.HasFormContentType || !request.Form.Files.Any())
|
||||
return Results.BadRequest("No file provided.");
|
||||
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();
|
||||
if (!allowed.Contains(ext)) return Results.BadRequest("File type not allowed.");
|
||||
var uploadsDir = Path.Combine(env.WebRootPath, "uploads");
|
||||
@@ -566,10 +684,154 @@ app.MapDelete("/api/upload/{filename}", (string filename, IWebHostEnvironment en
|
||||
return Results.NoContent();
|
||||
}).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 ───────────────────────────────────────────────────────────────────
|
||||
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)
|
||||
.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 + "/gallery", "0.8", "weekly", DateTime.UtcNow);
|
||||
Url(baseUrl + "/blog", "0.9", "daily", DateTime.UtcNow);
|
||||
foreach (var p in published)
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
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";
|
||||
ctx.Response.ContentType = "text/plain";
|
||||
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 });
|
||||
}).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.Run();
|
||||
return 0;
|
||||
@@ -639,6 +917,15 @@ static string Slugify(string text)
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content)) return 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,8 +1,10 @@
|
||||
name: drsousan # Lock project name — prevents runner workspace from overriding it
|
||||
|
||||
services:
|
||||
|
||||
# ── .NET API + Razor Pages + Static Files ────────────────────────────────────
|
||||
api:
|
||||
image: mirrors.soroushasadi.com/drsousan/api:${API_TAG:-latest}
|
||||
image: mirror.soroushasadi.com/drsousan/api:${API_TAG:-latest}
|
||||
build:
|
||||
context: ./DrSousan.Api
|
||||
dockerfile: Dockerfile
|
||||
@@ -21,6 +23,7 @@ services:
|
||||
Admin__Username: "${ADMIN_USERNAME:-admin}"
|
||||
Admin__Password: "${ADMIN_PASSWORD:-admin123}"
|
||||
ASPNETCORE_ENVIRONMENT: "Production"
|
||||
SITE_BASE_URL: "${SITE_BASE_URL:-}"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
+106
-63
@@ -609,6 +609,56 @@
|
||||
|
||||
.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 { background: var(--section-bg); }
|
||||
|
||||
@@ -921,7 +971,7 @@
|
||||
<div class="hero-inner">
|
||||
<div class="hero-text">
|
||||
<span class="hero-tag">پزشک عمومی و متخصص زیبایی پوست</span>
|
||||
<h1 class="hero-name">دکتر <span>سوسن</span><br>آلطه</h1>
|
||||
<h1 class="hero-name">دکتر <span>سوسن</span> آلطه</h1>
|
||||
<p class="hero-subtitle">
|
||||
با بیش از یک دهه تجربه در حوزهی زیبایی و مراقبت از پوست،<br>
|
||||
زیبایی واقعی شما را با علم و هنر همراه میکنیم.
|
||||
@@ -1145,68 +1195,8 @@
|
||||
<button class="tab-btn">پاکسازی</button>
|
||||
</div>
|
||||
|
||||
<div class="gallery-grid">
|
||||
<!-- Replace placeholders with actual before/after images -->
|
||||
<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 class="gallery-grid" id="galleryGrid">
|
||||
<!-- Loaded dynamically from API -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1426,11 +1416,64 @@
|
||||
|
||||
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
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
loadGallery(btn.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user