82 changed files with 55341 additions and 2862 deletions
-237
View File
@@ -15,119 +15,6 @@ POSITION_TABLES = [
"offices", "gallery_images", "nav_items", "offices", "gallery_images", "nav_items",
] ]
# ستون‌های انگلیسی — (جدول، ستون، نوع). ستون‌های JSON بدون default اند تا
# سطرهای موجود NULL بگیرند (یعنی «ترجمه ثبت نشده») و فرانت به فارسی برگردد.
EN_COLUMNS: list[tuple[str, str, str]] = [
("brand", "name_en", "VARCHAR(128)"),
("brand", "address_en", "TEXT"),
("services", "title_en", "VARCHAR(256)"),
("services", "desc_en", "TEXT"),
("projects", "title_en", "VARCHAR(256)"),
("projects", "location_en", "VARCHAR(128)"),
("projects", "year_en", "VARCHAR(64)"),
("projects", "area_en", "VARCHAR(64)"),
("projects", "floors_en", "VARCHAR(64)"),
("projects", "units_en", "VARCHAR(64)"),
("projects", "client_en", "VARCHAR(128)"),
("projects", "summary_en", "TEXT"),
("projects", "body_en", "JSON"),
("articles", "title_en", "VARCHAR(256)"),
("articles", "date_en", "VARCHAR(64)"),
("articles", "category_en", "VARCHAR(64)"),
("articles", "read_en", "VARCHAR(32)"),
("articles", "excerpt_en", "TEXT"),
("articles", "body_en", "JSON"),
("testimonials", "name_en", "VARCHAR(128)"),
("testimonials", "role_en", "VARCHAR(128)"),
("testimonials", "quote_en", "TEXT"),
("faqs", "question_en", "TEXT"),
("faqs", "answer_en", "TEXT"),
("team", "name_en", "VARCHAR(128)"),
("team", "role_en", "VARCHAR(128)"),
("team", "bio_en", "TEXT"),
("timeline", "year_en", "VARCHAR(32)"),
("timeline", "title_en", "VARCHAR(256)"),
("timeline", "desc_en", "TEXT"),
("stats", "suffix_en", "VARCHAR(16)"),
("stats", "label_en", "VARCHAR(128)"),
("why_us", "title_en", "VARCHAR(256)"),
("why_us", "desc_en", "TEXT"),
("before_after", "title_en", "VARCHAR(256)"),
("before_after", "lead_en", "TEXT"),
("before_after", "before_label_en", "VARCHAR(128)"),
("before_after", "after_label_en", "VARCHAR(128)"),
("nav_items", "label_en", "VARCHAR(64)"),
("hero", "kicker_en", "TEXT"),
("hero", "title_en", "TEXT"),
("hero", "title_highlight_en", "VARCHAR(128)"),
("hero", "subtitle_en", "TEXT"),
("hero", "badge_value_en", "VARCHAR(32)"),
("hero", "badge_label_en", "TEXT"),
("hero", "cta_primary_label_en", "VARCHAR(128)"),
("hero", "cta_secondary_label_en", "VARCHAR(128)"),
("hero", "scroll_label_en", "VARCHAR(64)"),
("story", "title_en", "VARCHAR(256)"),
("story", "paragraphs_en", "JSON"),
("story", "quote_en", "TEXT"),
("story", "quote_author_en", "TEXT"),
("story", "stat1_value_en", "VARCHAR(32)"),
("story", "stat1_label_en", "VARCHAR(128)"),
("story", "stat2_value_en", "VARCHAR(32)"),
("story", "stat2_label_en", "VARCHAR(128)"),
("story", "link_label_en", "VARCHAR(128)"),
("story", "bento_en", "JSON"),
("section_headings", "label_en", "VARCHAR(64)"),
("section_headings", "title_en", "VARCHAR(256)"),
("section_headings", "lead_en", "TEXT"),
("section_headings", "cta_label_en", "VARCHAR(128)"),
("page_heroes", "title_en", "VARCHAR(256)"),
("page_heroes", "lead_en", "TEXT"),
("investment_models", "title_en", "VARCHAR(256)"),
("investment_models", "desc_en", "TEXT"),
("investment_models", "points_en", "JSON"),
("process_steps", "step_en", "VARCHAR(16)"),
("process_steps", "title_en", "VARCHAR(256)"),
("process_steps", "desc_en", "TEXT"),
("offices", "city_en", "VARCHAR(64)"),
("offices", "address_en", "TEXT"),
("offices", "hours_en", "VARCHAR(128)"),
("gallery_images", "alt_en", "TEXT"),
("status_labels", "label_en", "VARCHAR(64)"),
("contact_section", "label_en", "VARCHAR(64)"),
("contact_section", "title_en", "VARCHAR(256)"),
("contact_section", "lead_en", "TEXT"),
("contact_section", "address_label_en", "VARCHAR(64)"),
("contact_section", "phone_label_en", "VARCHAR(64)"),
("contact_section", "email_label_en", "VARCHAR(64)"),
("contact_section", "name_label_en", "VARCHAR(64)"),
("contact_section", "name_placeholder_en", "VARCHAR(128)"),
("contact_section", "phone_field_label_en", "VARCHAR(64)"),
("contact_section", "phone_placeholder_en", "VARCHAR(64)"),
("contact_section", "note_label_en", "VARCHAR(64)"),
("contact_section", "note_optional_label_en", "VARCHAR(64)"),
("contact_section", "note_placeholder_en", "VARCHAR(256)"),
("contact_section", "submit_label_en", "VARCHAR(128)"),
("contact_section", "sending_label_en", "VARCHAR(64)"),
("contact_section", "success_title_en", "VARCHAR(256)"),
("contact_section", "success_text_en", "TEXT"),
("contact_section", "success_button_label_en", "VARCHAR(128)"),
("newsletter", "title_en", "VARCHAR(256)"),
("newsletter", "lead_en", "TEXT"),
("newsletter", "placeholder_en", "VARCHAR(128)"),
("newsletter", "button_label_en", "VARCHAR(64)"),
("newsletter", "sending_label_en", "VARCHAR(64)"),
("newsletter", "success_text_en", "VARCHAR(256)"),
("footer", "about_text_en", "TEXT"),
("footer", "pages_heading_en", "VARCHAR(64)"),
("footer", "services_heading_en", "VARCHAR(64)"),
("footer", "contact_heading_en", "VARCHAR(64)"),
("footer", "copyright_en", "VARCHAR(256)"),
("footer", "tagline_en", "VARCHAR(256)"),
("footer", "socials_en", "JSON"),
("seo_meta", "title_en", "VARCHAR(256)"),
("seo_meta", "description_en", "TEXT"),
]
def run_ddl_migrations() -> None: def run_ddl_migrations() -> None:
"""ستون‌های جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS.""" """ستون‌های جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
@@ -145,14 +32,6 @@ def run_ddl_migrations() -> None:
conn.execute( conn.execute(
text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
) )
# ستون‌های انگلیسی — رشته‌ها با پیش‌فرض خالی، JSON بدون پیش‌فرض (NULL = بدون ترجمه)
for table, column, col_type in EN_COLUMNS:
if col_type == "JSON":
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} JSON"))
else:
conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {column} {col_type} NOT NULL DEFAULT ''")
)
def paragraphs_to_doc(paragraphs: list) -> dict: def paragraphs_to_doc(paragraphs: list) -> dict:
@@ -187,122 +66,6 @@ def migrate_article_bodies() -> None:
db.close() db.close()
def _is_empty(value: object) -> bool:
"""مقدار خالی انگلیسی — یعنی هنوز ترجمه‌ای ثبت نشده و قابل پر کردن است."""
if value is None or value == "" or value == [] or value == {}:
return True
if isinstance(value, dict) and value.get("content") == [{"type": "paragraph"}]:
return True
return False
def _backfill_row(row, en_fields: dict) -> int:
"""پر کردن فیلدهای خالی *_en یک سطر از دیکشنری ترجمه — تعداد فیلدهای پرشده."""
filled = 0
for key, value in en_fields.items():
if not key.endswith("_en"):
continue
if not _is_empty(getattr(row, key, None)):
continue
setattr(row, key, value)
filled += 1
return filled
def backfill_en_content() -> None:
"""پر کردن ترجمه‌های انگلیسی سطرهای موجود از seed_data_en — idempotent.
تطبیق سطرها: جدول‌های تک‌سطری مستقیم؛ بقیه با کلیدهای طبیعی/متنی فارسی
(مثلاً slug پروژه، href منو، عنوان خدمت، متن پرسش) — اگر ادمین متن فارسی را
ویرایش کرده باشد سطر تطبیق نمی‌خورد و ترجمه‌اش خالی می‌ماند تا از پنل پر شود.
هیچ مقدار انگلیسیِ ثبت‌شده بازنویسی نمی‌شود.
"""
from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقه‌ای
# (مدل، فهرست فارسی، فهرست انگلیسی، کلیدهای تطبیق — خالی برای تک‌سطری‌ها)
PLAN: list[tuple[object, list[dict], list[dict], tuple[str, ...]]] = [
(models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], ()),
(models.Hero, [seed_data.HERO], [seed_data_en.HERO], ()),
(models.StorySection, [seed_data.STORY], [seed_data_en.STORY], ()),
(models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, ("key", "title")),
(models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, ("key", "title")),
(models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, ("href", "label")),
(models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, ("title",)),
(models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, ("title",)),
(models.Office, seed_data.OFFICES, seed_data_en.OFFICES, ("city",)),
(models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, ("alt",)),
(models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, ("key",)),
(models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], ()),
(models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], ()),
(models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], ()),
(models.Service, seed_data.SERVICES, seed_data_en.SERVICES, ("title",)),
(models.Stat, seed_data.STATS, seed_data_en.STATS, ("label",)),
(models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, ("slug", "title")),
(models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, ("slug", "title")),
(models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, ("name",)),
(models.Faq, seed_data.FAQS, seed_data_en.FAQS, ("question",)),
(models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, ("name",)),
(models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, ("title",)),
(models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, ("title",)),
(models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], ()),
(models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, ("page_key",)),
]
db = SessionLocal()
try:
total_rows = 0
total_fields = 0
unmatched: list[str] = []
for model, fa_rows, en_rows, match_keys in PLAN:
rows = db.query(model).all()
if not rows or not en_rows:
continue
table_name = getattr(model, "__tablename__", str(model))
if not match_keys:
if len(rows) != 1 or len(en_rows) != 1:
unmatched.append(table_name)
continue
pairs = [(rows[0], en_rows[0])]
else:
# برای هر کلید، نگاشت مقدار → سطر ترجمه (اولین کلید برنده است)
en_lookups: list[dict[object, dict]] = []
for key in match_keys:
en_lookups.append({r[key]: r for r in en_rows if r.get(key) is not None})
pairs = []
matched_en_ids: set[int] = set()
for row in rows:
en_row = None
for key, lookup in zip(match_keys, en_lookups):
en_row = lookup.get(getattr(row, key, None))
if en_row is not None:
break
if en_row is not None and id(en_row) not in matched_en_ids:
matched_en_ids.add(id(en_row))
pairs.append((row, en_row))
for row, en_row in pairs:
# بدنه مقاله انگلیسی هم مثل فارسی از پاراگراف به سند تبدیل می‌شود
en_fields = dict(en_row)
if model is models.Article and isinstance(en_fields.get("body_en"), list):
en_fields["body_en"] = paragraphs_to_doc(en_fields["body_en"])
filled = _backfill_row(row, en_fields)
total_fields += filled
if filled:
total_rows += 1
db.commit()
if total_fields:
print(f"ترجمه انگلیسی پر شد: {total_fields} فیلد در {total_rows} سطر.")
if unmatched:
print("سطرهایی که با seed تطبیق نکردند (از پنل مدیریت ترجمه کنید):")
for name in unmatched:
print(f" - {name}")
finally:
db.close()
def run_all() -> None: def run_all() -> None:
run_ddl_migrations() run_ddl_migrations()
migrate_article_bodies() migrate_article_bodies()
backfill_en_content()
+1 -113
View File
@@ -10,10 +10,6 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
# ستون‌های *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
# مقدار خالی یعنی «ترجمه‌ای ثبت نشده» و فرانت‌اند به نسخه فارسی برمی‌گردد.
class AdminUser(Base): class AdminUser(Base):
__tablename__ = "admin_users" __tablename__ = "admin_users"
@@ -27,7 +23,6 @@ class Brand(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), default="") name: Mapped[str] = mapped_column(String(128), default="")
name_en: Mapped[str] = mapped_column(String(128), default="")
logo: Mapped[str] = mapped_column(Text, default="") logo: Mapped[str] = mapped_column(Text, default="")
hero: Mapped[str] = mapped_column(Text, default="") hero: Mapped[str] = mapped_column(Text, default="")
contact_bg: Mapped[str] = mapped_column(Text, default="") contact_bg: Mapped[str] = mapped_column(Text, default="")
@@ -35,7 +30,6 @@ class Brand(Base):
phone_href: Mapped[str] = mapped_column(String(128), default="") phone_href: Mapped[str] = mapped_column(String(128), default="")
email: Mapped[str] = mapped_column(String(128), default="") email: Mapped[str] = mapped_column(String(128), default="")
address: Mapped[str] = mapped_column(Text, default="") address: Mapped[str] = mapped_column(Text, default="")
address_en: Mapped[str] = mapped_column(Text, default="")
class Service(Base): class Service(Base):
@@ -45,9 +39,7 @@ class Service(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
icon: Mapped[str] = mapped_column(String(32), default="draft") icon: Mapped[str] = mapped_column(String(32), default="draft")
@@ -59,26 +51,17 @@ class Project(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
location: Mapped[str] = mapped_column(String(128), default="") location: Mapped[str] = mapped_column(String(128), default="")
location_en: Mapped[str] = mapped_column(String(128), default="")
year: Mapped[str] = mapped_column(String(64), default="") year: Mapped[str] = mapped_column(String(64), default="")
year_en: Mapped[str] = mapped_column(String(64), default="")
status: Mapped[str] = mapped_column(String(16), default="done") # done | progress status: Mapped[str] = mapped_column(String(16), default="done") # done | progress
image: Mapped[str] = mapped_column(Text, default="") image: Mapped[str] = mapped_column(Text, default="")
gallery: Mapped[list] = mapped_column(JSON, default=list) gallery: Mapped[list] = mapped_column(JSON, default=list)
area: Mapped[str] = mapped_column(String(64), default="") area: Mapped[str] = mapped_column(String(64), default="")
area_en: Mapped[str] = mapped_column(String(64), default="")
floors: Mapped[str] = mapped_column(String(64), default="") floors: Mapped[str] = mapped_column(String(64), default="")
floors_en: Mapped[str] = mapped_column(String(64), default="")
units: Mapped[str] = mapped_column(String(64), default="") units: Mapped[str] = mapped_column(String(64), default="")
units_en: Mapped[str] = mapped_column(String(64), default="")
client: Mapped[str] = mapped_column(String(128), default="") client: Mapped[str] = mapped_column(String(128), default="")
client_en: Mapped[str] = mapped_column(String(128), default="")
summary: Mapped[str] = mapped_column(Text, default="") summary: Mapped[str] = mapped_column(Text, default="")
summary_en: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list) body: Mapped[list] = mapped_column(JSON, default=list)
body_en: Mapped[list] = mapped_column(JSON, default=list)
class Article(Base): class Article(Base):
@@ -89,18 +72,12 @@ class Article(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True) slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
date: Mapped[str] = mapped_column(String(64), default="") date: Mapped[str] = mapped_column(String(64), default="")
date_en: Mapped[str] = mapped_column(String(64), default="")
category: Mapped[str] = mapped_column(String(64), default="") category: Mapped[str] = mapped_column(String(64), default="")
category_en: Mapped[str] = mapped_column(String(64), default="")
image: Mapped[str] = mapped_column(Text, default="") image: Mapped[str] = mapped_column(Text, default="")
read: Mapped[str] = mapped_column(String(32), default="") read: Mapped[str] = mapped_column(String(32), default="")
read_en: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text, default="") excerpt: Mapped[str] = mapped_column(Text, default="")
excerpt_en: Mapped[str] = mapped_column(Text, default="") body: Mapped[list] = mapped_column(JSON, default=list)
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
# draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود # draft | published — پیش‌نویس فقط برای ادمین دیده می‌شود
status: Mapped[str] = mapped_column(String(16), default="published") status: Mapped[str] = mapped_column(String(16), default="published")
# کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود # کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود
@@ -114,11 +91,8 @@ class Testimonial(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128)) name: Mapped[str] = mapped_column(String(128))
name_en: Mapped[str] = mapped_column(String(128), default="")
role: Mapped[str] = mapped_column(String(128), default="") role: Mapped[str] = mapped_column(String(128), default="")
role_en: Mapped[str] = mapped_column(String(128), default="")
quote: Mapped[str] = mapped_column(Text, default="") quote: Mapped[str] = mapped_column(Text, default="")
quote_en: Mapped[str] = mapped_column(Text, default="")
class Faq(Base): class Faq(Base):
@@ -128,9 +102,7 @@ class Faq(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
question: Mapped[str] = mapped_column(Text) question: Mapped[str] = mapped_column(Text)
question_en: Mapped[str] = mapped_column(Text, default="")
answer: Mapped[str] = mapped_column(Text, default="") answer: Mapped[str] = mapped_column(Text, default="")
answer_en: Mapped[str] = mapped_column(Text, default="")
class TeamMember(Base): class TeamMember(Base):
@@ -140,11 +112,8 @@ class TeamMember(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128)) name: Mapped[str] = mapped_column(String(128))
name_en: Mapped[str] = mapped_column(String(128), default="")
role: Mapped[str] = mapped_column(String(128), default="") role: Mapped[str] = mapped_column(String(128), default="")
role_en: Mapped[str] = mapped_column(String(128), default="")
bio: Mapped[str] = mapped_column(Text, default="") bio: Mapped[str] = mapped_column(Text, default="")
bio_en: Mapped[str] = mapped_column(Text, default="")
class TimelineItem(Base): class TimelineItem(Base):
@@ -154,11 +123,8 @@ class TimelineItem(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
year: Mapped[str] = mapped_column(String(32)) year: Mapped[str] = mapped_column(String(32))
year_en: Mapped[str] = mapped_column(String(32), default="")
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
image: Mapped[str] = mapped_column(Text, default="") image: Mapped[str] = mapped_column(Text, default="")
@@ -170,9 +136,7 @@ class Stat(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer) value: Mapped[int] = mapped_column(Integer)
suffix: Mapped[str] = mapped_column(String(16), default="") suffix: Mapped[str] = mapped_column(String(16), default="")
suffix_en: Mapped[str] = mapped_column(String(16), default="")
label: Mapped[str] = mapped_column(String(128)) label: Mapped[str] = mapped_column(String(128))
label_en: Mapped[str] = mapped_column(String(128), default="")
class WhyUsItem(Base): class WhyUsItem(Base):
@@ -182,9 +146,7 @@ class WhyUsItem(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
class BeforeAfter(Base): class BeforeAfter(Base):
@@ -192,15 +154,11 @@ class BeforeAfter(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="") lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
before: Mapped[str] = mapped_column(Text, default="") before: Mapped[str] = mapped_column(Text, default="")
after: Mapped[str] = mapped_column(Text, default="") after: Mapped[str] = mapped_column(Text, default="")
before_label: Mapped[str] = mapped_column(String(128), default="") before_label: Mapped[str] = mapped_column(String(128), default="")
before_label_en: Mapped[str] = mapped_column(String(128), default="")
after_label: Mapped[str] = mapped_column(String(128), default="") after_label: Mapped[str] = mapped_column(String(128), default="")
after_label_en: Mapped[str] = mapped_column(String(128), default="")
class NavItem(Base): class NavItem(Base):
@@ -210,7 +168,6 @@ class NavItem(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(64)) label: Mapped[str] = mapped_column(String(64))
label_en: Mapped[str] = mapped_column(String(64), default="")
href: Mapped[str] = mapped_column(String(128)) href: Mapped[str] = mapped_column(String(128))
@@ -221,25 +178,16 @@ class Hero(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
kicker: Mapped[str] = mapped_column(Text, default="") kicker: Mapped[str] = mapped_column(Text, default="")
kicker_en: Mapped[str] = mapped_column(Text, default="")
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
title_en: Mapped[str] = mapped_column(Text, default="")
title_highlight: Mapped[str] = mapped_column(String(128), default="") title_highlight: Mapped[str] = mapped_column(String(128), default="")
title_highlight_en: Mapped[str] = mapped_column(String(128), default="")
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
subtitle_en: Mapped[str] = mapped_column(Text, default="")
badge_value: Mapped[str] = mapped_column(String(32), default="") badge_value: Mapped[str] = mapped_column(String(32), default="")
badge_value_en: Mapped[str] = mapped_column(String(32), default="")
badge_label: Mapped[str] = mapped_column(Text, default="") badge_label: Mapped[str] = mapped_column(Text, default="")
badge_label_en: Mapped[str] = mapped_column(Text, default="")
cta_primary_label: Mapped[str] = mapped_column(String(128), default="") cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
cta_primary_label_en: Mapped[str] = mapped_column(String(128), default="")
cta_primary_href: Mapped[str] = mapped_column(String(128), default="") cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
cta_secondary_label: Mapped[str] = mapped_column(String(128), default="") cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
cta_secondary_label_en: Mapped[str] = mapped_column(String(128), default="")
cta_secondary_href: Mapped[str] = mapped_column(String(128), default="") cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
scroll_label: Mapped[str] = mapped_column(String(64), default="") scroll_label: Mapped[str] = mapped_column(String(64), default="")
scroll_label_en: Mapped[str] = mapped_column(String(64), default="")
class StorySection(Base): class StorySection(Base):
@@ -249,26 +197,16 @@ class StorySection(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
paragraphs: Mapped[list] = mapped_column(JSON, default=list) paragraphs: Mapped[list] = mapped_column(JSON, default=list)
paragraphs_en: Mapped[list] = mapped_column(JSON, default=list)
quote: Mapped[str] = mapped_column(Text, default="") quote: Mapped[str] = mapped_column(Text, default="")
quote_en: Mapped[str] = mapped_column(Text, default="")
quote_author: Mapped[str] = mapped_column(Text, default="") quote_author: Mapped[str] = mapped_column(Text, default="")
quote_author_en: Mapped[str] = mapped_column(Text, default="")
stat1_value: Mapped[str] = mapped_column(String(32), default="") stat1_value: Mapped[str] = mapped_column(String(32), default="")
stat1_value_en: Mapped[str] = mapped_column(String(32), default="")
stat1_label: Mapped[str] = mapped_column(String(128), default="") stat1_label: Mapped[str] = mapped_column(String(128), default="")
stat1_label_en: Mapped[str] = mapped_column(String(128), default="")
stat2_value: Mapped[str] = mapped_column(String(32), default="") stat2_value: Mapped[str] = mapped_column(String(32), default="")
stat2_value_en: Mapped[str] = mapped_column(String(32), default="")
stat2_label: Mapped[str] = mapped_column(String(128), default="") stat2_label: Mapped[str] = mapped_column(String(128), default="")
stat2_label_en: Mapped[str] = mapped_column(String(128), default="")
link_label: Mapped[str] = mapped_column(String(128), default="") link_label: Mapped[str] = mapped_column(String(128), default="")
link_label_en: Mapped[str] = mapped_column(String(128), default="")
link_href: Mapped[str] = mapped_column(String(128), default="") link_href: Mapped[str] = mapped_column(String(128), default="")
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
class SectionHeadingContent(Base): class SectionHeadingContent(Base):
@@ -279,14 +217,10 @@ class SectionHeadingContent(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(64), unique=True, index=True) key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
label: Mapped[str] = mapped_column(String(64), default="") label: Mapped[str] = mapped_column(String(64), default="")
label_en: Mapped[str] = mapped_column(String(64), default="")
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="") lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
image: Mapped[str] = mapped_column(Text, default="") image: Mapped[str] = mapped_column(Text, default="")
cta_label: Mapped[str] = mapped_column(String(128), default="") cta_label: Mapped[str] = mapped_column(String(128), default="")
cta_label_en: Mapped[str] = mapped_column(String(128), default="")
cta_href: Mapped[str] = mapped_column(String(128), default="") cta_href: Mapped[str] = mapped_column(String(128), default="")
@@ -298,9 +232,7 @@ class PageHeroContent(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(32), unique=True, index=True) key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="") lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
image: Mapped[str] = mapped_column(Text, default="") image: Mapped[str] = mapped_column(Text, default="")
@@ -311,11 +243,8 @@ class InvestmentModel(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
points: Mapped[list] = mapped_column(JSON, default=list) points: Mapped[list] = mapped_column(JSON, default=list)
points_en: Mapped[list] = mapped_column(JSON, default=list)
class ProcessStep(Base): class ProcessStep(Base):
@@ -325,11 +254,8 @@ class ProcessStep(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
step: Mapped[str] = mapped_column(String(16), default="") step: Mapped[str] = mapped_column(String(16), default="")
step_en: Mapped[str] = mapped_column(String(16), default="")
title: Mapped[str] = mapped_column(String(256)) title: Mapped[str] = mapped_column(String(256))
title_en: Mapped[str] = mapped_column(String(256), default="")
desc: Mapped[str] = mapped_column(Text, default="") desc: Mapped[str] = mapped_column(Text, default="")
desc_en: Mapped[str] = mapped_column(Text, default="")
class Office(Base): class Office(Base):
@@ -339,11 +265,8 @@ class Office(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
city: Mapped[str] = mapped_column(String(64)) city: Mapped[str] = mapped_column(String(64))
city_en: Mapped[str] = mapped_column(String(64), default="")
address: Mapped[str] = mapped_column(Text, default="") address: Mapped[str] = mapped_column(Text, default="")
address_en: Mapped[str] = mapped_column(Text, default="")
hours: Mapped[str] = mapped_column(String(128), default="") hours: Mapped[str] = mapped_column(String(128), default="")
hours_en: Mapped[str] = mapped_column(String(128), default="")
class GalleryImage(Base): class GalleryImage(Base):
@@ -354,7 +277,6 @@ class GalleryImage(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
src: Mapped[str] = mapped_column(Text, default="") src: Mapped[str] = mapped_column(Text, default="")
alt: Mapped[str] = mapped_column(Text, default="") alt: Mapped[str] = mapped_column(Text, default="")
alt_en: Mapped[str] = mapped_column(Text, default="")
class StatusLabel(Base): class StatusLabel(Base):
@@ -365,7 +287,6 @@ class StatusLabel(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(16), unique=True, index=True) key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
label: Mapped[str] = mapped_column(String(64)) label: Mapped[str] = mapped_column(String(64))
label_en: Mapped[str] = mapped_column(String(64), default="")
class ContactSectionContent(Base): class ContactSectionContent(Base):
@@ -375,41 +296,23 @@ class ContactSectionContent(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
label: Mapped[str] = mapped_column(String(64), default="") label: Mapped[str] = mapped_column(String(64), default="")
label_en: Mapped[str] = mapped_column(String(64), default="")
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="") lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
address_label: Mapped[str] = mapped_column(String(64), default="") address_label: Mapped[str] = mapped_column(String(64), default="")
address_label_en: Mapped[str] = mapped_column(String(64), default="")
phone_label: Mapped[str] = mapped_column(String(64), default="") phone_label: Mapped[str] = mapped_column(String(64), default="")
phone_label_en: Mapped[str] = mapped_column(String(64), default="")
email_label: Mapped[str] = mapped_column(String(64), default="") email_label: Mapped[str] = mapped_column(String(64), default="")
email_label_en: Mapped[str] = mapped_column(String(64), default="")
name_label: Mapped[str] = mapped_column(String(64), default="") name_label: Mapped[str] = mapped_column(String(64), default="")
name_label_en: Mapped[str] = mapped_column(String(64), default="")
name_placeholder: Mapped[str] = mapped_column(String(128), default="") name_placeholder: Mapped[str] = mapped_column(String(128), default="")
name_placeholder_en: Mapped[str] = mapped_column(String(128), default="")
phone_field_label: Mapped[str] = mapped_column(String(64), default="") phone_field_label: Mapped[str] = mapped_column(String(64), default="")
phone_field_label_en: Mapped[str] = mapped_column(String(64), default="")
phone_placeholder: Mapped[str] = mapped_column(String(64), default="") phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
phone_placeholder_en: Mapped[str] = mapped_column(String(64), default="")
note_label: Mapped[str] = mapped_column(String(64), default="") note_label: Mapped[str] = mapped_column(String(64), default="")
note_label_en: Mapped[str] = mapped_column(String(64), default="")
note_optional_label: Mapped[str] = mapped_column(String(64), default="") note_optional_label: Mapped[str] = mapped_column(String(64), default="")
note_optional_label_en: Mapped[str] = mapped_column(String(64), default="")
note_placeholder: Mapped[str] = mapped_column(String(256), default="") note_placeholder: Mapped[str] = mapped_column(String(256), default="")
note_placeholder_en: Mapped[str] = mapped_column(String(256), default="")
submit_label: Mapped[str] = mapped_column(String(128), default="") submit_label: Mapped[str] = mapped_column(String(128), default="")
submit_label_en: Mapped[str] = mapped_column(String(128), default="")
sending_label: Mapped[str] = mapped_column(String(64), default="") sending_label: Mapped[str] = mapped_column(String(64), default="")
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
success_title: Mapped[str] = mapped_column(String(256), default="") success_title: Mapped[str] = mapped_column(String(256), default="")
success_title_en: Mapped[str] = mapped_column(String(256), default="")
success_text: Mapped[str] = mapped_column(Text, default="") success_text: Mapped[str] = mapped_column(Text, default="")
success_text_en: Mapped[str] = mapped_column(Text, default="")
success_button_label: Mapped[str] = mapped_column(String(128), default="") success_button_label: Mapped[str] = mapped_column(String(128), default="")
success_button_label_en: Mapped[str] = mapped_column(String(128), default="")
class NewsletterSectionContent(Base): class NewsletterSectionContent(Base):
@@ -419,17 +322,11 @@ class NewsletterSectionContent(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
lead: Mapped[str] = mapped_column(Text, default="") lead: Mapped[str] = mapped_column(Text, default="")
lead_en: Mapped[str] = mapped_column(Text, default="")
placeholder: Mapped[str] = mapped_column(String(128), default="") placeholder: Mapped[str] = mapped_column(String(128), default="")
placeholder_en: Mapped[str] = mapped_column(String(128), default="")
button_label: Mapped[str] = mapped_column(String(64), default="") button_label: Mapped[str] = mapped_column(String(64), default="")
button_label_en: Mapped[str] = mapped_column(String(64), default="")
sending_label: Mapped[str] = mapped_column(String(64), default="") sending_label: Mapped[str] = mapped_column(String(64), default="")
sending_label_en: Mapped[str] = mapped_column(String(64), default="")
success_text: Mapped[str] = mapped_column(String(256), default="") success_text: Mapped[str] = mapped_column(String(256), default="")
success_text_en: Mapped[str] = mapped_column(String(256), default="")
class FooterContent(Base): class FooterContent(Base):
@@ -439,19 +336,12 @@ class FooterContent(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
about_text: Mapped[str] = mapped_column(Text, default="") about_text: Mapped[str] = mapped_column(Text, default="")
about_text_en: Mapped[str] = mapped_column(Text, default="")
pages_heading: Mapped[str] = mapped_column(String(64), default="") pages_heading: Mapped[str] = mapped_column(String(64), default="")
pages_heading_en: Mapped[str] = mapped_column(String(64), default="")
services_heading: Mapped[str] = mapped_column(String(64), default="") services_heading: Mapped[str] = mapped_column(String(64), default="")
services_heading_en: Mapped[str] = mapped_column(String(64), default="")
contact_heading: Mapped[str] = mapped_column(String(64), default="") contact_heading: Mapped[str] = mapped_column(String(64), default="")
contact_heading_en: Mapped[str] = mapped_column(String(64), default="")
copyright: Mapped[str] = mapped_column(String(256), default="") copyright: Mapped[str] = mapped_column(String(256), default="")
copyright_en: Mapped[str] = mapped_column(String(256), default="")
tagline: Mapped[str] = mapped_column(String(256), default="") tagline: Mapped[str] = mapped_column(String(256), default="")
tagline_en: Mapped[str] = mapped_column(String(256), default="")
socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
socials_en: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}] انگلیسی
class SeoMeta(Base): class SeoMeta(Base):
@@ -462,9 +352,7 @@ class SeoMeta(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256), default="") title: Mapped[str] = mapped_column(String(256), default="")
title_en: Mapped[str] = mapped_column(String(256), default="")
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
description_en: Mapped[str] = mapped_column(Text, default="")
class ContactMessage(Base): class ContactMessage(Base):
-229
View File
@@ -10,16 +10,12 @@ class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# فیلدهای *_en معادل انگلیسی‌اند؛ خالی بودنشان یعنی «بدون ترجمه».
# ---------- خروجی‌ها ---------- # ---------- خروجی‌ها ----------
class BrandOut(ORMModel): class BrandOut(ORMModel):
id: int id: int
name: str name: str
name_en: str = ""
logo: str logo: str
hero: str hero: str
contact_bg: str contact_bg: str
@@ -27,15 +23,12 @@ class BrandOut(ORMModel):
phone_href: str phone_href: str
email: str email: str
address: str address: str
address_en: str = ""
class ServiceOut(ORMModel): class ServiceOut(ORMModel):
id: int id: int
title: str title: str
title_en: str = ""
desc: str desc: str
desc_en: str = ""
icon: str icon: str
@@ -43,44 +36,29 @@ class ProjectOut(ORMModel):
id: int id: int
slug: str slug: str
title: str title: str
title_en: str = ""
location: str location: str
location_en: str = ""
year: str year: str
year_en: str = ""
status: str status: str
image: str image: str
gallery: list[str] gallery: list[str]
area: str area: str
area_en: str = ""
floors: str floors: str
floors_en: str = ""
units: str units: str
units_en: str = ""
client: str client: str
client_en: str = ""
summary: str summary: str
summary_en: str = ""
body: list[str] body: list[str]
body_en: Optional[list[str]] = None
class ArticleOut(ORMModel): class ArticleOut(ORMModel):
id: int id: int
slug: str slug: str
title: str title: str
title_en: str = ""
date: str date: str
date_en: str = ""
category: str category: str
category_en: str = ""
image: str image: str
read: str read: str
read_en: str = ""
excerpt: str excerpt: str
excerpt_en: str = ""
body: dict body: dict
body_en: Optional[dict] = None
status: str status: str
seo_keyphrase: str seo_keyphrase: str
@@ -88,39 +66,28 @@ class ArticleOut(ORMModel):
class TestimonialOut(ORMModel): class TestimonialOut(ORMModel):
id: int id: int
name: str name: str
name_en: str = ""
role: str role: str
role_en: str = ""
quote: str quote: str
quote_en: str = ""
class FaqOut(ORMModel): class FaqOut(ORMModel):
id: int id: int
question: str question: str
question_en: str = ""
answer: str answer: str
answer_en: str = ""
class TeamMemberOut(ORMModel): class TeamMemberOut(ORMModel):
id: int id: int
name: str name: str
name_en: str = ""
role: str role: str
role_en: str = ""
bio: str bio: str
bio_en: str = ""
class TimelineItemOut(ORMModel): class TimelineItemOut(ORMModel):
id: int id: int
year: str year: str
year_en: str = ""
title: str title: str
title_en: str = ""
desc: str desc: str
desc_en: str = ""
image: str image: str
@@ -128,101 +95,69 @@ class StatOut(ORMModel):
id: int id: int
value: int value: int
suffix: str suffix: str
suffix_en: str = ""
label: str label: str
label_en: str = ""
class WhyUsItemOut(ORMModel): class WhyUsItemOut(ORMModel):
id: int id: int
title: str title: str
title_en: str = ""
desc: str desc: str
desc_en: str = ""
class BeforeAfterOut(ORMModel): class BeforeAfterOut(ORMModel):
id: int id: int
title: str title: str
title_en: str = ""
lead: str lead: str
lead_en: str = ""
before: str before: str
after: str after: str
before_label: str before_label: str
before_label_en: str = ""
after_label: str after_label: str
after_label_en: str = ""
class NavItemOut(ORMModel): class NavItemOut(ORMModel):
id: int id: int
label: str label: str
label_en: str = ""
href: str href: str
class HeroOut(ORMModel): class HeroOut(ORMModel):
id: int id: int
kicker: str kicker: str
kicker_en: str = ""
title: str title: str
title_en: str = ""
title_highlight: str title_highlight: str
title_highlight_en: str = ""
subtitle: str subtitle: str
subtitle_en: str = ""
badge_value: str badge_value: str
badge_value_en: str = ""
badge_label: str badge_label: str
badge_label_en: str = ""
cta_primary_label: str cta_primary_label: str
cta_primary_label_en: str = ""
cta_primary_href: str cta_primary_href: str
cta_secondary_label: str cta_secondary_label: str
cta_secondary_label_en: str = ""
cta_secondary_href: str cta_secondary_href: str
scroll_label: str scroll_label: str
scroll_label_en: str = ""
class StoryOut(ORMModel): class StoryOut(ORMModel):
id: int id: int
title: str title: str
title_en: str = ""
paragraphs: list[str] paragraphs: list[str]
paragraphs_en: Optional[list[str]] = None
quote: str quote: str
quote_en: str = ""
quote_author: str quote_author: str
quote_author_en: str = ""
stat1_value: str stat1_value: str
stat1_value_en: str = ""
stat1_label: str stat1_label: str
stat1_label_en: str = ""
stat2_value: str stat2_value: str
stat2_value_en: str = ""
stat2_label: str stat2_label: str
stat2_label_en: str = ""
link_label: str link_label: str
link_label_en: str = ""
link_href: str link_href: str
bento: list[dict] bento: list[dict]
bento_en: Optional[list[dict]] = None
class SectionHeadingOut(ORMModel): class SectionHeadingOut(ORMModel):
id: int id: int
key: str key: str
label: str label: str
label_en: str = ""
title: str title: str
title_en: str = ""
lead: str lead: str
lead_en: str = ""
image: str image: str
cta_label: str cta_label: str
cta_label_en: str = ""
cta_href: str cta_href: str
@@ -230,137 +165,91 @@ class PageHeroOut(ORMModel):
id: int id: int
key: str key: str
title: str title: str
title_en: str = ""
lead: str lead: str
lead_en: str = ""
image: str image: str
class InvestmentModelOut(ORMModel): class InvestmentModelOut(ORMModel):
id: int id: int
title: str title: str
title_en: str = ""
desc: str desc: str
desc_en: str = ""
points: list[str] points: list[str]
points_en: Optional[list[str]] = None
class ProcessStepOut(ORMModel): class ProcessStepOut(ORMModel):
id: int id: int
step: str step: str
step_en: str = ""
title: str title: str
title_en: str = ""
desc: str desc: str
desc_en: str = ""
class OfficeOut(ORMModel): class OfficeOut(ORMModel):
id: int id: int
city: str city: str
city_en: str = ""
address: str address: str
address_en: str = ""
hours: str hours: str
hours_en: str = ""
class GalleryImageOut(ORMModel): class GalleryImageOut(ORMModel):
id: int id: int
src: str src: str
alt: str alt: str
alt_en: str = ""
class StatusLabelOut(ORMModel): class StatusLabelOut(ORMModel):
id: int id: int
key: str key: str
label: str label: str
label_en: str = ""
class ContactSectionOut(ORMModel): class ContactSectionOut(ORMModel):
id: int id: int
label: str label: str
label_en: str = ""
title: str title: str
title_en: str = ""
lead: str lead: str
lead_en: str = ""
address_label: str address_label: str
address_label_en: str = ""
phone_label: str phone_label: str
phone_label_en: str = ""
email_label: str email_label: str
email_label_en: str = ""
name_label: str name_label: str
name_label_en: str = ""
name_placeholder: str name_placeholder: str
name_placeholder_en: str = ""
phone_field_label: str phone_field_label: str
phone_field_label_en: str = ""
phone_placeholder: str phone_placeholder: str
phone_placeholder_en: str = ""
note_label: str note_label: str
note_label_en: str = ""
note_optional_label: str note_optional_label: str
note_optional_label_en: str = ""
note_placeholder: str note_placeholder: str
note_placeholder_en: str = ""
submit_label: str submit_label: str
submit_label_en: str = ""
sending_label: str sending_label: str
sending_label_en: str = ""
success_title: str success_title: str
success_title_en: str = ""
success_text: str success_text: str
success_text_en: str = ""
success_button_label: str success_button_label: str
success_button_label_en: str = ""
class NewsletterOut(ORMModel): class NewsletterOut(ORMModel):
id: int id: int
title: str title: str
title_en: str = ""
lead: str lead: str
lead_en: str = ""
placeholder: str placeholder: str
placeholder_en: str = ""
button_label: str button_label: str
button_label_en: str = ""
sending_label: str sending_label: str
sending_label_en: str = ""
success_text: str success_text: str
success_text_en: str = ""
class FooterOut(ORMModel): class FooterOut(ORMModel):
id: int id: int
about_text: str about_text: str
about_text_en: str = ""
pages_heading: str pages_heading: str
pages_heading_en: str = ""
services_heading: str services_heading: str
services_heading_en: str = ""
contact_heading: str contact_heading: str
contact_heading_en: str = ""
copyright: str copyright: str
copyright_en: str = ""
tagline: str tagline: str
tagline_en: str = ""
socials: list[dict] socials: list[dict]
socials_en: Optional[list[dict]] = None
class SeoMetaOut(ORMModel): class SeoMetaOut(ORMModel):
id: int id: int
page_key: str page_key: str
title: str title: str
title_en: str = ""
description: str description: str
description_en: str = ""
class ContactMessageOut(ORMModel): class ContactMessageOut(ORMModel):
@@ -412,7 +301,6 @@ class ChangePasswordIn(BaseModel):
class BrandIn(BaseModel): class BrandIn(BaseModel):
name: str = "" name: str = ""
name_en: str = ""
logo: str = "" logo: str = ""
hero: str = "" hero: str = ""
contact_bg: str = "" contact_bg: str = ""
@@ -420,40 +308,28 @@ class BrandIn(BaseModel):
phone_href: str = "" phone_href: str = ""
email: str = "" email: str = ""
address: str = "" address: str = ""
address_en: str = ""
class ServiceIn(BaseModel): class ServiceIn(BaseModel):
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = "" desc: str = ""
desc_en: str = ""
icon: str = "draft" icon: str = "draft"
class ProjectIn(BaseModel): class ProjectIn(BaseModel):
slug: str = Field(min_length=1, max_length=128) slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
location: str = "" location: str = ""
location_en: str = ""
year: str = "" year: str = ""
year_en: str = ""
status: Literal["done", "progress"] = "done" status: Literal["done", "progress"] = "done"
image: str = "" image: str = ""
gallery: list[str] = [] gallery: list[str] = []
area: str = "" area: str = ""
area_en: str = ""
floors: str = "" floors: str = ""
floors_en: str = ""
units: str = "" units: str = ""
units_en: str = ""
client: str = "" client: str = ""
client_en: str = ""
summary: str = "" summary: str = ""
summary_en: str = ""
body: list[str] = [] body: list[str] = []
body_en: list[str] = []
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]} EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
@@ -471,287 +347,189 @@ def validate_doc_body(value: object) -> dict:
class ArticleIn(BaseModel): class ArticleIn(BaseModel):
slug: str = Field(min_length=1, max_length=128) slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
date: str = "" date: str = ""
date_en: str = ""
category: str = "" category: str = ""
category_en: str = ""
image: str = "" image: str = ""
read: str = "" read: str = ""
read_en: str = ""
excerpt: str = "" excerpt: str = ""
excerpt_en: str = ""
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC)) body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
status: Literal["draft", "published"] = "draft" status: Literal["draft", "published"] = "draft"
seo_keyphrase: str = Field(default="", max_length=128) seo_keyphrase: str = Field(default="", max_length=128)
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body)
class TestimonialIn(BaseModel): class TestimonialIn(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
name_en: str = ""
role: str = "" role: str = ""
role_en: str = ""
quote: str = "" quote: str = ""
quote_en: str = ""
class FaqIn(BaseModel): class FaqIn(BaseModel):
question: str = Field(min_length=1) question: str = Field(min_length=1)
question_en: str = ""
answer: str = "" answer: str = ""
answer_en: str = ""
class TeamMemberIn(BaseModel): class TeamMemberIn(BaseModel):
name: str = Field(min_length=1, max_length=128) name: str = Field(min_length=1, max_length=128)
name_en: str = ""
role: str = "" role: str = ""
role_en: str = ""
bio: str = "" bio: str = ""
bio_en: str = ""
class TimelineItemIn(BaseModel): class TimelineItemIn(BaseModel):
year: str = Field(min_length=1, max_length=32) year: str = Field(min_length=1, max_length=32)
year_en: str = ""
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = "" desc: str = ""
desc_en: str = ""
image: str = "" image: str = ""
class StatIn(BaseModel): class StatIn(BaseModel):
value: int value: int
suffix: str = "" suffix: str = ""
suffix_en: str = ""
label: str = Field(min_length=1, max_length=128) label: str = Field(min_length=1, max_length=128)
label_en: str = ""
class WhyUsItemIn(BaseModel): class WhyUsItemIn(BaseModel):
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = "" desc: str = ""
desc_en: str = ""
class BeforeAfterIn(BaseModel): class BeforeAfterIn(BaseModel):
title: str = "" title: str = ""
title_en: str = ""
lead: str = "" lead: str = ""
lead_en: str = ""
before: str = "" before: str = ""
after: str = "" after: str = ""
before_label: str = "" before_label: str = ""
before_label_en: str = ""
after_label: str = "" after_label: str = ""
after_label_en: str = ""
class NavItemIn(BaseModel): class NavItemIn(BaseModel):
label: str = Field(min_length=1, max_length=64) label: str = Field(min_length=1, max_length=64)
label_en: str = ""
href: str = Field(min_length=1, max_length=128) href: str = Field(min_length=1, max_length=128)
class HeroIn(BaseModel): class HeroIn(BaseModel):
kicker: str = "" kicker: str = ""
kicker_en: str = ""
title: str = "" title: str = ""
title_en: str = ""
title_highlight: str = "" title_highlight: str = ""
title_highlight_en: str = ""
subtitle: str = "" subtitle: str = ""
subtitle_en: str = ""
badge_value: str = "" badge_value: str = ""
badge_value_en: str = ""
badge_label: str = "" badge_label: str = ""
badge_label_en: str = ""
cta_primary_label: str = "" cta_primary_label: str = ""
cta_primary_label_en: str = ""
cta_primary_href: str = "" cta_primary_href: str = ""
cta_secondary_label: str = "" cta_secondary_label: str = ""
cta_secondary_label_en: str = ""
cta_secondary_href: str = "" cta_secondary_href: str = ""
scroll_label: str = "" scroll_label: str = ""
scroll_label_en: str = ""
class StoryIn(BaseModel): class StoryIn(BaseModel):
title: str = "" title: str = ""
title_en: str = ""
paragraphs: list[str] = [] paragraphs: list[str] = []
paragraphs_en: list[str] = []
quote: str = "" quote: str = ""
quote_en: str = ""
quote_author: str = "" quote_author: str = ""
quote_author_en: str = ""
stat1_value: str = "" stat1_value: str = ""
stat1_value_en: str = ""
stat1_label: str = "" stat1_label: str = ""
stat1_label_en: str = ""
stat2_value: str = "" stat2_value: str = ""
stat2_value_en: str = ""
stat2_label: str = "" stat2_label: str = ""
stat2_label_en: str = ""
link_label: str = "" link_label: str = ""
link_label_en: str = ""
link_href: str = "" link_href: str = ""
bento: list[dict] = [] bento: list[dict] = []
bento_en: list[dict] = []
class SectionHeadingIn(BaseModel): class SectionHeadingIn(BaseModel):
key: str = Field(min_length=1, max_length=64) key: str = Field(min_length=1, max_length=64)
label: str = "" label: str = ""
label_en: str = ""
title: str = "" title: str = ""
title_en: str = ""
lead: str = "" lead: str = ""
lead_en: str = ""
image: str = "" image: str = ""
cta_label: str = "" cta_label: str = ""
cta_label_en: str = ""
cta_href: str = "" cta_href: str = ""
class PageHeroIn(BaseModel): class PageHeroIn(BaseModel):
key: str = Field(min_length=1, max_length=32) key: str = Field(min_length=1, max_length=32)
title: str = "" title: str = ""
title_en: str = ""
lead: str = "" lead: str = ""
lead_en: str = ""
image: str = "" image: str = ""
class InvestmentModelIn(BaseModel): class InvestmentModelIn(BaseModel):
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = "" desc: str = ""
desc_en: str = ""
points: list[str] = [] points: list[str] = []
points_en: list[str] = []
class ProcessStepIn(BaseModel): class ProcessStepIn(BaseModel):
step: str = "" step: str = ""
step_en: str = ""
title: str = Field(min_length=1, max_length=256) title: str = Field(min_length=1, max_length=256)
title_en: str = ""
desc: str = "" desc: str = ""
desc_en: str = ""
class OfficeIn(BaseModel): class OfficeIn(BaseModel):
city: str = Field(min_length=1, max_length=64) city: str = Field(min_length=1, max_length=64)
city_en: str = ""
address: str = "" address: str = ""
address_en: str = ""
hours: str = "" hours: str = ""
hours_en: str = ""
class GalleryImageIn(BaseModel): class GalleryImageIn(BaseModel):
src: str = "" src: str = ""
alt: str = "" alt: str = ""
alt_en: str = ""
class StatusLabelIn(BaseModel): class StatusLabelIn(BaseModel):
key: str = Field(min_length=1, max_length=16) key: str = Field(min_length=1, max_length=16)
label: str = Field(min_length=1, max_length=64) label: str = Field(min_length=1, max_length=64)
label_en: str = ""
class ContactSectionIn(BaseModel): class ContactSectionIn(BaseModel):
label: str = "" label: str = ""
label_en: str = ""
title: str = "" title: str = ""
title_en: str = ""
lead: str = "" lead: str = ""
lead_en: str = ""
address_label: str = "" address_label: str = ""
address_label_en: str = ""
phone_label: str = "" phone_label: str = ""
phone_label_en: str = ""
email_label: str = "" email_label: str = ""
email_label_en: str = ""
name_label: str = "" name_label: str = ""
name_label_en: str = ""
name_placeholder: str = "" name_placeholder: str = ""
name_placeholder_en: str = ""
phone_field_label: str = "" phone_field_label: str = ""
phone_field_label_en: str = ""
phone_placeholder: str = "" phone_placeholder: str = ""
phone_placeholder_en: str = ""
note_label: str = "" note_label: str = ""
note_label_en: str = ""
note_optional_label: str = "" note_optional_label: str = ""
note_optional_label_en: str = ""
note_placeholder: str = "" note_placeholder: str = ""
note_placeholder_en: str = ""
submit_label: str = "" submit_label: str = ""
submit_label_en: str = ""
sending_label: str = "" sending_label: str = ""
sending_label_en: str = ""
success_title: str = "" success_title: str = ""
success_title_en: str = ""
success_text: str = "" success_text: str = ""
success_text_en: str = ""
success_button_label: str = "" success_button_label: str = ""
success_button_label_en: str = ""
class NewsletterSectionIn(BaseModel): class NewsletterSectionIn(BaseModel):
title: str = "" title: str = ""
title_en: str = ""
lead: str = "" lead: str = ""
lead_en: str = ""
placeholder: str = "" placeholder: str = ""
placeholder_en: str = ""
button_label: str = "" button_label: str = ""
button_label_en: str = ""
sending_label: str = "" sending_label: str = ""
sending_label_en: str = ""
success_text: str = "" success_text: str = ""
success_text_en: str = ""
class FooterIn(BaseModel): class FooterIn(BaseModel):
about_text: str = "" about_text: str = ""
about_text_en: str = ""
pages_heading: str = "" pages_heading: str = ""
pages_heading_en: str = ""
services_heading: str = "" services_heading: str = ""
services_heading_en: str = ""
contact_heading: str = "" contact_heading: str = ""
contact_heading_en: str = ""
copyright: str = "" copyright: str = ""
copyright_en: str = ""
tagline: str = "" tagline: str = ""
tagline_en: str = ""
socials: list[dict] = [] socials: list[dict] = []
socials_en: list[dict] = []
class SeoMetaIn(BaseModel): class SeoMetaIn(BaseModel):
page_key: str = Field(min_length=1, max_length=32) page_key: str = Field(min_length=1, max_length=32)
title: str = "" title: str = ""
title_en: str = ""
description: str = "" description: str = ""
description_en: str = ""
class SeoMetaPatch(BaseModel): class SeoMetaPatch(BaseModel):
page_key: Optional[str] = None page_key: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
title_en: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
description_en: Optional[str] = None
class MessageReadIn(BaseModel): class MessageReadIn(BaseModel):
@@ -773,22 +551,15 @@ ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
class ArticlePatch(BaseModel): class ArticlePatch(BaseModel):
slug: Optional[str] = None slug: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
title_en: Optional[str] = None
date: Optional[str] = None date: Optional[str] = None
date_en: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
category_en: Optional[str] = None
image: Optional[str] = None image: Optional[str] = None
read: Optional[str] = None read: Optional[str] = None
read_en: Optional[str] = None
excerpt: Optional[str] = None excerpt: Optional[str] = None
excerpt_en: Optional[str] = None
body: Optional[dict] = None body: Optional[dict] = None
body_en: Optional[dict] = None
status: Optional[Literal["draft", "published"]] = None status: Optional[Literal["draft", "published"]] = None
seo_keyphrase: Optional[str] = None seo_keyphrase: Optional[str] = None
_validate_body = field_validator("body")(validate_doc_body) _validate_body = field_validator("body")(validate_doc_body)
_validate_body_en = field_validator("body_en")(validate_doc_body)
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn) TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
FaqPatch = _make_patch("FaqPatch", FaqIn) FaqPatch = _make_patch("FaqPatch", FaqIn)
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn) TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
+6 -38
View File
@@ -11,7 +11,7 @@ seed می‌شوند و داده‌های قبلی دست‌نخورده می‌
import argparse import argparse
from typing import Any from typing import Any
from . import models, seed_data, seed_data_en from . import models, seed_data
from .auth import hash_password from .auth import hash_password
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine from .database import Base, SessionLocal, engine
@@ -46,35 +46,6 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
(models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"), (models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
] ]
# ترجمه انگلیسی همان جدول‌ها — هم‌ترتیب با CONTENT تا با zip ادغام شود
EN_CONTENT: list[list[dict]] = [
[seed_data_en.BRAND],
[seed_data_en.HERO],
[seed_data_en.STORY],
seed_data_en.SECTION_HEADINGS,
seed_data_en.PAGE_HEROES,
seed_data_en.NAV_ITEMS,
seed_data_en.INVESTMENT_MODELS,
seed_data_en.PROCESS_STEPS,
seed_data_en.OFFICES,
seed_data_en.GALLERY_IMAGES,
seed_data_en.STATUS_LABELS,
[seed_data_en.CONTACT_SECTION],
[seed_data_en.NEWSLETTER],
[seed_data_en.FOOTER],
seed_data_en.SERVICES,
seed_data_en.STATS,
seed_data_en.PROJECTS,
seed_data_en.ARTICLES,
seed_data_en.TESTIMONIALS,
seed_data_en.FAQS,
seed_data_en.TEAM,
seed_data_en.TIMELINE,
seed_data_en.WHY,
[seed_data_en.BEFORE_AFTER],
seed_data_en.SEO_META,
]
def seed(force: bool = False) -> None: def seed(force: bool = False) -> None:
db = SessionLocal() db = SessionLocal()
@@ -86,19 +57,16 @@ def seed(force: bool = False) -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations() run_migrations()
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT): for model, rows, label in CONTENT:
if db.query(model).count() > 0: if db.query(model).count() > 0:
continue continue
for idx, row in enumerate(rows): for idx, row in enumerate(rows):
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه می‌شود payload = dict(row)
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else dict(row)
if hasattr(model, "position") and "position" not in payload: if hasattr(model, "position") and "position" not in payload:
payload["position"] = idx payload["position"] = idx
# بدنه مقاله (فارسی و انگلیسی) در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود # بدنه مقالات در seed به‌صورت لیست پاراگراف است — به سند تبدیل می‌شود
if model is models.Article: if model is models.Article and isinstance(payload.get("body"), list):
for body_key in ("body", "body_en"): payload["body"] = paragraphs_to_doc(payload["body"])
if isinstance(payload.get(body_key), list):
payload[body_key] = paragraphs_to_doc(payload[body_key])
db.add(model(**payload)) db.add(model(**payload))
if db.query(models.AdminUser).count() == 0: if db.query(models.AdminUser).count() == 0:
-580
View File
@@ -1,580 +0,0 @@
"""ترجمه انگلیسی داده‌های اولیه سایت — ساختار یک‌به‌یک با seed_data.py.
هر فهرست هم‌طول و هم‌ترتیب با معادل فارسی خود است تا seed.py بتواند با zip
ادغام کند و backfill مهاجرت بتواند سطرهای موجود را پر کند. فقط فیلدهای
`*_en` ملاک ادغام‌اند؛ کلیدهای هم‌نام با فارسی (مثل title یا body) صرفاً
برای خواندن راحت‌تر مترجم آمده‌اند و در ادغام نادیده گرفته می‌شوند.
فیلدهای تطبیق (slug، key، page_key، src) عیناً مقدار فارسی‌اند.
"""
from .seed_data import IMAGES
BRAND = {
"name": "گروه ساختمانی آرامش",
"name_en": "Aramesh Construction Group",
"address": "تهران، خیابان ولیعصر، برج آرامش، طبقه هجدهم",
"address_en": "Valiasr St., Aramesh Tower, 18th Floor, Tehran, Iran",
}
SERVICES = [
{"title": "طراحی معماری", "title_en": "Architectural Design", "desc": "خلق فرم‌های معاصر با نگاه ایرانی؛ از کانسپت اولیه تا نقشه‌های اجرایی و جزئیات نما.", "desc_en": "Contemporary forms with an Iranian eye — from the first concept to construction drawings and facade details.", "icon": "draft"},
{"title": "ساخت برج‌های مسکونی", "title_en": "Residential Towers", "desc": "اجرای برج‌های بلندمرتبه با استانداردهای سازه‌ای بین‌المللی و کیفیت تحویل بی‌نقص.", "desc_en": "High-rise execution built on international structural standards and a flawless handover quality.", "icon": "tower"},
{"title": "مجتمع‌های تجاری", "title_en": "Commercial Complexes", "desc": "طراحی و ساخت مراکز تجاری با مطالعه دقیق جریان مخاطب و بازدهی بلندمدت سرمایه.", "desc_en": "Retail destinations designed and built around precise visitor-flow studies and long-term returns.", "icon": "mall"},
{"title": "ویلاسازی", "title_en": "Private Villas", "desc": "ویلاهای اختصاصی هم‌نشین با طبیعت؛ محرمانگی، نور طبیعی و مصالح ماندگار.", "desc_en": "Bespoke villas in harmony with nature — privacy, natural light and enduring materials.", "icon": "villa"},
{"title": "بازسازی", "title_en": "Renovation", "desc": "بازآفرینی ساختمان‌های موجود با ارتقای سازه، نما و تجربه زندگی در فضا.", "desc_en": "Reimagining existing buildings with upgraded structure, facades and living experience.", "icon": "renew"},
{"title": "مدیریت پروژه", "title_en": "Project Management", "desc": "کنترل هم‌زمان زمان، هزینه و کیفیت با گزارش‌دهی شفاف در تمام مراحل ساخت.", "desc_en": "Controlling time, cost and quality in parallel with transparent reporting at every stage.", "icon": "manage"},
{"title": "سرمایه‌گذاری ساختمانی", "title_en": "Real Estate Investment", "desc": "ساختارهای مشارکت شفاف، ارزیابی ریسک و طراحی سبد ملکی متناسب با سرمایه شما.", "desc_en": "Transparent partnership structures, risk assessment and property portfolios tailored to your capital.", "icon": "invest"},
{"title": "محوطه‌سازی و لنداسکیپ", "title_en": "Landscape Design", "desc": "طراحی حیاط، بام سبز و مسیرهای دسترسی؛ ادامه معماری در فضای باز پروژه.", "desc_en": "Courtyards, green roofs and pathways — architecture carried on into the open air.", "icon": "landscape"},
]
STATS = [
{"value": 21, "suffix": "+", "suffix_en": "+", "label": "سال تجربه ساخت", "label_en": "Years of building experience"},
{"value": 47, "suffix": "", "suffix_en": "", "label": "پروژه تحویل‌شده", "label_en": "Projects delivered"},
{"value": 860, "suffix": "هزار", "suffix_en": "K", "label": "مترمربع زیربنا", "label_en": "Square meters built"},
{"value": 1200, "suffix": "+", "suffix_en": "+", "label": "خانواده ساکن", "label_en": "Resident families"},
]
PROJECTS = [
{
"slug": "aramesh-elahieh",
"title": "برج آرامش الهیه",
"title_en": "Aramesh Elahieh Tower",
"location": "تهران، الهیه",
"location_en": "Elahieh, Tehran",
"year": "۱۴۰۲",
"year_en": "2023",
"area": "۲۴٬۰۰۰ متر مربع",
"area_en": "24,000 m²",
"floors": "۱۸ طبقه",
"floors_en": "18 floors",
"units": "۵۶ واحد",
"units_en": "56 units",
"client": "سرمایه‌گذاری خصوصی",
"client_en": "Private investor",
"summary": "یک برج مسکونی لوکس با نمای سنگ تراورتن و پوسته دوپوسته‌ای که مصرف انرژی را تا ۳۵ درصد کاهش داده است.",
"summary_en": "A luxury residential tower with travertine cladding and a double-skin facade that cuts energy use by up to 35%.",
"body": [],
"body_en": [
"Aramesh Elahieh Tower was designed on a site with a natural northward slope — an opportunity that placed the lobby, pool and shared spaces on three different levels and gave every unit an unobstructed view of the city.",
"The outer shell combines light travertine stone with floor-to-ceiling triple-glazed windows. Copper-toned vertical louvers break the direct summer sun while letting winter light pour in.",
"Construction was completed in thirty-two months, and every unit was sold before handover.",
],
},
{
"slug": "parseh-mall",
"title": "مجتمع تجاری پارسه",
"title_en": "Parseh Shopping Mall",
"location": "اصفهان، مرکز شهر",
"location_en": "City center, Isfahan",
"year": "۱۴۰۱",
"year_en": "2022",
"area": "۳۸٬۵۰۰ متر مربع",
"area_en": "38,500 m²",
"floors": "۷ طبقه",
"floors_en": "7 floors",
"units": "۱۴۰ واحد تجاری",
"units_en": "140 retail units",
"client": "گروه سرمایه‌گذاری پارسه",
"client_en": "Parseh Investment Group",
"summary": "مرکز تجاری با آتریوم مرکزی و نورگیر سقفی که جریان مخاطب را به‌صورت طبیعی در همه ترازها توزیع می‌کند.",
"summary_en": "A retail center with a central atrium and roof light-wells that naturally distribute visitor flow across every level.",
"body": [],
"body_en": [
"A visitor-behavior study before design showed that the shortest vertical path was the key to the complex's commercial success — so the central atrium with diagonal escalators sits at the heart of the plan.",
"Low-E glazing on the roof light-well carries daylight down to level B2 and measurably cuts artificial lighting consumption.",
"The complex opened two months ahead of the contractual schedule.",
],
},
{
"slug": "daryakenar-villas",
"title": "ویلاهای دریاکنار",
"title_en": "Daryakenar Villas",
"location": "مازندران، نوشهر",
"location_en": "Nowshahr, Mazandaran",
"year": "۱۴۰۰",
"year_en": "2021",
"area": "۹٬۲۰۰ متر مربع",
"area_en": "9,200 m²",
"floors": "۲ طبقه",
"floors_en": "2 floors",
"units": "۱۲ ویلا",
"units_en": "12 villas",
"client": "مجموعه خصوصی دریاکنار",
"client_en": "Daryakenar Private Estate",
"summary": "دوازده ویلای اختصاصی با چیدمان شانه‌ای که برای هر واحد، دید مستقیم به دریا و محرمانگی کامل فراهم می‌کند.",
"summary_en": "Twelve bespoke villas in a comb layout that gives each unit a direct sea view and complete privacy.",
"body": [],
"body_en": [
"The comb layout of the villas is the result of dozens of view-angle studies: no villa overlooks another, and all of them open onto the sea.",
"The materials answer the humid northern climate: thermowood, exposed concrete and salt-resistant coatings on the facade metals.",
"The landscape was designed with native plants to keep annual irrigation demand to a minimum.",
],
},
{
"slug": "niavaran-twins",
"title": "برج دوقلوی نیاوران",
"title_en": "Niavaran Twin Towers",
"location": "تهران، نیاوران",
"location_en": "Niavaran, Tehran",
"year": "۱۳۹۹",
"year_en": "2020",
"area": "۳۱٬۷۰۰ متر مربع",
"area_en": "31,700 m²",
"floors": "۲۰ طبقه",
"floors_en": "20 floors",
"units": "۸۰ واحد",
"units_en": "80 units",
"client": "مشارکت با مالکان زمین",
"client_en": "Partnership with landowners",
"summary": "دو برج همسان با پادست مشترک که فضاهای رفاهی و پارکینگ‌ها را در یک تراز واحد ادغام می‌کند.",
"summary_en": "Two identical towers on a shared podium that merges amenities and parking into a single level.",
"body": [],
"body_en": [
"The two towers rest on a shared podium — a decision that let the amenities, event hall and parking be shared between the buildings and reduced the footprint on the garden.",
"The structure uses shear walls with post-tensioned slabs so the interior spans stay free and the floor plans remain customizable.",
],
},
{
"slug": "kish-garden-tower",
"title": "باغ‌برج کیش",
"title_en": "Kish Garden Tower",
"location": "کیش، مروارید",
"location_en": "Marvàrid, Kish Island",
"year": "۱۴۰۵ (پیش‌بینی)",
"year_en": "2026 (expected)",
"area": "۱۸٬۴۰۰ متر مربع",
"area_en": "18,400 m²",
"floors": "۱۴ طبقه",
"floors_en": "14 floors",
"units": "۶۲ واحد اقامتی",
"units_en": "62 hospitality units",
"client": "صندوق پروژه‌محور آرامش",
"client_en": "Aramesh Project Fund",
"summary": "باغ‌برجی اقامتی با تراس‌های سبز پیوسته که در حال ساخت است؛ پیشرفت فیزیکی پروژه ۴۸ درصد.",
"summary_en": "A hospitality garden tower with continuous green terraces, currently under construction — 48% complete.",
"body": [],
"body_en": [
"The continuous green terraces form a living skin that measurably lowers the facade surface temperature in the island's hot climate.",
"The structure has reached the ninth floor, and facade installation has begun on the southern elevation.",
],
},
{
"slug": "aramesh-business-center",
"title": "مرکز تجاری آرامش تهران",
"title_en": "Aramesh Business Center Tehran",
"location": "تهران، سعادت‌آباد",
"location_en": "Saadatabad, Tehran",
"year": "۱۴۰۶ (پیش‌بینی)",
"year_en": "2027 (expected)",
"area": "۲۷٬۳۰۰ متر مربع",
"area_en": "27,300 m²",
"floors": "۱۲ طبقه",
"floors_en": "12 floors",
"units": "۹۰ دفتر اداری",
"units_en": "90 offices",
"client": "مشارکت با هلدینگ آرامش",
"client_en": "Partnership with Aramesh Holding",
"summary": "مجتمع اداری-تجاری با هسته مرکزی شفاف و لابی دوبلکس؛ در مرحله اجرای سفت‌کاری.",
"summary_en": "An office-retail complex with a transparent central core and double-height lobby; currently in structural works.",
"body": [],
"body_en": [
"The space planning was built around flexible offices: every level can be divided into two, three or six independent units.",
"Mechanical installations are progressing in parallel with the structural works to shorten the overall timeline.",
],
},
]
BEFORE_AFTER = {
"title": "از کانسپت تا خلق واقعیت",
"title_en": "From Concept to Reality",
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل می‌شود.",
"lead_en": "Drag the handle to see how a building facade turns from a raw concept into its modern form.",
"before_label": "کانسپت اولیه",
"before_label_en": "Initial concept",
"after_label": "پروژه تکمیل‌شده",
"after_label_en": "Completed project",
}
TIMELINE = [
{"year": "۱۳۸۳", "year_en": "2004", "title": "آغاز مسیر", "title_en": "The Journey Begins", "desc": "تأسیس دفتر معماری آرامش با تیمی چهارنفره و نخستین پروژه بازسازی در تهران.", "desc_en": "Aramesh Architecture Office is founded by a four-person team, taking on its first renovation project in Tehran.", "image": IMAGES[2]},
{"year": "۱۳۸۵", "year_en": "2006", "title": "نخستین ساخت مستقل", "title_en": "First Independent Build", "desc": "تحویل اولین ساختمان مسکونی هفت‌طبقه؛ آغاز همکاری با پیمانکاران سازه‌ای بزرگ.", "desc_en": "Delivery of the first seven-story residential building and the start of partnerships with major structural contractors.", "image": IMAGES[0]},
{"year": "۱۳۸۷", "year_en": "2008", "title": "ورود به بازار تجاری", "title_en": "Entering Commercial Market", "desc": "طراحی و اجرای اولین مجتمع تجاری با رویکرد بازدهی سرمایه و جریان مخاطب.", "desc_en": "Design and execution of the first commercial complex, driven by capital returns and visitor flow.", "image": IMAGES[1]},
{"year": "۱۳۸۹", "year_en": "2010", "title": "استاندارد بین‌المللی", "title_en": "International Standards", "desc": "استقرار نظام کنترل کیفیت و دریافت گواهی‌نامه‌های مدیریت پروژه.", "desc_en": "A quality-control system is established and project-management certifications are earned.", "image": IMAGES[4]},
{"year": "۱۳۹۵", "year_en": "2016", "title": "بلندمرتبه‌سازی", "title_en": "Going High-Rise", "desc": "آغاز ساخت نخستین برج بیست‌طبقه شرکت در شمال تهران.", "desc_en": "Construction begins on the company's first twenty-story tower in northern Tehran.", "image": IMAGES[3]},
{"year": "۱۴۰۳", "year_en": "Today", "title": "گروه ساختمانی آرامش امروز", "title_en": "Aramesh Construction Group Today", "desc": "پرتفوی ۴۷ پروژه، دفاتر در سه شهر و صندوق سرمایه‌گذاری ساختمانی اختصاصی.", "desc_en": "A portfolio of 47 projects, offices in three cities and a dedicated real estate investment fund.", "image": IMAGES[0]},
]
WHY = [
{"title": "کیفیت بدون مصالحه", "title_en": "Quality Without Compromise", "desc": "انتخاب مصالح و مجریان بر اساس ماندگاری چنددهه‌ای، نه سریع‌ترین تحویل.", "desc_en": "Materials and subcontractors are chosen for decades of durability, not the fastest delivery."},
{"title": "شفافیت مالی کامل", "title_en": "Full Financial Transparency", "desc": "گزارش ماهانه هزینه‌ها و پیشرفت فیزیکی برای همه شرکای پروژه.", "desc_en": "Monthly reporting of costs and physical progress for every project partner."},
{"title": "تحویل به‌موقع", "title_en": "On-Time Delivery", "desc": "۹۴٪ پروژه‌های ما در بازه زمانی قراردادی یا زودتر تحویل داده شده‌اند.", "desc_en": "94% of our projects have been delivered on or ahead of the contractual schedule."},
{"title": "طراحی اختصاصی", "title_en": "Bespoke Design", "desc": "هیچ نقشه‌ای دو بار اجرا نمی‌شود؛ هر پروژه هویت معماری خودش را دارد.", "desc_en": "No drawing is ever executed twice — every project has its own architectural identity."},
{"title": "خدمات پس از تحویل", "title_en": "After-Sales Service", "desc": "پنج سال پشتیبانی فنی و نگهداری تأسیسات پس از تحویل کلید.", "desc_en": "Five years of technical support and building-systems maintenance after handover."},
{"title": "پایداری و انرژی", "title_en": "Sustainability & Energy", "desc": "طراحی پوسته و تأسیسات با هدف کاهش مصرف انرژی تا ۳۵ درصد.", "desc_en": "Envelope and building systems designed to cut energy consumption by up to 35%."},
]
TESTIMONIALS = [
{"name": "مهندس سارا کیانی", "name_en": "Sara Kiani", "role": "سرمایه‌گذار پروژه پارسه", "role_en": "Investor, Parseh Mall project", "quote": "گزارش‌های ماهانه گروه ساختمانی آرامش آن‌قدر دقیق بود که هیچ‌وقت نیاز نشد نگران سرمایه‌ام باشم. پروژه دو ماه زودتر از موعد به بهره‌برداری رسید.", "quote_en": "Aramesh's monthly reports were so precise that I never had to worry about my investment. The project opened two months ahead of schedule."},
{"name": "دکتر امیرحسین راد", "name_en": "Dr. Amirhossein Rad", "role": "ساکن برج الهیه", "role_en": "Resident, Elahieh Tower", "quote": "کیفیت اجرا در جزئیاتی دیده می‌شود که معمولاً کسی به آن‌ها فکر نمی‌کند؛ از عایق صوتی پنجره‌ها تا نور غیرمستقیم راهروها.", "quote_en": "The build quality shows in details nobody usually thinks about — from the acoustic insulation of the windows to the indirect lighting in the corridors."},
{"name": "مریم دلاور", "name_en": "Maryam Delavar", "role": "مدیر مجموعه ویلاهای دریاکنار", "role_en": "Manager, Daryakenar Villas", "quote": "همکاری با تیم طراحی، تجربه‌ای واقعاً حرفه‌ای بود. ایده‌های ما را شنیدند و به زبان معماری ترجمه کردند.", "quote_en": "Working with the design team was a truly professional experience. They listened to our ideas and translated them into architecture."},
]
TEAM = [
{"name": "کامران آرامش", "name_en": "Kamran Aramesh", "role": "بنیان‌گذار و مدیرعامل", "role_en": "Founder & CEO", "bio": "بیش از سه دهه تجربه در ساخت بلندمرتبه.", "bio_en": "Three decades of high-rise construction experience."},
{"name": "نگار شریفی", "name_en": "Negar Sharifi", "role": "مدیر ارشد طراحی", "role_en": "Chief Design Officer", "bio": "معمار ارشد، فارغ‌التحصیل دانشگاه تهران و میلان.", "bio_en": "Senior architect, graduate of the University of Tehran and Milan."},
{"name": "بهزاد مرادی", "name_en": "Behzad Moradi", "role": "مدیر اجرایی پروژه‌ها", "role_en": "Director of Project Execution", "bio": "مسئول کنترل کیفیت و زمان‌بندی همه کارگاه‌ها.", "bio_en": "Responsible for quality control and scheduling across all sites."},
{"name": "الهام نیک‌آیین", "name_en": "Elham Nikaein", "role": "مدیر سرمایه‌گذاری", "role_en": "Investment Director", "bio": "طراح ساختارهای مشارکت و تحلیل بازدهی ملکی.", "bio_en": "Designs partnership structures and analyzes property returns."},
]
ARTICLES = [
{
"slug": "natural-light",
"title": "معماری آرام: چرا نور طبیعی گران‌ترین مصالح ساختمان است؟",
"title_en": "Quiet Architecture: Why Is Natural Light the Most Expensive Building Material?",
"date": "۲۴ مرداد ۱۴۰۳",
"date_en": "August 14, 2024",
"category": "معماری",
"category_en": "Architecture",
"read": "۶ دقیقه",
"read_en": "6 min read",
"excerpt": "نور طبیعی نه‌تنها کیفیت زندگی در فضا را تعیین می‌کند، بلکه مستقیماً بر مصرف انرژی و ارزش بلندمدت ملک اثر می‌گذارد.",
"excerpt_en": "Natural light not only defines the quality of life in a space — it directly affects energy consumption and the long-term value of the property.",
"body": [],
"body_en": [
"In the construction market, a unit's value is usually measured in square meters. But our experience over the past twenty years shows that the quality of light is more decisive than a few extra meters.",
"Building orientation, bay depth and floor-to-ceiling height are the three main variables of natural light. The decision on all three is made before the first plan is drawn — and is hardly reversible afterwards.",
"In Aramesh Elahieh Tower, rotating the building mass 12 degrees off north added two hours of useful winter light to the units — a cost-free decision with a decades-long effect.",
"Our advice for choosing a unit is simple: visit at mid-morning and late afternoon, and judge the quality of light at exactly the hours you are actually home.",
],
},
{
"slug": "real-estate-portfolio",
"title": "راهنمای انتخاب سبد سرمایه‌گذاری ملکی در بازار امروز",
"title_en": "A Guide to Building a Real Estate Portfolio in Today's Market",
"date": "۱۰ مرداد ۱۴۰۳",
"date_en": "July 31, 2024",
"category": "سرمایه‌گذاری",
"category_en": "Investment",
"read": "۹ دقیقه",
"read_en": "9 min read",
"excerpt": "سه معیار کلیدی برای تشکیل سبدی متعادل از دارایی ملکی، بر اساس افق زمانی و سطح ریسک شما.",
"excerpt_en": "Three key criteria for building a balanced property portfolio, based on your time horizon and risk appetite.",
"body": [],
"body_en": [
"A balanced property portfolio mixes assets with different liquidity — not all capital in one long-term project, not all in ready units.",
"Time horizon is the first criterion. If your plan is shorter than two years, pre-purchasing in a project that has just started is a risky choice.",
"The second criterion is reporting transparency. Without monthly reports of physical and financial progress, assessing the state of your capital is practically impossible.",
"The third criterion is diversity of use. A residential–commercial mix measurably reduces the volatility of portfolio returns in downturns.",
],
},
{
"slug": "facade-quality",
"title": "پنج نکته فنی که کیفیت نما را پس از ده سال مشخص می‌کند",
"title_en": "Five Technical Details That Reveal Facade Quality After Ten Years",
"date": "۲۸ تیر ۱۴۰۳",
"date_en": "July 18, 2024",
"category": "اجرا",
"category_en": "Construction",
"read": "۵ دقیقه",
"read_en": "5 min read",
"excerpt": "نمای ساختمان در سال اول زیبا به نظر می‌رسد؛ تفاوت کیفیت اجرا در سال دهم آشکار می‌شود.",
"excerpt_en": "A facade looks beautiful in its first year; the difference in workmanship shows in its tenth.",
"body": [],
"body_en": [
"Mechanical fixings of facade stone matter more than the stone itself. Adhesive fixing has a short life in Iran's hot-and-cold climate.",
"Contraction joints must be designed on the drawing, not decided on site — their absence is the first cause of horizontal facade cracks.",
"Window perimeter waterproofing is the most frequent point of failure. Installing waterproofing tape before the sill goes in removes that risk.",
"Hot-dip galvanized sub-structures cost marginally more than painted ones — and last many times longer.",
"Finally, take the annual facade inspection seriously: repairing one joint costs next to nothing compared with redoing the whole facade.",
],
},
{
"slug": "commercial-yield",
"title": "چگونه بازدهی یک پروژه تجاری را پیش از ساخت تخمین بزنیم؟",
"title_en": "How to Estimate a Commercial Project's Yield Before Building It",
"date": "۱۵ تیر ۱۴۰۳",
"date_en": "July 5, 2024",
"category": "سرمایه‌گذاری",
"category_en": "Investment",
"read": "۷ دقیقه",
"read_en": "7 min read",
"excerpt": "مدل ساده‌ای برای برآورد بازدهی، بر پایه جریان مخاطب، ترکیب واحدها و نرخ اجاره منطقه.",
"excerpt_en": "A simple yield-estimation model based on visitor flow, unit mix and local rental rates.",
"body": [],
"body_en": [
"Estimating commercial yield starts with three numbers: daily visitor flow, the rental rate per square meter in the area, and the net-to-gross floor ratio.",
"A common mistake is ignoring the upper levels. Without a well-designed vertical path, levels three and above usually fail to reach even half of the ground-floor rate.",
"Finally, write the conservative scenario too — if the project still makes sense in the pessimistic case, the investment is a rational one.",
],
},
{
"slug": "facade-materials",
"title": "مصالح نما: مقایسه سنگ، کامپوزیت و ترموود در اقلیم ایران",
"title_en": "Facade Materials: Stone, Composite and Thermowood in Iran's Climate",
"date": "۲ تیر ۱۴۰۳",
"date_en": "June 22, 2024",
"category": "اجرا",
"category_en": "Construction",
"read": "۸ دقیقه",
"read_en": "8 min read",
"excerpt": "هر مصالح نما در اقلیم متفاوتی درست کار می‌کند؛ انتخاب اشتباه، هزینه نگهداری را چند برابر می‌کند.",
"excerpt_en": "Every facade material works in the right climate; the wrong choice multiplies maintenance costs.",
"body": [],
"body_en": [
"In a dry climate, stone has the best durability — but its weight demands a precise sub-structure and structural calculations.",
"Composite is the fast, light choice, provided you pick a fire-rated grade and account for thermal expansion along the sheet.",
"In the humid northern climate, thermowood is the best aesthetic answer — with a twice-yearly maintenance schedule.",
],
},
{
"slug": "renovate-or-rebuild",
"title": "بازسازی یا نوسازی؟ معیارهای تصمیم‌گیری برای مالکان",
"title_en": "Renovate or Rebuild? Decision Criteria for Property Owners",
"date": "۲۰ خرداد ۱۴۰۳",
"date_en": "June 9, 2024",
"category": "معماری",
"category_en": "Architecture",
"read": "۶ دقیقه",
"read_en": "6 min read",
"excerpt": "اگر عمر سازه و ضوابط شهری اجازه دهند، بازسازی می‌تواند بازدهی بالاتری از تخریب کامل داشته باشد.",
"excerpt_en": "If the structure's age and city regulations allow, renovation can outperform full demolition and rebuild.",
"body": [],
"body_en": [
"First the structure is assessed: if seismic retrofitting costs more than 40% of a new build, rebuilding is the more rational option.",
"The second criterion is today's urban regulations. Sometimes demolition grants you less permissible floor area — which changes the decision entirely.",
"In renovation, the greatest added value comes from three places: the facade, the entrance and the re-planning of the interior.",
],
},
]
FAQS = [
{"question": "حداقل سرمایه برای مشارکت در پروژه‌های گروه ساختمانی آرامش چقدر است؟", "question_en": "What is the minimum capital to join Aramesh Construction Group projects?", "answer": "ساختار مشارکت هر پروژه متفاوت است، اما معمولاً واحدهای سرمایه‌گذاری از سطح مشخصی آغاز می‌شوند و جزئیات دقیق در جلسه ارزیابی اولیه ارائه می‌شود.", "answer_en": "Each project's partnership structure is different, but investment units usually start from a defined level, and the exact details are presented in the initial assessment meeting."},
{"question": "مدت زمان معمول ساخت یک برج مسکونی چقدر است؟", "question_en": "How long does it take to build a residential tower?", "answer": "بسته به تعداد طبقات و شرایط زمین، میانگین اجرای یک برج مسکونی بین ۲۸ تا ۴۲ ماه از زمان صدور پروانه تا تحویل کلید است.", "answer_en": "Depending on the number of floors and site conditions, a residential tower takes on average 28 to 42 months from permit issuance to key handover."},
{"question": "آیا امکان تغییر نقشه داخلی واحد وجود دارد؟", "question_en": "Can the unit's interior layout be customized?", "answer": "بله. تا پیش از پایان مرحله سفت‌کاری، امکان شخصی‌سازی پلان داخلی با هماهنگی تیم طراحی و تأیید سازه فراهم است.", "answer_en": "Yes. Until the structural phase is complete, the interior plan can be customized in coordination with the design team and structural approval."},
{"question": "خدمات پس از تحویل شامل چه مواردی می‌شود؟", "question_en": "What does the after-sales service include?", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دوره‌ای سالانه و تضمین اجرای نما و عایق‌کاری.", "answer_en": "Five years of technical support for mechanical and electrical systems, an annual inspection, and warranty on facade work and insulation."},
{"question": "پروژه‌ها در چه شهرهایی اجرا می‌شوند؟", "question_en": "In which cities do you build?", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژه‌ای نیز در جزیره کیش در دست ساخت است.", "answer_en": "Tehran, Isfahan and the northern coasts are currently our main regions, and a project is also under construction on Kish Island."},
]
NAV_ITEMS = [
{"label": "صفحه اصلی", "label_en": "Home", "href": "/"},
{"label": "درباره ما", "label_en": "About", "href": "/about"},
{"label": "خدمات", "label_en": "Services", "href": "/services"},
{"label": "پروژه‌ها", "label_en": "Projects", "href": "/projects"},
{"label": "سرمایه‌گذاری", "label_en": "Investment", "href": "/investment"},
{"label": "وبلاگ", "label_en": "Blog", "href": "/blog"},
{"label": "سوالات متداول", "label_en": "FAQ", "href": "/faq"},
{"label": "تماس با ما", "label_en": "Contact", "href": "/contact"},
]
HERO = {
"kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
"kicker_en": "Since 2004 — Tehran, Isfahan, Northern Coasts",
"title": "ساختمانی که آرامش را معماری می‌کند",
"title_en": "A building that architects tranquility",
"title_highlight": "معماری",
"title_highlight_en": "architects",
"subtitle": "طراحی، ساخت و سرمایه‌گذاری در پروژه‌های مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده می‌شود و در سال‌ها دوام می‌آورد.",
"subtitle_en": "Design, construction and investment in luxury residential and commercial projects — to a standard you can see in the details and trust for years.",
"badge_value": "۴۷",
"badge_value_en": "47",
"badge_label": "پروژه تحویل‌شده در بیست‌ویک سال فعالیت حرفه‌ای",
"badge_label_en": "Projects delivered in twenty-one years of professional work",
"cta_primary_label": "مشاهده پروژه‌ها",
"cta_primary_label_en": "View Projects",
"cta_secondary_label": "مشاوره سرمایه‌گذاری",
"cta_secondary_label_en": "Investment Advisory",
"scroll_label": "اسکرول کنید",
"scroll_label_en": "Scroll",
}
STORY = {
"title": "درباره ی ما",
"title_en": "About Us",
"paragraphs": [],
"paragraphs_en": [
"Aramesh Construction Group began as a small architecture office and is today one of Iran's recognized names in luxury construction. Through all these years one principle has not changed: a building must be made for living, not for a quick sale.",
"Every project starts with a study of the land, light, climate and the residents' way of life; then the design, structural and execution teams carry it through a single process to key handover.",
],
"quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی می‌سازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
"quote_en": "\u201cA building, for us, is not just brick and concrete; it is a commitment to the quality of life of its residents. We build spaces that keep their value and identity as time passes.\u201d",
"quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
"quote_author_en": "Kamran Aramesh — CEO, Aramesh Construction Group",
"stat1_value": "۹۴٪",
"stat1_value_en": "94%",
"stat1_label": "تحویل به‌موقع پروژه‌ها",
"stat1_label_en": "On-time project delivery",
"stat2_value": "۵ سال",
"stat2_value_en": "5 years",
"stat2_label": "پشتیبانی پس از تحویل",
"stat2_label_en": "Post-handover support",
"link_label": "داستان کامل ما",
"link_label_en": "Our full story",
"bento": [],
"bento_en": [
{"src": IMAGES[0], "alt": "Facade of an Aramesh residential tower in Tehran"},
{"src": IMAGES[1], "alt": "Central atrium of Parseh Shopping Mall"},
{"src": IMAGES[2], "alt": "Villas built on the northern coasts"},
{"src": IMAGES[3], "alt": "Night view of the Niavaran Twin Towers"},
],
}
SECTION_HEADINGS = [
{"key": "services", "title_en": "Services", "lead_en": "From the first line on paper to key handover, capital management and landscape design — every stage under one roof and one quality-control system."},
{"key": "featured_projects", "title_en": "Featured Projects", "lead_en": "The work that says the most about our standard — both the delivered projects and the ones under construction today.", "cta_label_en": "All Projects"},
{"key": "gallery", "title_en": "Gallery", "lead_en": "Frames from the sites and delivered projects of Aramesh Construction Group."},
{"key": "timeline", "title_en": "Our Path", "lead_en": "From a four-person office to 47 projects. Walk through the timeline of our work."},
{"key": "why_us", "title_en": "Why Aramesh Construction Group", "lead_en": "Six reasons clients and investors come back to us a second time."},
{"key": "testimonials", "title_en": "Client Testimonials"},
{"key": "team", "title_en": "Our Team", "lead_en": "The people who make each project's key decisions — and answer for them."},
{"key": "articles", "label_en": "Blog", "title_en": "Latest Articles", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market.", "cta_label_en": "All Articles"},
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Short answers to the questions investors and buyers ask most."},
{"key": "investment_models", "title_en": "Partnership Models", "lead_en": "Depending on your capital, time horizon and risk appetite, one of these three paths is right for you."},
{"key": "process", "title_en": "How We Work", "lead_en": "Four transparent steps, from the first meeting to key handover."},
]
PAGE_HEROES = [
{"key": "about", "title_en": "About Us", "lead_en": "Aramesh Construction Group is a team of architects, structural engineers and project managers working in luxury construction since 2004."},
{"key": "services", "title_en": "Services", "lead_en": "Eight specialized fields covering a building project's full cycle, from idea to operation."},
{"key": "projects", "title_en": "Projects", "lead_en": "A collection of residential towers, commercial centers and private villas — from delivered projects to today's active sites."},
{"key": "investment", "title_en": "Investment", "lead_en": "Three clear paths into the luxury construction market, with transparent financial structures and regular reporting."},
{"key": "blog", "title_en": "Blog", "lead_en": "Notes from the architecture, execution and investment teams of Aramesh Construction Group on what's happening on site and in the market."},
{"key": "faq", "title_en": "Frequently Asked Questions", "lead_en": "Before you call, the answer to your question might be right here. If not, we would be glad to talk directly."},
{"key": "contact", "title_en": "Contact Us", "lead_en": "For construction consultancy, investment or a project visit, our teams in three cities are ready to respond."},
]
INVESTMENT_MODELS = [
{
"title": "مشارکت در ساخت",
"title_en": "Joint Construction Partnership",
"desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
"desc_en": "The landowner and Aramesh share the project's profit; execution, permits and sales are on us.",
"points": [],
"points_en": ["Free land assessment", "Transparent contract with performance guarantee", "Monthly progress reporting"],
},
{
"title": "صندوق پروژه‌محور",
"title_en": "Project-Based Fund",
"desc": "سرمایه‌گذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبه‌شده در پایان دوره.",
"desc_en": "Investment in a specific project with purchasable units and a calculated yield at the end of the term.",
"points": [],
"points_en": ["Minimum 24-month term", "Quarterly valuation reports", "Investment units are transferable"],
},
{
"title": "پیش‌خرید واحد",
"title_en": "Pre-Purchase",
"desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحله‌ای.",
"desc_en": "Buying a residential or commercial unit in the early stages of construction, at a pre-completion price with staged payments.",
"points": [],
"points_en": ["Payments staged to match progress", "Interior layout customization", "Delivery-time guarantee"],
},
]
PROCESS_STEPS = [
{"step": "۰۱", "step_en": "01", "title": "مطالعه و امکان‌سنجی", "title_en": "Study & Feasibility", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه.", "desc_en": "Site review, urban regulations and an economic yield analysis of the project."},
{"step": "۰۲", "step_en": "02", "title": "طراحی و فاز اجرایی", "title_en": "Design & Construction Drawings", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشه‌های اجرایی.", "desc_en": "Architectural concept, structural and MEP design, and construction documentation."},
{"step": "۰۳", "step_en": "03", "title": "اجرا و کنترل کیفیت", "title_en": "Execution & Quality Control", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه.", "desc_en": "Construction with continuous supervision, monthly progress reports and cost control."},
{"step": "۰۴", "step_en": "04", "title": "تحویل و پشتیبانی", "title_en": "Handover & Support", "desc": "تحویل کلید، آموزش بهره‌برداری و پنج سال پشتیبانی فنی.", "desc_en": "Key handover, operating walkthrough and five years of technical support."},
]
OFFICES = [
{"city": "تهران", "city_en": "Tehran", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "address_en": "Valiasr St., Aramesh Tower, 18th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸", "hours_en": "Saturday–Wednesday, 9 AM–6 PM"},
{"city": "اصفهان", "city_en": "Isfahan", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "address_en": "Chaharbagh Bala St., Parseh Complex, 6th Floor", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷", "hours_en": "Saturday–Wednesday, 9 AM–5 PM"},
{"city": "نوشهر", "city_en": "Nowshahr", "address": "بلوار دریا، دفتر پروژه دریاکنار", "address_en": "Darya Blvd., Daryakenar Project Office", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶", "hours_en": "Saturday–Thursday, 10 AM–4 PM"},
]
GALLERY_IMAGES = [
{"src": IMAGES[0], "alt_en": "Aramesh Construction Group project, No. 1"},
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 2"},
{"src": IMAGES[2], "alt_en": "Aramesh Construction Group project, No. 3"},
{"src": IMAGES[3], "alt_en": "Aramesh Construction Group project, No. 4"},
{"src": IMAGES[4], "alt_en": "Aramesh Construction Group project, No. 5"},
{"src": IMAGES[1], "alt_en": "Aramesh Construction Group project, No. 6"},
]
STATUS_LABELS = [
{"key": "done", "label_en": "Completed"},
{"key": "progress", "label_en": "In Progress"},
]
CONTACT_SECTION = {
"label": "تماس با ما",
"label_en": "Contact Us",
"title": "پروژه‌تان را با ما در میان بگذارید",
"title_en": "Tell Us About Your Project",
"lead": "شماره‌تان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس می‌گیرد.",
"lead_en": "Leave your number — our expert will call you within one business day for a free consultation session.",
"address_label": "دفتر مرکزی",
"address_label_en": "Head Office",
"phone_label": "تلفن",
"phone_label_en": "Phone",
"email_label": "ایمیل",
"email_label_en": "Email",
"name_label": "نام",
"name_label_en": "Name",
"name_placeholder": "نام و نام خانوادگی",
"name_placeholder_en": "Full name",
"phone_field_label": "شماره تلفن",
"phone_field_label_en": "Phone number",
"note_label": "توضیحات",
"note_label_en": "Details",
"note_optional_label": "(اختیاری)",
"note_optional_label_en": "(optional)",
"note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
"note_placeholder_en": "Tell us about your project or request…",
"submit_label": "ارسال درخواست مشاوره",
"submit_label_en": "Request a Consultation",
"sending_label": "در حال ارسال…",
"sending_label_en": "Sending…",
"success_title": "درخواست شما ثبت شد",
"success_title_en": "Your Request Was Received",
"success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما به‌زودی با شما تماس می‌گیرند.",
"success_text_en": "Thank you for your trust. Our experts will contact you shortly.",
"success_button_label": "ثبت درخواست جدید",
"success_button_label_en": "Submit Another Request",
}
NEWSLETTER = {
"title": "خبرنامه گروه ساختمانی آرامش",
"title_en": "Aramesh Construction Group Newsletter",
"lead": "هر ماه یک ایمیل: پروژه‌های جدید، فرصت‌های سرمایه‌گذاری و یادداشت‌های تیم معماری.",
"lead_en": "One email a month: new projects, investment opportunities and notes from the architecture team.",
"placeholder": "نشانی ایمیل شما",
"placeholder_en": "Your email address",
"button_label": "عضویت",
"button_label_en": "Subscribe",
"sending_label": "در حال ارسال…",
"sending_label_en": "Sending…",
"success_text": "عضویت شما ثبت شد.",
"success_text_en": "Your subscription has been registered.",
}
FOOTER = {
"about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمان‌هایی می‌سازد که قرار است چند نسل در آن‌ها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایه‌گذاری شفاف.",
"about_text_en": "Since 2004, Aramesh Construction Group has been building for generations to come: calm architecture, precise execution and transparent investment.",
"pages_heading": "صفحات",
"pages_heading_en": "Pages",
"services_heading": "خدمات",
"services_heading_en": "Services",
"contact_heading": "ارتباط با ما",
"contact_heading_en": "Contact",
"copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
"copyright_en": "© 2025 Aramesh Construction Group — All rights reserved.",
"tagline": "طراحی و ساخت با دقتِ معماری",
"tagline_en": "Designed and built with architectural precision",
"socials_en": [
{"label": "Instagram", "href": "#", "icon": "instagram"},
{"label": "LinkedIn", "href": "#", "icon": "linkedin"},
{"label": "Telegram", "href": "#", "icon": "telegram"},
],
}
SEO_META = [
{"page_key": "home", "title_en": "Aramesh Construction Group | Luxury Residential & Commercial Buildings", "description_en": "Design, construction and investment in luxury residential and commercial projects in Iran — 21 years of experience, 47 delivered projects."},
{"page_key": "about", "title_en": "About Us | Aramesh Construction Group", "description_en": "Architects, structural engineers and project managers in luxury construction since 2004."},
{"page_key": "services", "title_en": "Services | Aramesh Construction Group", "description_en": "Architectural design, high-rise construction, commercial complexes, villas, renovation and project management."},
{"page_key": "projects", "title_en": "Projects | Aramesh Construction Group", "description_en": "Residential towers, commercial centers and private villas — delivered projects and active sites."},
{"page_key": "investment", "title_en": "Investment | Aramesh Construction Group", "description_en": "Three transparent paths into the luxury construction market: joint construction, project-based funds and pre-purchase."},
{"page_key": "blog", "title_en": "Blog | Aramesh Construction Group", "description_en": "Articles on architecture, construction and real estate investment from the Aramesh team."},
{"page_key": "faq", "title_en": "FAQ | Aramesh Construction Group", "description_en": "Short answers to the most frequent questions from investors and buyers."},
{"page_key": "contact", "title_en": "Contact Us | Aramesh Construction Group", "description_en": "Get in touch for construction consultancy, investment or a project visit — offices in Tehran, Isfahan and Nowshahr."},
]
+28 -225
View File
@@ -2,11 +2,8 @@
فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند. فرانت‌اند یک SPA است؛ خزنده‌های اجتماعی (تلگرام، واتس‌اپ و...) جاوااسکریپت اجرا نمی‌کنند.
این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های این ماژول همان index.html بیلد فرانت‌اند را می‌خواند و پیش از ارسال، تگ‌های
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق می‌کند — title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق می‌کند —
بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند. بازدیدکننده‌های عادی همان SPA همیشگی را می‌بینند و خزنده‌ها متای درست را می‌خوانند.
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو می‌شود؛
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی می‌شوند.
""" """
import html import html
@@ -25,10 +22,6 @@ from .database import get_db
router = APIRouter() router = APIRouter()
STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"] STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
# صفحاتی که متای آن‌ها از seo_meta خوانده می‌شود — کلید همان page_key است
META_KEYS = ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
FALLBACK_FA_BRAND = "گروه ساختمانی آرامش"
FALLBACK_EN_BRAND = "Aramesh Construction Group"
def _absolute(path: str) -> str: def _absolute(path: str) -> str:
@@ -40,29 +33,6 @@ def _absolute(path: str) -> str:
return f"{site}{path}" if site else path return f"{site}{path}" if site else path
def _en_path(path: str) -> str:
"""مسیر انگلیسی معادل یک مسیر فارسی — / و /en یکدیگرند."""
return "/en" if path == "/" else f"/en{path}"
def _fa_path(path: str) -> str:
"""مسیر فارسی معادل یک مسیر انگلیسی."""
if path == "/en":
return "/"
return path[3:] if path.startswith("/en/") else path
def _alternates(path: str, lang: str) -> str:
"""تگ‌های hreflang متقابل فارسی/انگلیسی برای head."""
fa = path if lang == "fa" else _fa_path(path)
en = _en_path(path) if lang == "fa" else path
return (
f'<link rel="alternate" hreflang="fa" href="{html.escape(_absolute(fa))}" />\n'
f' <link rel="alternate" hreflang="en" href="{html.escape(_absolute(en))}" />\n'
f' <link rel="alternate" hreflang="x-default" href="{html.escape(_absolute(fa))}" />'
)
def _read_index() -> str | None: def _read_index() -> str | None:
try: try:
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8") return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
@@ -94,19 +64,17 @@ def _shell(
title: str, title: str,
description: str, description: str,
path: str, path: str,
lang: str = "fa",
og_image: str = "", og_image: str = "",
og_type: str = "website", og_type: str = "website",
jsonld: dict | None = None, jsonld: dict | None = None,
noscript: str = "", noscript: str = "",
) -> HTMLResponse: ) -> HTMLResponse:
"""index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی.""" """index.html فرانت‌اند با متای تزریق‌شده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND) title = title.strip() or "گروه ساختمانی آرامش"
description = description.strip() description = description.strip()
canonical = _absolute(path) canonical = _absolute(path)
image = _absolute(og_image) image = _absolute(og_image)
esc = html.escape esc = html.escape
alternates = _alternates(path, lang)
# تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود # تگ title جای تگ موجود index.html را می‌گیرد و در بلوک تکرار نمی‌شود
tags = [ tags = [
@@ -115,7 +83,6 @@ def _shell(
f'<meta property="og:description" content="{esc(description)}" />', f'<meta property="og:description" content="{esc(description)}" />',
f'<meta property="og:type" content="{esc(og_type)}" />', f'<meta property="og:type" content="{esc(og_type)}" />',
f'<meta property="og:url" content="{esc(canonical)}" />', f'<meta property="og:url" content="{esc(canonical)}" />',
f'<meta property="og:locale" content="{"en_US" if lang == "en" else "fa_IR"}" />',
f'<meta name="twitter:card" content="summary_large_image" />', f'<meta name="twitter:card" content="summary_large_image" />',
] ]
if image: if image:
@@ -128,61 +95,28 @@ def _shell(
raw = _read_index() raw = _read_index()
if raw is None: if raw is None:
# بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند # بیلد فرانت‌اند سوار نشده — صفحه حداقلی با خودِ متا تا خزنده‌ها دست خالی نمانند
lang_attr = 'lang="en" dir="ltr"' if lang == "en" else 'lang="fa" dir="rtl"'
body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>" body = f"<h1>{esc(title)}</h1><p>{esc(description)}</p>"
return HTMLResponse( return HTMLResponse(
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">' f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
f"<title>{esc(title)}</title>{alternates}{block}</head><body>{body}</body></html>" f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
) )
if lang == "en":
# پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود
raw = re.sub(r'<html[^>]*>', '<html lang="en" dir="ltr">', raw, count=1)
raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND)
raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"')
# description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود
raw = re.sub(r'<meta name="description"[^>]*/>\s*', '', raw, count=1)
# og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود
og_locale = "en_US" if lang == "en" else "fa_IR"
if re.search(r'<meta property="og:locale"', raw):
raw = re.sub(
r'(<meta property="og:locale" content=")[^"]*(")',
lambda m: f'{m.group(1)}{og_locale}{m.group(2)}',
raw,
count=1,
)
tags = [t for t in tags if 'og:locale' not in t]
block = "\n ".join(tags)
raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S) raw = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", raw, count=1, flags=re.S)
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1) raw = raw.replace("</head>", f" {block}\n </head>", 1)
if noscript: if noscript:
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1) raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
return HTMLResponse(raw) return HTMLResponse(raw)
def _brand_name(db: Session, lang: str = "fa") -> str: def _brand_name(db: Session) -> str:
brand = db.query(models.Brand).first() brand = db.query(models.Brand).first()
if brand is None: return brand.name if brand else "گروه ساختمانی آرامش"
return FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND
if lang == "en":
return brand.name_en or brand.name or FALLBACK_EN_BRAND
return brand.name or FALLBACK_FA_BRAND
def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None: def _page_meta(db: Session, page_key: str) -> models.SeoMeta | None:
return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first() return db.query(models.SeoMeta).filter(models.SeoMeta.page_key == page_key).first()
def _meta_for(db: Session, page_key: str, lang: str) -> tuple[str, str]:
"""عنوان و توضیح سئوی یک صفحه به زبان درخواستی — با برگشت به فارسی."""
meta = _page_meta(db, page_key)
if meta is None:
return "", ""
if lang == "en":
return meta.title_en or meta.title or "", meta.description_en or meta.description or ""
return meta.title or "", meta.description or ""
# ---------- robots.txt و sitemap.xml ---------- # ---------- robots.txt و sitemap.xml ----------
@@ -194,35 +128,26 @@ def robots(db: Session = Depends(get_db)):
return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8") return Response(content="\n".join(lines), media_type="text/plain; charset=utf-8")
def _sitemap_entry(loc: str, fa: str, en: str) -> str:
esc = html.escape
return (
f" <url><loc>{esc(_absolute(loc))}</loc>\n"
f' <xhtml:link rel="alternate" hreflang="fa" href="{esc(_absolute(fa))}" />\n'
f' <xhtml:link rel="alternate" hreflang="en" href="{esc(_absolute(en))}" />\n'
f" </url>"
)
@router.get("/sitemap.xml", response_class=Response) @router.get("/sitemap.xml", response_class=Response)
def sitemap(db: Session = Depends(get_db)): def sitemap(db: Session = Depends(get_db)):
fa_urls = list(STATIC_PATHS) urls = list(STATIC_PATHS)
fa_urls += [ urls += [
f"/blog/{a.slug}" f"/blog/{a.slug}"
for a in db.query(models.Article) for a in db.query(models.Article)
.filter(models.Article.status == "published") .filter(models.Article.status == "published")
.order_by(models.Article.position, models.Article.id) .order_by(models.Article.position, models.Article.id)
.all() .all()
] ]
fa_urls += [ urls += [
f"/projects/{p.slug}" f"/projects/{p.slug}"
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all() for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
] ]
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls) items = "\n".join(
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
)
xml = ( xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n' '<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ' '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
f"{items}\n</urlset>\n" f"{items}\n</urlset>\n"
) )
return Response(content=xml, media_type="application/xml; charset=utf-8") return Response(content=xml, media_type="application/xml; charset=utf-8")
@@ -238,16 +163,10 @@ def blog_list(db: Session = Depends(get_db)):
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db), title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
description=meta.description if meta else "", description=meta.description if meta else "",
path="/blog", path="/blog",
og_image=meta.image if meta else "",
) )
def _article_pair(article: models.Article, lang: str) -> tuple[str, str]:
"""عنوان و چکیده مقاله به زبان درخواستی — با برگشت به فارسی."""
if lang == "en":
return article.title_en or article.title, article.excerpt_en or article.excerpt
return article.title, article.excerpt
@router.get("/blog/{slug}", response_class=HTMLResponse) @router.get("/blog/{slug}", response_class=HTMLResponse)
def blog_detail(slug: str, db: Session = Depends(get_db)): def blog_detail(slug: str, db: Session = Depends(get_db)):
article = ( article = (
@@ -260,23 +179,22 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}") return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
brand = _brand_name(db) brand = _brand_name(db)
title, excerpt = _article_pair(article, "fa")
first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2]) first_text = _doc_text(article.body) if not isinstance(article.body, list) else " ".join(article.body[:2])
jsonld = { jsonld = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
"headline": title, "headline": article.title,
"description": excerpt, "description": article.excerpt,
"image": _absolute(article.image), "image": _absolute(article.image),
"inLanguage": "fa", "inLanguage": "fa",
"author": {"@type": "Organization", "name": brand}, "author": {"@type": "Organization", "name": brand},
"publisher": {"@type": "Organization", "name": brand}, "publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"), "mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
} }
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>" noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
return _shell( return _shell(
title=f"{title} | {brand}", title=f"{article.title} | {brand}",
description=excerpt, description=article.excerpt,
path=f"/blog/{article.slug}", path=f"/blog/{article.slug}",
og_image=article.image, og_image=article.image,
og_type="article", og_type="article",
@@ -305,132 +223,17 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}") return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
brand = _brand_name(db) brand = _brand_name(db)
jsonld = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": project.title,
"description": project.summary,
"image": _absolute(project.image),
"inLanguage": "fa",
}
return _shell( return _shell(
title=f"{project.title} | {brand}", title=f"{project.title} | {brand}",
description=project.summary, description=project.summary,
path=f"/projects/{project.slug}", path=f"/projects/{project.slug}",
og_image=project.image, og_image=project.image,
jsonld=jsonld, jsonld={
"@context": "https://schema.org",
"@type": "WebPage",
"name": project.title,
"description": project.summary,
"image": _absolute(project.image),
"inLanguage": "fa",
},
) )
# ---------- صفحات انگلیسی (/en/...) ----------
def _en_blog_detail(article: models.Article, db: Session) -> HTMLResponse:
brand = _brand_name(db, "en")
title, excerpt = _article_pair(article, "en")
body = article.body_en if isinstance(article.body_en, dict) else article.body
first_text = _doc_text(body) if not isinstance(body, list) else " ".join(body[:2])
jsonld = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": excerpt,
"image": _absolute(article.image),
"inLanguage": "en",
"author": {"@type": "Organization", "name": brand},
"publisher": {"@type": "Organization", "name": brand},
"mainEntityOfPage": _absolute(f"/en/blog/{article.slug}"),
}
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
return _shell(
title=f"{title} | {brand}",
description=excerpt,
path=f"/en/blog/{article.slug}",
lang="en",
og_image=article.image,
og_type="article",
jsonld=jsonld,
noscript=noscript,
)
@router.get("/en", response_class=HTMLResponse)
@router.get("/en/", response_class=HTMLResponse)
def en_home(db: Session = Depends(get_db)):
title, description = _meta_for(db, "home", "en")
if not title:
title = _brand_name(db, "en")
return _shell(title=title, description=description, path="/en", lang="en")
@router.get("/en/blog", response_class=HTMLResponse)
def en_blog_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "blog")
title = meta.title_en or meta.title if meta else ""
description = meta.description_en or meta.description if meta else ""
if not title:
title = "Blog | " + _brand_name(db, "en")
return _shell(title=title, description=description, path="/en/blog", lang="en")
@router.get("/en/blog/{slug}", response_class=HTMLResponse)
def en_blog_detail(slug: str, db: Session = Depends(get_db)):
article = (
db.query(models.Article)
.filter(models.Article.slug == slug, models.Article.status == "published")
.first()
)
if article is None:
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/blog/{slug}", lang="en")
return _en_blog_detail(article, db)
@router.get("/en/projects", response_class=HTMLResponse)
def en_projects_list(db: Session = Depends(get_db)):
meta = _page_meta(db, "projects")
title = meta.title_en or meta.title if meta else ""
description = meta.description_en or meta.description if meta else ""
if not title:
title = "Projects | " + _brand_name(db, "en")
return _shell(title=title, description=description, path="/en/projects", lang="en")
@router.get("/en/projects/{slug}", response_class=HTMLResponse)
def en_project_detail(slug: str, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.slug == slug).first()
if project is None:
return _shell(title=_brand_name(db, "en"), description="", path=f"/en/projects/{slug}", lang="en")
brand = _brand_name(db, "en")
title = project.title_en or project.title
summary = project.summary_en or project.summary
jsonld = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": title,
"description": summary,
"image": _absolute(project.image),
"inLanguage": "en",
}
return _shell(
title=f"{title} | {brand}",
description=summary,
path=f"/en/projects/{project.slug}",
lang="en",
og_image=project.image,
jsonld=jsonld,
)
@router.get("/en/{path:path}", response_class=HTMLResponse)
def en_page(path: str, db: Session = Depends(get_db)):
"""بقیه مسیرهای انگلیسی (about، services، ...) — متا از seo_meta به انگلیسی."""
page_key = path.split("/")[0] if path else ""
title, description = _meta_for(db, page_key, "en") if page_key in META_KEYS else ("", "")
brand = _brand_name(db, "en")
if not title:
# عنوان پیش‌فرض از برچسب ناوبری انگلیسی همان صفحه
nav = db.query(models.NavItem).filter(models.NavItem.href == f"/{page_key}").first() if page_key else None
label = (nav.label_en or nav.label) if nav else ""
title = f"{label} | {brand}" if label else brand
return _shell(title=title, description=description, path=f"/en/{path}".rstrip("/"), lang="en")
-88
View File
@@ -180,91 +180,3 @@ def test_head_not_injected_for_draft(client):
def test_head_replaces_title_once(client): def test_head_replaces_title_once(client):
res = client.get("/blog/published-post") res = client.get("/blog/published-post")
assert len(re.findall(r"<title>", res.text)) == 1 assert len(re.findall(r"<title>", res.text)) == 1
# ---------- دوزبانه ----------
def test_bilingual_project_and_en_seo_shell(auth_client, client):
created = auth_client.post(
"/api/admin/projects",
json={
"slug": "bilingual-tower",
"title": "برج دوزبانه",
"title_en": "Bilingual Tower",
"summary": "خلاصه فارسی",
"summary_en": "English summary",
"body": [],
"body_en": ["English paragraph"],
},
)
assert created.status_code == 201, created.text
pid = created.json()["id"]
# GET /api/site هر دو زبان را برمی‌گرداند
project = next(p for p in client.get("/api/site").json()["projects"] if p["slug"] == "bilingual-tower")
assert project["title"] == "برج دوزبانه"
assert project["title_en"] == "Bilingual Tower"
assert project["body_en"] == ["English paragraph"]
# پوسته سئوی انگلیسی — جهت و متای انگلیسی و hreflang متقابل
res = client.get("/en/projects/bilingual-tower")
assert res.status_code == 200
assert 'lang="en" dir="ltr"' in res.text
assert "Bilingual Tower" in res.text
assert "English summary" in res.text
assert 'hreflang="fa"' in res.text
assert "https://test.example/projects/bilingual-tower" in res.text
assert "https://test.example/en/projects/bilingual-tower" in res.text
# sitemap دو زبانه با alternates
sm = client.get("/sitemap.xml").text
assert "xmlns:xhtml" in sm
assert 'hreflang="en"' in sm
assert "/en/projects/bilingual-tower" in sm
# پوسته فارسی هم alternates دارد
assert 'hreflang="en"' in client.get("/blog/published-post").text
# صفحه استاتیک انگلیسی از مسیر catch-all سرو می‌شود
res = client.get("/en/about")
assert res.status_code == 200
assert 'lang="en"' in res.text
assert auth_client.delete(f"/api/admin/projects/{pid}").status_code == 204
assert "bilingual-tower" not in client.get("/sitemap.xml").text
def test_bilingual_article_en_shell(auth_client, client):
articles = auth_client.get("/api/admin/articles").json()
article = next(a for a in articles if a["slug"] == "published-post")
res = auth_client.patch(
f"/api/admin/articles/{article['id']}",
json={
"title_en": "Published EN",
"excerpt_en": "English excerpt",
"body_en": DOC,
},
)
assert res.status_code == 200, res.text
res = client.get("/en/blog/published-post")
assert res.status_code == 200
assert "Published EN" in res.text
assert "English excerpt" in res.text
assert '"inLanguage": "en"' in res.text
def test_en_backfill_fills_only_empty_fields(auth_client, client):
"""backfill ترجمه‌های خالی را پر می‌کند و مقدار ثبت‌شده را بازنویسی نمی‌کند."""
from app.migrations import backfill_en_content
backfill_en_content()
brand = client.get("/api/site").json()["brand"]
assert brand["name_en"] == "Aramesh Construction Group"
# مقدار دستیِ ادمین محترم شمرده می‌شود
res = auth_client.patch(f"/api/admin/brand/{brand['id']}", json={"name_en": "Custom EN Name"})
assert res.status_code == 200
backfill_en_content()
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
+1 -1
View File
@@ -35,7 +35,7 @@ services:
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`) - traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le - traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)) - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.tls.certresolver=le
+1 -4
View File
@@ -10,8 +10,6 @@ services:
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
ports: ports:
- "${TRAEFIK_HTTP_PORT:-5173}:80" - "${TRAEFIK_HTTP_PORT:-5173}:80"
@@ -124,8 +122,7 @@ services:
- traefik.http.routers.api.priority=100 - traefik.http.routers.api.priority=100
- traefik.http.services.api.loadbalancer.server.port=8000 - traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA # robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
# مسیرهای /en/... هم همه به بک‌اند می‌روند تا متای سئوی انگلیسی و html انگلیسی تزریق شود - traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
- traefik.http.routers.seo.entrypoints=web - traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50 - traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api - traefik.http.routers.seo.service=api
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
// تست موقت رندر استاتیک و ابزارها — پس از اجرا حذف میشود
import { renderToHTMLString } from '@tiptap/static-renderer'
import { ARTICLE_EXTENSIONS } from './src/components/admin/ArticleEditor'
import { formatReadingTime, readingTimeMinutes } from './src/utils/readingTime'
import { buildLinkRel, normalizeHref, parseLinkRel } from './src/utils/link'
import { docHeadings, docImages, docToText } from './src/utils/tiptapDoc'
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'متن نمونه مقاله برای تست رندر' }] },
{
type: 'image',
attrs: { src: '/media/a.jpg', alt: 'تصویر تست', title: 'Photo 1', caption: 'کپشن تصویر تست' },
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'لینک خارجی',
marks: [{ type: 'link', attrs: { href: 'https://example.com', target: '_blank', rel: 'noopener noreferrer nofollow' } }],
},
],
},
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'تیتر دوم' }] },
],
}
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: doc as never })
console.log('--- rendered html ---')
console.log(html)
console.log('--- checks ---')
console.log('has figure:', html.includes('<figure'))
console.log('has figcaption:', html.includes('<figcaption') && html.includes('کپشن تصویر تست'))
console.log('has img src:', html.includes('src="/media/a.jpg"'))
console.log('has alt:', html.includes('alt="تصویر تست"'))
console.log('has title:', html.includes('title="Photo 1"'))
console.log('link rel/target:', html.includes('rel="noopener noreferrer nofollow"'), html.includes('target="_blank"'))
console.log('--- text utils ---')
console.log('text:', docToText(doc))
console.log('h2:', docHeadings(doc, 2), '| images:', docImages(doc).length)
console.log('reading time:', formatReadingTime(doc), '| minutes:', readingTimeMinutes(doc))
console.log('--- link helpers ---')
console.log('normalize:', normalizeHref('example.com'), '|', normalizeHref('/contact'), '|', normalizeHref('javascript:alert(1)'))
console.log('rel:', buildLinkRel({ newTab: true, nofollow: true }), '|', buildLinkRel({ newTab: false, nofollow: false }), '|', parseLinkRel('noopener noreferrer').newTab)
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: { entry: '.tmp-render-test.ts', formats: ['cjs'], fileName: () => 'tmp-render-test.cjs' },
outDir: '.tmp-render-out',
minify: false,
emptyOutDir: true,
},
})
+13 -4
View File
@@ -3,9 +3,19 @@
A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`). A modern React + Vite + TypeScript frontend application with a luxurious dark theme (Background: `#27221c`, Accent: `#d5874c`). All text content and assets are fetched dynamically from the backend API (`GET /api/site`).
## Typography ## Typography & Smart Bidi Text
The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users. The **Pinar** font is installed and served locally. The application no longer relies on any external servers or CDNs for font rendering, and the UI is fully localized for Persian (Farsi) users.
**Smart Bidi Typography:** To maintain perfect visual weight and baseline alignment when mixing English words within Persian text, we utilize a dynamic CSS calculation for Latin characters: `calc(1em - 2px)`. This ensures that English text automatically scales down slightly to match the natural density and baseline of the Persian font, preventing any visual imbalance in mixed-language sentences.
## Recent UI/UX Improvements
* **Tiptap Editor Fixes**:
* Resolved the initialization bug where the editor body remained empty during edit mode.
* Fixed Tiptap block boundaries so formatting a new line (e.g., changing it to H2) no longer alters the formatting of the previous line.
* **Dynamic TOC & Layout**:
* The Table of Contents (TOC) is now strictly `position: sticky` and extracts all H2/H3 tags flawlessly.
* The entire article layout (including Author, Read Time, and TOC) dynamically flips RTL/LTR based on the article's primary language.
## CMS, SEO & Editor Features ## CMS, SEO & Editor Features
The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX: The project includes a robust, custom-built Digital Twin (Admin Panel) with advanced content management capabilities, emphasizing a premium UI/UX:
@@ -15,8 +25,8 @@ The project includes a robust, custom-built Digital Twin (Admin Panel) with adva
* **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title. * **Article Metadata & Reading Time:** A beautifully designed metadata component displaying the author's avatar, name, publication date, and an auto-calculated dynamic reading time (displayed dynamically with Persian numerals, e.g., "۵ دقیقه مطالعه") directly below the H1 title.
* **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links. * **Dynamic Table of Contents (TOC):** Auto-generates a clickable TOC from `h2` and `h3` tags within the article, using a custom Persian-supported `slugify` logic for anchor links.
* **Custom Tiptap WYSIWYG Editor:** * **Custom Tiptap WYSIWYG Editor:**
* **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) with specific font scaling (RTL Base: 14px, LTR Base: 12px) mapped to a fixed toolbar that maintains layout stability. * **Fixed Toolbar & Typography:** Dedicated LTR/RTL toggles (`data-dir="ltr|rtl"`) mapped to a fixed toolbar that ensures layout stability.
* **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls (separate 'X' buttons to delete either the image entirely or just the caption text). * **Advanced Image & Caption Nodes:** Built via `ReactNodeViewRenderer`, rendering HTML wrapped in `<figure><figcaption>`. Features strictly isolated UI deletion controls.
* **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`). * **Animated Link Modal:** A premium, auto-opening modal with smooth fade/scale transition effects. Includes seamless checkbox toggles for "Open in new tab" (`target="_blank" rel="noopener noreferrer"`) and SEO link attributes (`nofollow/dofollow`).
* **Full Jalali (Shamsi) Calendar Integration:** * **Full Jalali (Shamsi) Calendar Integration:**
* Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience. * Admin panel utilizes `react-multi-date-picker` for a visual Persian date selection experience.
@@ -50,4 +60,3 @@ npm run build
* `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor. * `src/admin/` — Live editing layer (Digital Twin): includes the admin toolbar, entity drawers, and the article editor.
* `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD). * `src/seo.ts` — Manages page-level SEO tags (Title, Description, Open Graph, Canonical, JSON-LD).
* `src/data/site.ts` — Shared TypeScript data models and interfaces. * `src/data/site.ts` — Shared TypeScript data models and interfaces.
-8
View File
@@ -16,14 +16,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script>
// زبان و جهت سند پیش از اولین رندر، از خود مسیر — /en/... انگلیسی و LTR است
// تا جهت صفحه از نخستین فریم درست باشد (نسخه انگلیسی سمت سرور هم همین را تزریق می‌کند)
if (location.pathname === '/en' || location.pathname.startsWith('/en/')) {
document.documentElement.setAttribute('lang', 'en');
document.documentElement.setAttribute('dir', 'ltr');
}
</script>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>
+28 -43
View File
@@ -15,35 +15,12 @@ import { Blog } from './pages/Blog';
import { ArticleDetail } from './pages/ArticleDetail'; import { ArticleDetail } from './pages/ArticleDetail';
import { FaqPage } from './pages/FaqPage'; import { FaqPage } from './pages/FaqPage';
import { ContactPage } from './pages/ContactPage'; import { ContactPage } from './pages/ContactPage';
import { SiteProvider, useSiteData, useSite } from './api'; import { SiteProvider, useSiteData } from './api';
import { applySeo, seoKeyForPath } from './seo'; import { applySeo, seoKeyForPath } from './seo';
import { AdminProvider, useAdmin } from './admin/AdminContext'; import { AdminProvider, useAdmin } from './admin/AdminContext';
import { AdminBar } from './admin/AdminBar'; import { AdminBar } from './admin/AdminBar';
import { AdminLoginPage } from './admin/AdminLoginPage'; import { AdminLoginPage } from './admin/AdminLoginPage';
import { ArticleWritePage } from './admin/ArticleWritePage'; import { ArticleWritePage } from './admin/ArticleWritePage';
import { LangProvider, useLang } from './i18n/lang';
// مسیرهای عمومی یک بار تعریف می‌شوند و دو بار سرو می‌شوند: ریشه فارسی و /en انگلیسی
const PUBLIC_ROUTES: { path: string; element: React.ReactElement }[] = [
{ path: '/', element: <Home /> },
{ path: '/about', element: <About /> },
{ path: '/services', element: <ServicesPage /> },
{ path: '/projects', element: <Projects /> },
{ path: '/projects/:slug', element: <ProjectDetail /> },
{ path: '/investment', element: <Investment /> },
{ path: '/blog', element: <Blog /> },
{ path: '/blog/:slug', element: <ArticleDetail /> },
{ path: '/faq', element: <FaqPage /> },
{ path: '/contact', element: <ContactPage /> },
];
// فرزند مستقیم <Routes> باید <Route> باشد — آرایه‌های Route به‌جای کامپوننت واسط
const FA_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={path} path={path} element={element} />
));
const EN_ROUTES = PUBLIC_ROUTES.map(({ path, element }) => (
<Route key={`en-${path}`} path={path === '/' ? '/en' : `/en${path}`} element={element} />
));
function ScrollToTop() { function ScrollToTop() {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -68,9 +45,17 @@ function AnimatedRoutes() {
<Route path="/admin/login" element={<AdminLoginPage />} /> <Route path="/admin/login" element={<AdminLoginPage />} />
<Route path="/admin/write" element={<ArticleWritePage />} /> <Route path="/admin/write" element={<ArticleWritePage />} />
<Route path="/admin/write/:id" element={<ArticleWritePage />} /> <Route path="/admin/write/:id" element={<ArticleWritePage />} />
{FA_ROUTES} <Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:slug" element={<ProjectDetail />} />
<Route path="/investment" element={<Investment />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<ArticleDetail />} />
<Route path="/faq" element={<FaqPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="*" element={<Home />} /> <Route path="*" element={<Home />} />
{EN_ROUTES}
</Routes> </Routes>
</motion.main> </motion.main>
</AnimatePresence>); </AnimatePresence>);
@@ -80,13 +65,12 @@ function AnimatedRoutes() {
function AppChrome() { function AppChrome() {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isAuthenticated } = useAdmin(); const { isAuthenticated } = useAdmin();
const data = useSite(); const { data } = useSiteData();
const { lang } = useLang();
const isAdminPage = pathname.startsWith('/admin'); const isAdminPage = pathname.startsWith('/admin');
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند // عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحه‌های جزئیات که خودشان ست می‌کنند
useEffect(() => { useEffect(() => {
if (isAdminPage) return; if (isAdminPage || !data) return;
const key = seoKeyForPath(pathname); const key = seoKeyForPath(pathname);
if (!key) return; if (!key) return;
const meta = data.seo_meta?.find((s) => s.page_key === key); const meta = data.seo_meta?.find((s) => s.page_key === key);
@@ -96,9 +80,8 @@ function AppChrome() {
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand), title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
description: meta?.description ?? '', description: meta?.description ?? '',
path: pathname, path: pathname,
lang,
}); });
}, [pathname, data, isAdminPage, lang]); }, [pathname, data, isAdminPage]);
return ( return (
<> <>
@@ -113,44 +96,46 @@ function AppChrome() {
function AppShell() { function AppShell() {
const { data, error, reload } = useSiteData(); const { data, error, reload } = useSiteData();
const { t, dir, lang } = useLang();
// overflow-x-clip جای overflow-x-hidden: اسکرول‌افزار نمی‌سازد، پس position: sticky
// (فهرست مطالب مقاله) به صفحه می‌چسبد و با محتوا پایین نمی‌رود؛ برش افقی هم حفظ می‌شود.
return ( return (
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory"> <div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
<Preloader /> <Preloader />
{error ? ( {error ? (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1> <h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70"> <p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
{t('errorText')} محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
</p> </p>
<button <button
type="button" type="button"
onClick={reload} onClick={reload}
className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105" className="mt-8 bg-copper px-10 py-3 text-sm font-bold text-char transition-transform duration-300 hover:scale-105"
> >
{t('retry')} تلاش دوباره
</button> </button>
</div> </div>
) : data ? ( ) : data ? (
<> <BrowserRouter>
<ScrollToTop /> <ScrollToTop />
<AppChrome /> <AppChrome />
</> </BrowserRouter>
) : null} ) : null}
</div> </div>
); );
} }
export function App() { export function App() {
useEffect(() => {
document.documentElement.setAttribute('dir', 'rtl');
document.documentElement.setAttribute('lang', 'fa');
}, []);
return ( return (
<SiteProvider> <SiteProvider>
<AdminProvider> <AdminProvider>
<BrowserRouter> <AppShell />
<LangProvider>
<AppShell />
</LangProvider>
</BrowserRouter>
</AdminProvider> </AdminProvider>
</SiteProvider> </SiteProvider>
); );
+1 -14
View File
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */ /** نوار شناور ادمین — فقط برای کاربر واردشده رندر می‌شود */
export function AdminBar() { export function AdminBar() {
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin() const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
const [pwOpen, setPwOpen] = useState(false) const [pwOpen, setPwOpen] = useState(false)
return ( return (
@@ -161,19 +161,6 @@ export function AdminBar() {
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'} {isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
</button> </button>
<button
type="button"
onClick={toggleEditLang}
title="تعیین می‌کند ویرایش‌ها روی کدام ستون زبان ذخیره شوند"
className={`flex items-center gap-1.5 border px-3 py-1.5 text-xs font-light transition-colors duration-200 ${
editLang === 'en'
? 'border-copper bg-copper/10 text-copper'
: 'border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
زبان ویرایش: {editLang === 'fa' ? 'فارسی' : 'English'}
</button>
<span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60"> <span className="flex items-center gap-2 border-r border-ivory/10 pr-5 text-xs font-light text-ivory/60">
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" /> <span className={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
{STATUS_TEXT[saveStatus]} {STATUS_TEXT[saveStatus]}
+4 -69
View File
@@ -4,11 +4,8 @@ import Swal from 'sweetalert2'
import type { SiteData } from '../api' import type { SiteData } from '../api'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { adminApi, getToken, HttpError, setToken } from './adminClient' import { adminApi, getToken, HttpError, setToken } from './adminClient'
import { ENTITIES, isTranslatable } from './entities'
export type SaveStatus = 'idle' | 'saving' | 'error' export type SaveStatus = 'idle' | 'saving' | 'error'
/** زبان ویرایش محتوا — رابط مدیریتی فارسی می‌ماند؛ این فقط تعیین می‌کند کدام ستون زبان ذخیره شود */
export type EditLang = 'fa' | 'en'
interface AdminContextValue { interface AdminContextValue {
isAuthenticated: boolean isAuthenticated: boolean
@@ -19,9 +16,6 @@ interface AdminContextValue {
saveStatus: SaveStatus saveStatus: SaveStatus
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */ /** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را به‌روز می‌کند و خطاها را با دکمه تلاش دوباره نشان می‌دهد */
runSave: (action: () => Promise<void>) => Promise<boolean> runSave: (action: () => Promise<void>) => Promise<boolean>
/** زبان محتوایی که در حالت ویرایش ذخیره می‌شود (فارسی یا انگلیسی) */
editLang: EditLang
toggleEditLang: () => void
} }
const AdminContext = createContext<AdminContextValue | null>(null) const AdminContext = createContext<AdminContextValue | null>(null)
@@ -60,9 +54,6 @@ export function AdminProvider({ children }: { children: ReactNode }) {
const [isEditMode, setIsEditMode] = useState<boolean>( const [isEditMode, setIsEditMode] = useState<boolean>(
() => sessionStorage.getItem('aramesh_edit_mode') === '1', () => sessionStorage.getItem('aramesh_edit_mode') === '1',
) )
const [editLang, setEditLang] = useState<EditLang>(
() => (sessionStorage.getItem('aramesh_edit_lang') === 'en' ? 'en' : 'fa'),
)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle') const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const { reload } = useSiteData() const { reload } = useSiteData()
@@ -73,14 +64,6 @@ export function AdminProvider({ children }: { children: ReactNode }) {
}) })
}, []) }, [])
const toggleEditLang = useCallback(() => {
setEditLang((v) => {
const next: EditLang = v === 'fa' ? 'en' : 'fa'
sessionStorage.setItem('aramesh_edit_lang', next)
return next
})
}, [])
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string) => {
await adminApi.login(username, password) await adminApi.login(username, password)
setIsAuthenticated(true) setIsAuthenticated(true)
@@ -148,10 +131,8 @@ export function AdminProvider({ children }: { children: ReactNode }) {
logout, logout,
saveStatus, saveStatus,
runSave, runSave,
editLang,
toggleEditLang,
}), }),
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang], [isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
) )
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider> return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
@@ -160,14 +141,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
/** /**
* پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت * پرکاربردترین کمک‌ساز ویرایش: یک فیلد واحد را PATCH می‌کند و پس از موفقیت
* همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند. * همان تغییر را به‌صورت خوش‌بینانه در درخت داده سایت اعمال می‌کند.
*
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت می‌شوند؛ چون
* برخی mutatorهای کامپوننت‌ها نام فیلد را جداگانه می‌نویسند، مقدار قبلی فیلدهای
* فارسی اسنپ‌شات می‌شود و پس از اجرای mutator بازگردانده می‌شود تا متن انگلیسی
* هرگز جای متن فارسی در درخت خام ننشیند.
*/ */
export function useFieldSaver() { export function useFieldSaver() {
const { runSave, editLang } = useAdmin() const { runSave } = useAdmin()
const { updateSite } = useSiteData() const { updateSite } = useSiteData()
return useCallback( return useCallback(
@@ -178,50 +154,9 @@ export function useFieldSaver() {
apply: (data: SiteData) => void, apply: (data: SiteData) => void,
): Promise<boolean> => ): Promise<boolean> =>
runSave(async () => { runSave(async () => {
const originalKeys = Object.keys(changes)
if (editLang === 'en') {
const mapped: Record<string, unknown> = {}
for (const [key, value] of Object.entries(changes)) {
mapped[isTranslatable(resource, key) ? `${key}_en` : key] = value
}
for (const key of Object.keys(changes)) delete changes[key]
Object.assign(changes, mapped)
}
await adminApi.patch(resource, id, changes) await adminApi.patch(resource, id, changes)
updateSite((d) => { updateSite(apply)
const config = ENTITIES[resource]
let node: Record<string, unknown> | undefined
if (config) {
const target = d[config.dataKey as keyof SiteData] as unknown
node =
config.kind === 'singleton'
? (target as Record<string, unknown> | undefined)
: (target as { id: number }[] | undefined)?.find((x) => x.id === id)
}
const snapshot =
editLang === 'en' && node
? Object.fromEntries(originalKeys.filter((k) => k !== 'id').map((k) => [k, node![k]]))
: null
apply(d)
if (snapshot && node) {
Object.assign(node, snapshot)
Object.assign(node, changes)
}
})
}), }),
[runSave, updateSite, editLang], [runSave, updateSite],
)
}
/**
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
* پسوند _en می‌گیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره می‌شود.
*/
export function useEditField() {
const { editLang } = useAdmin()
return useCallback(
(resource: string, field: string) =>
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
[editLang],
) )
} }
+29 -54
View File
@@ -15,12 +15,13 @@ import type { CategoryOption } from '../components/admin/LuxeSelect'
import { ARTICLE_CATEGORIES } from './entities' import { ARTICLE_CATEGORIES } from './entities'
import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants' import { FEATURED_IMAGE_ALT_SUFFIX } from '../data/constants'
import { formatReadingTime } from '../utils/readingTime' import { formatReadingTime } from '../utils/readingTime'
import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { Article, ArticleBodyDoc } from '../data/site' import type { Article, ArticleBodyDoc } from '../data/site'
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
/** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */ /** پیش‌نویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc } type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
/** /**
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند: * فیلدهای متادیتای مقاله که با React Hook Form مدیریت می‌شوند:
@@ -64,7 +65,7 @@ const STATUS_TEXT: Record<string, string> = {
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش). * مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
*/ */
export function ArticleWritePage() { export function ArticleWritePage() {
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin() const { isAuthenticated, saveStatus, runSave } = useAdmin()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { data, updateSite } = useSiteData() const { data, updateSite } = useSiteData()
@@ -77,11 +78,8 @@ export function ArticleWritePage() {
id: 0, id: 0,
slug: `article-${Date.now()}`, slug: `article-${Date.now()}`,
title: '', title: '',
title_en: '',
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
date_en: '',
category: ARTICLE_CATEGORIES[0], category: ARTICLE_CATEGORIES[0],
category_en: '',
tags: [], tags: [],
image: '', image: '',
image_alt: '', image_alt: '',
@@ -89,11 +87,8 @@ export function ArticleWritePage() {
author: '', author: '',
meta_description: '', meta_description: '',
read: '۵ دقیقه', read: '۵ دقیقه',
read_en: '',
excerpt: '', excerpt: '',
excerpt_en: '',
body: EMPTY_DOC, body: EMPTY_DOC,
body_en: EMPTY_DOC,
status: 'draft', status: 'draft',
seo_keyphrase: '', seo_keyphrase: '',
}) })
@@ -117,6 +112,11 @@ export function ArticleWritePage() {
const { field: imageTitleField } = useController({ name: 'image_title', control }) const { field: imageTitleField } = useController({ name: 'image_title', control })
const [createdId, setCreatedId] = useState<number | null>(null) const [createdId, setCreatedId] = useState<number | null>(null)
/**
* شناسه محتوای بارگذاری‌شده — با رسیدن مقاله از سرور عوض می‌شود تا
* بدنه ویرایشگر TipTap با متن واقعی مقاله مقداردهی شود.
*/
const [loadedContentKey, setLoadedContentKey] = useState<string | number>('new')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const draftRef = useRef(draft) const draftRef = useRef(draft)
draftRef.current = draft draftRef.current = draft
@@ -147,17 +147,15 @@ export function ArticleWritePage() {
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود // وقتی مقاله موجود شناخته شد، فرم و شناسه ساخته‌شده یک‌بار مقداردهی می‌شود
useEffect(() => { useEffect(() => {
if (!article || createdRef.current) return if (!article || createdRef.current) return
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
setDraft({ setDraft({
...(article as unknown as Draft), ...article,
tags: normalizeTags((article as unknown as { tags?: unknown }).tags), tags: normalizeTags(article.tags),
body: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, // بدنه ذخیره‌شده (سند جدید یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
body_en: body: toArticleBodyDoc(article.body),
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
? (rawEnBody as ArticleBodyDoc)
: EMPTY_DOC,
}) })
setCreatedId(article.id) setCreatedId(article.id)
// بدنه ویرایشگر با محتوای بارگذاری‌شده همگام می‌شود
setLoadedContentKey(article.id)
// فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود // فرم متادیتا هم با مقادیر مقاله بارگذاری‌شده همگام می‌شود
reset({ reset({
title: article.title ?? '', title: article.title ?? '',
@@ -261,8 +259,6 @@ export function ArticleWritePage() {
}) })
const metaLength = (draft.meta_description ?? '').trim().length const metaLength = (draft.meta_description ?? '').trim().length
// در زبان انگلیسی، فیلدهای متنی به ستون‌های _en می‌روند؛ اسلاگ/تصویر/وضعیت مشترک‌اند
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
// متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود // متن جایگزین تصویر شاخص — با watch('title') به‌صورت زنده ساخته می‌شود
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX] const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
@@ -284,18 +280,6 @@ export function ArticleWritePage() {
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button
type="button"
onClick={toggleEditLang}
title="تعیین می‌کند متن مقاله روی کدام زبان ذخیره شود"
className={`px-4 py-2 text-xs font-bold transition-colors duration-200 ${
editLang === 'en'
? 'bg-copper text-char'
: 'border border-ivory/20 text-ivory/70 hover:border-copper hover:text-copper'
}`}
>
{editLang === 'fa' ? 'ویرایش: فارسی' : 'Edit: English'}
</button>
<span className="flex items-center gap-2 text-xs font-light text-ivory/60"> <span className="flex items-center gap-2 text-xs font-light text-ivory/60">
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />} {saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
{STATUS_TEXT[saveStatus]} {STATUS_TEXT[saveStatus]}
@@ -330,7 +314,7 @@ export function ArticleWritePage() {
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'} {draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیش‌نویس'}
</button> </button>
<Link <Link
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`} to={`/blog/${draft.slug}`}
data-admin-link data-admin-link
className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper" className="flex items-center gap-1.5 border border-ivory/20 px-4 py-2 text-xs font-light text-ivory/70 transition-colors hover:border-copper hover:text-copper"
> >
@@ -361,8 +345,7 @@ export function ArticleWritePage() {
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'} : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'}
</p> </p>
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛ {/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره می‌شود */}
<Controller <Controller
name="title" name="title"
control={control} control={control}
@@ -372,9 +355,9 @@ export function ArticleWritePage() {
{...field} {...field}
onChange={(event) => { onChange={(event) => {
field.onChange(event.target.value) field.onChange(event.target.value)
queueSave({ [suf('title')]: event.target.value }) queueSave({ title: event.target.value })
}} }}
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'} placeholder="عنوان مقاله…"
aria-label="عنوان مقاله" aria-label="عنوان مقاله"
className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none" className="mt-4 w-full bg-transparent text-4xl font-bold leading-[1.6] text-ivory placeholder:text-ivory/25 focus:outline-none"
/> />
@@ -407,18 +390,14 @@ export function ArticleWritePage() {
/> />
</div> </div>
{/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */} {/* متن */}
<div className="mt-8"> <div className="mt-8">
<ArticleEditor <ArticleEditor
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>} contentKey={loadedContentKey}
doc={draft.body as unknown as Record<string, unknown>}
onChange={(json) => { onChange={(json) => {
// زمان مطالعه به‌صورت پویا از متن سند محاسبه می‌شود — به زبان جاری // زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
const minutes = formatReadingTime(json) queueSave({ body: json, read: formatReadingTime(json) })
if (editLang === 'en') {
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
} else {
queueSave({ body: json, read: `${minutes} دقیقه` })
}
}} }}
/> />
</div> </div>
@@ -478,8 +457,8 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label> <label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده می‌شود)</label>
<textarea <textarea
rows={2} rows={2}
{...textField(suf('excerpt'))} {...textField('excerpt')}
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارت‌های وبلاگ…'} placeholder="خلاصه کوتاه مقاله برای کارت‌های وبلاگ…"
className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none" className="mt-2 w-full resize-y border border-ivory/15 bg-char px-3 py-2 text-sm font-light leading-8 text-ivory focus:border-copper focus:outline-none"
/> />
</div> </div>
@@ -542,13 +521,9 @@ export function ArticleWritePage() {
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label> <label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
<input <input
type="text" type="text"
disabled={editLang !== 'en'} disabled
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })} {...textField('read')}
className={`mt-2 w-full text-sm font-light ${ className="mt-2 w-full cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-sm font-light text-ivory/50"
editLang === 'en'
? 'border border-ivory/15 bg-char px-3 py-2 text-ivory focus:border-copper focus:outline-none'
: 'cursor-not-allowed border border-ivory/15 bg-char/50 px-3 py-2 text-ivory/50'
}`}
/> />
</div> </div>
<div> <div>
+8 -34
View File
@@ -5,7 +5,7 @@ import Swal from 'sweetalert2'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { useAdmin, useFieldSaver } from './AdminContext' import { useAdmin, useFieldSaver } from './AdminContext'
import { adminApi } from './adminClient' import { adminApi } from './adminClient'
import { editableFields } from './entities' import { toArticleBodyDoc } from '../utils/tiptapDoc'
import type { EntityConfig, FieldConfig } from './entities' import type { EntityConfig, FieldConfig } from './entities'
import { ImageField } from '../components/admin/ImageField' import { ImageField } from '../components/admin/ImageField'
import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker'
@@ -23,16 +23,11 @@ type EntityDrawerProps = {
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) { export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
const save = useFieldSaver() const save = useFieldSaver()
const { runSave, editLang } = useAdmin() const { runSave } = useAdmin()
const { reload } = useSiteData() const { reload } = useSiteData()
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const flushRef = useRef<() => void>(() => {}) const flushRef = useRef<() => void>(() => {})
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر می‌شوند؛
// متن فارسی همان فیلد به‌عنوان جای‌نگهدار نشان داده می‌شود تا مترجم زمینه را ببیند
const fields = editableFields(config, editLang)
const faBase = (name: string) => name.replace(/_en$/, '')
const closeWithFlush = () => { const closeWithFlush = () => {
flushRef.current() flushRef.current()
onClose() onClose()
@@ -119,7 +114,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
<div className="flex-1 overflow-y-auto px-6 py-6"> <div className="flex-1 overflow-y-auto px-6 py-6">
<div className="grid gap-6"> <div className="grid gap-6">
{fields.map((field) => { {config.fields.map((field) => {
const isImage = field.type === 'image' const isImage = field.type === 'image'
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt // فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
const altKey = isImage const altKey = isImage
@@ -140,7 +135,6 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
key={field.name} key={field.name}
field={field} field={field}
value={item[field.name]} value={item[field.name]}
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
altValue={altKey ? String(item[altKey] ?? '') : undefined} altValue={altKey ? String(item[altKey] ?? '') : undefined}
altFallback={imageAltFallback} altFallback={imageAltFallback}
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined} imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
@@ -185,7 +179,6 @@ function FieldControl({
value, value,
altValue, altValue,
altFallback, altFallback,
placeholder,
imageTitleValue, imageTitleValue,
onSave, onSave,
onSaveAlt, onSaveAlt,
@@ -197,8 +190,6 @@ function FieldControl({
/** مقدار Alt تصویر — فقط برای فیلدهای image */ /** مقدار Alt تصویر — فقط برای فیلدهای image */
altValue?: string altValue?: string
altFallback?: string altFallback?: string
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */
placeholder?: unknown
/** مقدار Title تصویر — فقط برای فیلدهای image */ /** مقدار Title تصویر — فقط برای فیلدهای image */
imageTitleValue?: string imageTitleValue?: string
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
@@ -278,22 +269,14 @@ function FieldControl({
} }
if (field.type === 'wysiwyg') { if (field.type === 'wysiwyg') {
return ( return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
<WysiwygField
field={field}
value={value}
onSave={onSave}
registerFlush={registerFlush}
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را می‌بیند' : undefined}
/>
)
} }
if (field.type === 'list') { if (field.type === 'list') {
return field.ui === 'tags' ? ( return field.ui === 'tags' ? (
<TagsField field={field} value={value} onSave={onSave} /> <TagsField field={field} value={value} onSave={onSave} />
) : ( ) : (
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را می‌بیند' : undefined} /> <ListField field={field} value={value} onSave={onSave} />
) )
} }
@@ -304,7 +287,6 @@ function FieldControl({
<textarea <textarea
dir="auto" dir="auto"
rows={4} rows={4}
placeholder={placeholder ? String(placeholder) : undefined}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onBlur={commitText} onBlur={commitText}
@@ -321,7 +303,6 @@ function FieldControl({
<input <input
dir={field.type === 'number' ? 'ltr' : 'auto'} dir={field.type === 'number' ? 'ltr' : 'auto'}
type={field.type === 'number' ? 'number' : 'text'} type={field.type === 'number' ? 'number' : 'text'}
placeholder={placeholder ? String(placeholder) : undefined}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onBlur={commitText} onBlur={commitText}
@@ -364,12 +345,10 @@ function ListField({
field, field,
value, value,
onSave, onSave,
hint,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
hint?: string
}) { }) {
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : []) const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
@@ -382,7 +361,6 @@ function ListField({
<div> <div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p> <p className="text-xs font-bold text-ivory/80">{field.label}</p>
{field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>} {field.hint && <p className="mt-1 text-[10px] font-light text-ivory/40">{field.hint}</p>}
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
<div className="mt-2 grid gap-2"> <div className="mt-2 grid gap-2">
{lines.map((line, i) => ( {lines.map((line, i) => (
<div key={i} className="flex items-start gap-2"> <div key={i} className="flex items-start gap-2">
@@ -430,18 +408,15 @@ function WysiwygField({
value, value,
onSave, onSave,
registerFlush, registerFlush,
hint,
}: { }: {
field: FieldConfig field: FieldConfig
value: unknown value: unknown
onSave: (value: unknown) => Promise<boolean> onSave: (value: unknown) => Promise<boolean>
registerFlush?: (fn: () => void) => void registerFlush?: (fn: () => void) => void
hint?: string
}) { }) {
const [doc, setDoc] = useState<Record<string, unknown>>(() => const [doc, setDoc] = useState<Record<string, unknown>>(
value && typeof value === 'object' && !Array.isArray(value) // بدنه ذخیره‌شده (سند جدید، رشته JSON یا پاراگراف‌های قدیمی) به سند قابل ویرایش تبدیل می‌شود
? (value as Record<string, unknown>) () => toArticleBodyDoc(value),
: { type: 'doc', content: [{ type: 'paragraph' }] },
) )
const timer = useRef<ReturnType<typeof setTimeout> | null>(null) const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pending = useRef<Record<string, unknown> | null>(null) const pending = useRef<Record<string, unknown> | null>(null)
@@ -482,7 +457,6 @@ function WysiwygField({
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{field.label}</p> <p className="text-xs font-bold text-ivory/80">{field.label}</p>
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
<p className="mt-1 text-[10px] font-light text-ivory/40"> <p className="mt-1 text-[10px] font-light text-ivory/40">
ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید ذخیره خودکار — تصویر را داخل متن بچسبانید یا بکشید
</p> </p>
+23 -43
View File
@@ -14,8 +14,6 @@ export type FieldConfig = {
creatable?: boolean creatable?: boolean
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */ /** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
ui?: 'lines' | 'tags' ui?: 'lines' | 'tags'
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */
shared?: boolean
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */ /** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
altFallbackSuffix?: string altFallbackSuffix?: string
} }
@@ -35,15 +33,13 @@ const F = (
label: string, label: string,
type: FieldType = 'text', type: FieldType = 'text',
hint?: string, hint?: string,
sharedOrExtra?: boolean | Partial<FieldConfig>, extra?: Partial<FieldConfig>,
): FieldConfig => ({ ): FieldConfig => ({
name, name,
label, label,
type, type,
hint, hint,
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null ...extra,
? sharedOrExtra
: { shared: !!sharedOrExtra }),
}) })
/** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */ /** دسته‌بندی‌های استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده می‌شوند */
@@ -59,16 +55,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
services: { services: {
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection', resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
titleKey: 'title', titleKey: 'title',
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)], fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' }, defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
}, },
projects: { projects: {
resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection', resource: 'projects', dataKey: 'projects', title: 'پروژه‌ها', kind: 'collection',
titleKey: 'title', titleKey: 'title',
fields: [ fields: [
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'), F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true), F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true), F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'), F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'), F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
], ],
@@ -80,15 +76,15 @@ export const ENTITIES: Record<string, EntityConfig> = {
fields: [ fields: [
F('title', 'عنوان'), F('title', 'عنوان'),
F('body', 'متن مقاله', 'wysiwyg'), F('body', 'متن مقاله', 'wysiwyg'),
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'), F('excerpt', 'چکیده (روی کارت مقاله)'),
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true), F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'),
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }), F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }), F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
F('author', 'نویسنده', 'text', undefined, true), F('author', 'نویسنده'),
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true), F('date', 'تاریخ انتشار (شمسی)', 'date'),
F('read', 'زمان مطالعه (دقیقه)'), F('read', 'زمان مطالعه (دقیقه)'),
F('slug', 'نشانی (slug)', 'text', undefined, true), F('slug', 'نشانی (slug)'),
], ],
defaults: { defaults: {
title: 'مقاله جدید', title: 'مقاله جدید',
@@ -128,13 +124,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
timeline: { timeline: {
resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection', resource: 'timeline', dataKey: 'timeline', title: 'تایم‌لاین', kind: 'collection',
titleKey: 'title', titleKey: 'title',
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)], fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' }, defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
}, },
stats: { stats: {
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection', resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
titleKey: 'label', titleKey: 'label',
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')], fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
defaults: { label: 'شاخص جدید', value: 0, suffix: '' }, defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
}, },
why_us: { why_us: {
@@ -164,20 +160,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
gallery: { gallery: {
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection', resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
titleKey: 'alt', titleKey: 'alt',
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')], fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
defaults: { src: '', alt: 'تصویر گالری' }, defaults: { src: '', alt: 'تصویر گالری' },
}, },
nav: { nav: {
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection', resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
titleKey: 'label', titleKey: 'label',
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)], fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
defaults: { label: 'آیتم جدید', href: '/' }, defaults: { label: 'آیتم جدید', href: '/' },
}, },
seo_meta: { seo_meta: {
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection', resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
titleKey: 'page_key', titleKey: 'page_key',
fields: [ fields: [
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true), F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'), F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'), F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
], ],
@@ -186,25 +182,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
brand: { brand: {
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton', resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
titleKey: 'name', titleKey: 'name',
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image', undefined, true), F('hero', 'تصویر هیرو', 'image', undefined, true), F('contact_bg', 'پس‌زمینه بخش تماس', 'image', undefined, true), F('phone', 'تلفن نمایشی', 'text', undefined, true), F('phone_href', 'لینک تلفن', 'text', undefined, true), F('email', 'ایمیل', 'text', undefined, true), F('address', 'نشانی', 'textarea')], fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پس‌زمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
defaults: {}, defaults: {},
}, },
hero: { hero: {
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton', resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی', 'text', undefined, true), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم', 'text', undefined, true), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')], fields: [F('title_highlight', 'واژه مسیِ تیتر'), F('badge_value', 'عدد کارت کنار'), F('badge_label', 'متن کارت کنار', 'textarea'), F('cta_primary_label', 'متن دکمه اصلی'), F('cta_primary_href', 'لینک دکمه اصلی'), F('cta_secondary_label', 'متن دکمه دوم'), F('cta_secondary_href', 'لینک دکمه دوم'), F('scroll_label', 'متن اسکرول'), F('kicker', 'متن بالای تیتر', 'textarea')],
defaults: {}, defaults: {},
}, },
story: { story: {
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton', resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
fields: [F('title', 'عنوان بخش'), F('paragraphs', 'پاراگراف‌های معرفی', 'list'), F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه', 'text', undefined, true)], fields: [F('quote', 'نقل‌قول مدیرعامل', 'textarea'), F('quote_author', 'استناد نقل‌قول'), F('stat1_value', 'آمار ۱ — عدد'), F('stat1_label', 'آمار ۱ — برچسب'), F('stat2_value', 'آمار ۲ — عدد'), F('stat2_label', 'آمار ۲ — برچسب'), F('link_label', 'متن دکمه'), F('link_href', 'لینک دکمه')],
defaults: {}, defaults: {},
}, },
before_after: { before_after: {
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton', resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
titleKey: 'title', titleKey: 'title',
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')], fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
defaults: {}, defaults: {},
}, },
contact_section: { contact_section: {
@@ -235,22 +231,6 @@ export const ENTITIES: Record<string, EntityConfig> = {
}, },
} }
/** آیا فیلد در منبع داده قابل ترجمه است؟ (فیلدهای مشترک و پسوندهای انگلیسی قبلی خیر) */
export function isTranslatable(resource: string, name: string): boolean {
if (name.endsWith('_en')) return false
const config = ENTITIES[resource]
return !!config?.fields.some((f) => f.name === name && !f.shared)
}
/** فیلدهای قابل ویرایش یک منبع در زبان داده‌شده — انگلیسی: بدون فیلدهای مشترک و با پسوند _en */
export function editableFields(config: EntityConfig, lang: 'fa' | 'en'): FieldConfig[] {
if (lang === 'fa') return config.fields
return config.fields
.filter((f) => !f.shared)
.map((f) => ({ ...f, name: `${f.name}_en` }))
}
/** دسترسی به آرایه/شیء منبع از درخت داده سایت */
export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) { export function getEntityData(data: SiteData, config: EntityConfig): Record<string, unknown> & { id: number }[] | (Record<string, unknown> & { id: number }) {
return data[config.dataKey as keyof SiteData] as never return data[config.dataKey as keyof SiteData] as never
} }
+20 -91
View File
@@ -3,14 +3,11 @@ import type { ReactNode } from 'react'
import type { Article, Project, Service } from './data/site' import type { Article, Project, Service } from './data/site'
import { API_URL } from './config' import { API_URL } from './config'
import { getToken } from './admin/adminClient' import { getToken } from './admin/adminClient'
import { localizeSite } from './i18n/localize'
import { useLang } from './i18n/lang'
// انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند // انواع داده‌هایی که بک‌اند در GET /api/site برمی‌گرداند
export type BrandInfo = { export type BrandInfo = {
id: number id: number
name: string name: string
name_en?: string
logo: string logo: string
hero: string hero: string
contact_bg: string contact_bg: string
@@ -18,157 +15,105 @@ export type BrandInfo = {
phone_href: string phone_href: string
email: string email: string
address: string address: string
address_en?: string
} }
export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string } export type TestimonialItem = { id: number; name: string; role: string; quote: string }
export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string } export type FaqItem = { id: number; question: string; answer: string }
export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: string } export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
export type TimelineItem = { id: number; year: string; year_en?: string; title: string; title_en?: string; desc: string; desc_en?: string; image: string } export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
export type StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string } export type StatItem = { id: number; value: number; suffix: string; label: string }
export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string } export type WhyUsItem = { id: number; title: string; desc: string }
export type BeforeAfterInfo = { export type BeforeAfterInfo = {
id: number id: number
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
before: string before: string
after: string after: string
before_label: string before_label: string
before_label_en?: string
after_label: string after_label: string
after_label_en?: string
} }
export type NavItem = { id: number; label: string; label_en?: string; href: string } export type NavItem = { id: number; label: string; href: string }
export type HeroContent = { export type HeroContent = {
id: number id: number
kicker: string kicker: string
kicker_en?: string
title: string title: string
title_en?: string
title_highlight: string title_highlight: string
title_highlight_en?: string
subtitle: string subtitle: string
subtitle_en?: string
badge_value: string badge_value: string
badge_value_en?: string
badge_label: string badge_label: string
badge_label_en?: string
cta_primary_label: string cta_primary_label: string
cta_primary_label_en?: string
cta_primary_href: string cta_primary_href: string
cta_secondary_label: string cta_secondary_label: string
cta_secondary_label_en?: string
cta_secondary_href: string cta_secondary_href: string
scroll_label: string scroll_label: string
scroll_label_en?: string
} }
export type StoryContent = { export type StoryContent = {
id: number id: number
title: string title: string
title_en?: string
paragraphs: string[] paragraphs: string[]
paragraphs_en?: string[] | null
quote: string quote: string
quote_en?: string
quote_author: string quote_author: string
quote_author_en?: string
stat1_value: string stat1_value: string
stat1_value_en?: string
stat1_label: string stat1_label: string
stat1_label_en?: string
stat2_value: string stat2_value: string
stat2_value_en?: string
stat2_label: string stat2_label: string
stat2_label_en?: string
link_label: string link_label: string
link_label_en?: string
link_href: string link_href: string
bento: { src: string; alt: string }[] bento: { src: string; alt: string }[]
bento_en?: { src: string; alt: string }[] | null
} }
export type SectionHeadingContent = { export type SectionHeadingContent = {
id: number id: number
key: string key: string
label: string label: string
label_en?: string
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
image: string image: string
cta_label: string cta_label: string
cta_label_en?: string
cta_href: string cta_href: string
} }
export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string } export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null } export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string } export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string } export type OfficeItem = { id: number; city: string; address: string; hours: string }
export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string } export type GalleryImageItem = { id: number; src: string; alt: string }
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string } export type StatusLabelItem = { id: number; key: string; label: string }
export type ContactSectionContent = { export type ContactSectionContent = {
id: number id: number
label: string label: string
label_en?: string
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
address_label: string address_label: string
address_label_en?: string
phone_label: string phone_label: string
phone_label_en?: string
email_label: string email_label: string
email_label_en?: string
name_label: string name_label: string
name_label_en?: string
name_placeholder: string name_placeholder: string
name_placeholder_en?: string
phone_field_label: string phone_field_label: string
phone_field_label_en?: string
phone_placeholder: string phone_placeholder: string
phone_placeholder_en?: string
note_label: string note_label: string
note_label_en?: string
note_optional_label: string note_optional_label: string
note_optional_label_en?: string
note_placeholder: string note_placeholder: string
note_placeholder_en?: string
submit_label: string submit_label: string
submit_label_en?: string
sending_label: string sending_label: string
sending_label_en?: string
success_title: string success_title: string
success_title_en?: string
success_text: string success_text: string
success_text_en?: string
success_button_label: string success_button_label: string
success_button_label_en?: string
} }
export type NewsletterContent = { export type NewsletterContent = {
id: number id: number
title: string title: string
title_en?: string
lead: string lead: string
lead_en?: string
placeholder: string placeholder: string
placeholder_en?: string
button_label: string button_label: string
button_label_en?: string
sending_label: string sending_label: string
sending_label_en?: string
success_text: string success_text: string
success_text_en?: string
} }
export type SocialItem = { label: string; href: string; icon: string } export type SocialItem = { label: string; href: string; icon: string }
@@ -176,19 +121,12 @@ export type SocialItem = { label: string; href: string; icon: string }
export type FooterContent = { export type FooterContent = {
id: number id: number
about_text: string about_text: string
about_text_en?: string
pages_heading: string pages_heading: string
pages_heading_en?: string
services_heading: string services_heading: string
services_heading_en?: string
contact_heading: string contact_heading: string
contact_heading_en?: string
copyright: string copyright: string
copyright_en?: string
tagline: string tagline: string
tagline_en?: string
socials: SocialItem[] socials: SocialItem[]
socials_en?: SocialItem[] | null
} }
export interface SiteData { export interface SiteData {
@@ -216,7 +154,7 @@ export interface SiteData {
contact_section: ContactSectionContent contact_section: ContactSectionContent
newsletter: NewsletterContent newsletter: NewsletterContent
footer: FooterContent footer: FooterContent
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[] seo_meta: { id: number; page_key: string; title: string; description: string }[]
} }
interface SiteContextValue { interface SiteContextValue {
@@ -306,16 +244,13 @@ export function useSiteData(): SiteContextValue {
return useContext(SiteContext) return useContext(SiteContext)
} }
// فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند. // فقط در کامپوننت‌هایی استفاده شود که پس از بارگذاری کامل داده‌ها رندر می‌شوند
// خروجی بومی‌سازی‌شده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی می‌شوند (با برگشت به فارسی).
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده می‌شود.
export function useSite(): SiteData { export function useSite(): SiteData {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
const { lang } = useLang()
if (!data) { if (!data) {
throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود') throw new Error('useSite باید پس از بارگذاری داده‌های سایت استفاده شود')
} }
return localizeSite(data, lang) return data
} }
const EMPTY_HEADING: SectionHeadingContent = { const EMPTY_HEADING: SectionHeadingContent = {
@@ -332,25 +267,19 @@ const EMPTY_HEADING: SectionHeadingContent = {
/** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */ /** تیتر و توضیح یک بخش بر اساس key تعریف‌شده در پنل مدیریت */
export function useSectionHeading(key: string): SectionHeadingContent { export function useSectionHeading(key: string): SectionHeadingContent {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
const { lang } = useLang() return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
const localized = data ? localizeSite(data, lang) : null
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
} }
/** هیرو بالای صفحه بر اساس key تعریف‌شده در پنل مدیریت */ /** هیرو بالای صفحه بر اساس key تعریف‌شده در پنل مدیریت */
export function usePageHero(key: string): PageHeroContent { export function usePageHero(key: string): PageHeroContent {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
const { lang } = useLang()
const localized = data ? localizeSite(data, lang) : null
return ( return (
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' } data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
) )
} }
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */ /** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
export function useStatusLabel(key: string): string { export function useStatusLabel(key: string): string {
const { data } = useContext(SiteContext) const { data } = useContext(SiteContext)
const { lang } = useLang() return data?.status_labels.find((s) => s.key === key)?.label ?? key
const localized = data ? localizeSite(data, lang) : null
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
} }
+6 -8
View File
@@ -3,25 +3,23 @@ import { Link } from 'react-router-dom'
import { ArrowLeftIcon, ClockIcon } from 'lucide-react' import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
import type { Article } from '../data/site' import type { Article } from '../data/site'
import { formatJalali } from '../utils/date' import { formatJalali } from '../utils/date'
import { useLang } from '../i18n/lang'
import { toPersianDigits } from '../utils/digits' import { toPersianDigits } from '../utils/digits'
export function ArticleCard({ article }: { article: Article }) { export function ArticleCard({ article }: { article: Article }) {
const { t, href } = useLang()
return ( return (
<Link <Link
to={href(`/blog/${article.slug}`)} to={`/blog/${article.slug}`}
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper" className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/80 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.18)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`${t('readArticlePrefix')} ${article.title}`} aria-label={`خواندن مقاله ${article.title}`}
> >
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<img <img
src={article.image} src={article.image}
alt={`${t('articleImagePrefix')} ${article.title}`} alt={`تصویر مقاله ${article.title}`}
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105" className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
/> />
{/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */} {/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
<div className="absolute top-4 ltr:left-4 rtl:right-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]"> <div className="absolute right-4 top-4 rounded-full border border-white/20 bg-ink/75 px-3.5 py-1 text-xs font-light text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)]">
{article.category} {article.category}
</div> </div>
</div> </div>
@@ -46,8 +44,8 @@ export function ArticleCard({ article }: { article: Article }) {
{/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */} {/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */}
<span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]"> <span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]">
{t('readMore')} ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
</span> </span>
</div> </div>
</div> </div>
+8 -10
View File
@@ -4,7 +4,6 @@ import Swal from 'sweetalert2'
import { Reveal } from './Reveal' import { Reveal } from './Reveal'
import { Button } from './Button' import { Button } from './Button'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable' import { SingletonPill } from '../admin/Editable'
@@ -12,7 +11,6 @@ import { API_URL } from '../config'
export function ContactSection() { export function ContactSection() {
const { brand, contact_section: content } = useSite() const { brand, contact_section: content } = useSite()
const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' }) const [form, setForm] = useState({ name: '', phone: '', note: '' })
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
@@ -36,7 +34,7 @@ export function ContactSection() {
background: '#1e1a16', background: '#1e1a16',
color: '#F5F3EF', color: '#F5F3EF',
iconColor: '#d5874c', iconColor: '#d5874c',
confirmButtonText: t('understood'), confirmButtonText: 'متوجه شدم',
buttonsStyling: false, buttonsStyling: false,
customClass: { customClass: {
container: 'backdrop-blur-md bg-ink/70', container: 'backdrop-blur-md bg-ink/70',
@@ -64,13 +62,13 @@ export function ContactSection() {
const phoneValue = form.phone.trim() const phoneValue = form.phone.trim()
if (nameValue.length < 3) { if (nameValue.length < 3) {
showErrorAlert(t('incompleteInfo'), t('nameRequired')) showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
return return
} }
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/ const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
if (!phoneValue || !phoneRegex.test(phoneValue)) { if (!phoneValue || !phoneRegex.test(phoneValue)) {
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText')) showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
return return
} }
@@ -85,14 +83,14 @@ export function ContactSection() {
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
const detail = const detail =
typeof data?.detail === 'string' ? data.detail : t('formCheck') typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
showErrorAlert(t('submitErrorTitle'), detail) showErrorAlert('خطا در ثبت درخواست', detail)
return return
} }
setSent(true) setSent(true)
} catch { } catch {
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText')) showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally { } finally {
setSending(false) setSending(false)
} }
@@ -234,7 +232,7 @@ export function ContactSection() {
value={form.phone} value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })} onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder={content.phone_placeholder} placeholder={content.phone_placeholder}
className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-sm font-light ltr:text-left rtl:text-right text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none" className="mt-3 w-full rounded-2xl border border-white/10 bg-ink/60 px-5 py-3.5 text-right text-sm font-light text-ivory placeholder:text-ivory/35 backdrop-blur-md transition-all duration-300 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3)] focus:border-copper/70 focus:bg-ink/80 focus:shadow-[0_0_15px_rgba(197,143,92,0.25)] focus:outline-none"
/> />
</div> </div>
@@ -264,7 +262,7 @@ export function ContactSection() {
) : ( ) : (
<> <>
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} /> <InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
</> </>
)} )}
</Button> </Button>
+2 -4
View File
@@ -1,17 +1,15 @@
import React from 'react'; import React from 'react';
import { PhoneIcon } from 'lucide-react'; import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang';
export function FloatingContact() { export function FloatingContact() {
const { brand } = useSite(); const { brand } = useSite();
const { t } = useLang();
return ( return (
<a <a
href={brand.phone_href} href={brand.phone_href}
aria-label={t('callBrand')} aria-label="تماس تلفنی با گروه ساختمانی آرامش"
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95" className="group fixed bottom-8 left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
> >
{/* پالس هاله نوری در پس‌زمینه مانند رادار */} {/* پالس هاله نوری در پس‌زمینه مانند رادار */}
<span <span
+3 -5
View File
@@ -2,7 +2,6 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'; import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from './admin/InlineEdit'; import { InlineEdit } from './admin/InlineEdit';
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable'; import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
@@ -15,7 +14,6 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
export function Footer() { export function Footer() {
const { brand, services, nav, footer } = useSite(); const { brand, services, nav, footer } = useSite();
const { t, href } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const saveFooter = (field: string, value: string) => const saveFooter = (field: string, value: string) =>
@@ -40,7 +38,7 @@ export function Footer() {
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<div className="relative w-fit"> <div className="relative w-fit">
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" /> <img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" /> <SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
</div> </div>
<p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60"> <p className="mt-6 max-w-sm text-sm font-light leading-8 text-ivory/60">
<InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} /> <InlineEdit multiline value={footer.about_text} onSave={(v) => saveFooter('about_text', v)} />
@@ -72,7 +70,7 @@ export function Footer() {
<EditableItem key={item.id} resource="nav" item={item}> <EditableItem key={item.id} resource="nav" item={item}>
<li> <li>
<Link <Link
to={href(item.href)} to={item.href}
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper" className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
> >
<InlineEdit <InlineEdit
@@ -138,7 +136,7 @@ export function Footer() {
onClick={scrollToTop} onClick={scrollToTop}
className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer" className="group flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.04] px-4 py-2 text-xs font-medium text-ivory/70 backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper hover:bg-copper/10 hover:text-copper hover:shadow-[0_0_20px_rgba(197,143,92,0.3)] active:scale-95 cursor-pointer"
> >
<span>{t('backToTop')}</span> <span>بازگشت به بالا</span>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char"> <span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-copper transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:bg-copper group-hover:text-char">
<ArrowUpIcon className="h-3 w-3" /> <ArrowUpIcon className="h-3 w-3" />
</span> </span>
+7 -13
View File
@@ -2,15 +2,12 @@ import React, { useEffect, useState } from "react";
import { Link, NavLink, useLocation } from "react-router-dom"; import { Link, NavLink, useLocation } from "react-router-dom";
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react"; import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
import { useSite } from "../api"; import { useSite } from "../api";
import { useLang } from "../i18n/lang";
import { useFieldSaver } from "../admin/AdminContext"; import { useFieldSaver } from "../admin/AdminContext";
import { InlineEdit } from "./admin/InlineEdit"; import { InlineEdit } from "./admin/InlineEdit";
import { EditableItem, SingletonPill } from "../admin/Editable"; import { EditableItem, SingletonPill } from "../admin/Editable";
import { LangToggle } from "./LangToggle";
export function Header() { export function Header() {
const { brand, nav } = useSite(); const { brand, nav } = useSite();
const { t, href } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const saveNav = (id: number, changes: Record<string, unknown>) => const saveNav = (id: number, changes: Record<string, unknown>) =>
@@ -43,7 +40,7 @@ export function Header() {
<div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10"> <div className="mx-auto flex max-w-site items-center justify-between gap-8 px-6 py-4 lg:px-10">
<div className="relative flex items-center"> <div className="relative flex items-center">
<Link <Link
to={href("/")} to="/"
className="flex items-center gap-3" className="flex items-center gap-3"
aria-label={brand.name} aria-label={brand.name}
> >
@@ -55,16 +52,16 @@ export function Header() {
}`} }`}
/> />
</Link> </Link>
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" /> <SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
</div> </div>
<nav aria-label={t("mainNav")} className="hidden xl:block"> <nav aria-label="ناوبری اصلی" className="hidden xl:block">
<ul className="flex items-center gap-7"> <ul className="flex items-center gap-7">
{nav.map((item) => ( {nav.map((item) => (
<EditableItem key={item.id} resource="nav" item={item}> <EditableItem key={item.id} resource="nav" item={item}>
<li> <li>
<NavLink <NavLink
to={href(item.href)} to={item.href}
className={({ isActive }) => className={({ isActive }) =>
`group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${ `group relative py-2 text-[0.95rem] font-light transition-colors duration-200 ease-lux hover:text-copper [text-shadow:_0_2px_4px_rgba(0,0,0,0.6)] ${
isActive ? "text-copper font-normal" : "text-ivory/80" isActive ? "text-copper font-normal" : "text-ivory/80"
@@ -105,15 +102,12 @@ export function Header() {
<span dir="ltr" className="tracking-wide">{brand.phone}</span> <span dir="ltr" className="tracking-wide">{brand.phone}</span>
</a> </a>
{/* تغییر زبان — فارسی/انگلیسی */}
<LangToggle />
{/* دکمه منوی موبایل دایره‌ای شیشه‌ای */} {/* دکمه منوی موبایل دایره‌ای شیشه‌ای */}
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden" className="flex h-11 w-11 items-center justify-center rounded-full border border-white/15 bg-white/5 text-ivory backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 xl:hidden"
aria-label={open ? t("closeMenu") : t("openMenu")} aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open} aria-expanded={open}
> >
{open ? ( {open ? (
@@ -127,7 +121,7 @@ export function Header() {
{open && ( {open && (
<nav <nav
aria-label={t("mobileNav")} aria-label="ناوبری موبایل"
className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl" className="border-t border-white/10 bg-ink/95 backdrop-blur-2xl px-6 py-4 xl:hidden shadow-2xl"
> >
<ul className="grid gap-1"> <ul className="grid gap-1">
@@ -135,7 +129,7 @@ export function Header() {
<EditableItem key={item.id} resource="nav" item={item}> <EditableItem key={item.id} resource="nav" item={item}>
<li> <li>
<NavLink <NavLink
to={href(item.href)} to={item.href}
className={({ isActive }) => className={({ isActive }) =>
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${ `block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
isActive ? "text-copper" : "text-ivory/80" isActive ? "text-copper" : "text-ivory/80"
-31
View File
@@ -1,31 +0,0 @@
import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) {
const { lang, t } = useLang()
const navigate = useNavigate()
const { pathname } = useLocation()
const target = counterpartPath(pathname, lang)
const switchLang = () => {
writeLangCookie(lang === 'fa' ? 'en' : 'fa')
// پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد
sessionStorage.setItem('langAutoRedirect', '1')
navigate(target)
}
return (
<button
type="button"
onClick={switchLang}
aria-label={t('switchLangAria')}
title={t('switchLangAria')}
className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`}
>
<LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" />
<span>{t('switchLang')}</span>
</button>
)
}
+4 -6
View File
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ChevronLeftIcon } from 'lucide-react' import { ChevronLeftIcon } from 'lucide-react'
import { usePageHero } from '../api' import { usePageHero } from '../api'
import { useLang } from '../i18n/lang'
import { useFieldSaver } from '../admin/AdminContext' import { useFieldSaver } from '../admin/AdminContext'
import { SingletonPill } from '../admin/Editable' import { SingletonPill } from '../admin/Editable'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
@@ -16,7 +15,6 @@ type PageHeroProps = {
export function PageHero({ pageKey }: PageHeroProps) { export function PageHero({ pageKey }: PageHeroProps) {
const hero = usePageHero(pageKey) const hero = usePageHero(pageKey)
const { t, href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const saveField = (field: 'title' | 'lead' | 'image', value: string) => const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
@@ -38,11 +36,11 @@ export function PageHero({ pageKey }: PageHeroProps) {
/> />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50"> <nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper"> <Link to="/" className="transition-colors hover:text-copper">
{t('home')} صفحه اصلی
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{hero.title}</span> <span className="text-copper">{hero.title}</span>
</nav> </nav>
<motion.h1 <motion.h1
+3 -4
View File
@@ -1,19 +1,18 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useSiteData } from '../api' import { useSiteData } from '../api'
import { useLang } from '../i18n/lang'
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود // برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری داده‌ها از دیتابیس استفاده می‌شود
const FALLBACK_LOGO = '/images/logo.png' const FALLBACK_LOGO = '/images/logo.png'
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
export function Preloader() { export function Preloader() {
const { data } = useSiteData() const { data } = useSiteData()
const { t } = useLang()
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const logo = data?.brand.logo || FALLBACK_LOGO const logo = data?.brand.logo || FALLBACK_LOGO
const name = data?.brand.name || t('brandFallback') const name = data?.brand.name || FALLBACK_NAME
useEffect(() => { useEffect(() => {
const start = Date.now() const start = Date.now()
@@ -44,7 +43,7 @@ export function Preloader() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
aria-live="polite" aria-live="polite"
aria-label={t('loading')} aria-label="در حال بارگذاری"
> >
<div className="relative h-36 w-36"> <div className="relative h-36 w-36">
<img <img
+6 -9
View File
@@ -2,19 +2,17 @@ import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ArrowLeftIcon } from 'lucide-react' import { ArrowLeftIcon } from 'lucide-react'
import { useStatusLabel } from '../api' import { useStatusLabel } from '../api'
import { useLang } from '../i18n/lang'
export function ProjectCard({ project }: any) { export function ProjectCard({ project }: any) {
const isOngoing = project.status === 'progress' const isOngoing = project.status === 'progress'
const statusText = useStatusLabel(project.status) const statusText = useStatusLabel(project.status)
const { t, href, lang } = useLang()
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60' const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90' const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
return ( return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"> <article className="group flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-char/85 backdrop-blur-xl shadow-[0_10px_30px_rgba(0,0,0,0.3)] transition-all duration-500 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-[0_20px_40px_rgba(0,0,0,0.6),0_0_30px_rgba(197,143,92,0.2)] focus:outline-none focus-visible:ring-2 focus-visible:ring-copper">
<Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none"> <Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */} {/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */}
<div className="relative aspect-[4/3] w-full overflow-hidden"> <div className="relative aspect-[4/3] w-full overflow-hidden">
@@ -25,14 +23,14 @@ export function ProjectCard({ project }: any) {
/> />
{/* نشانگر وضعیت کپسولی شیشه‌ای */} {/* نشانگر وضعیت کپسولی شیشه‌ای */}
<div className={`absolute top-4 ltr:left-4 rtl:right-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}> <div className={`absolute right-4 top-4 flex items-center gap-2 rounded-full border border-white/15 bg-ink/75 px-4 py-1.5 text-xs font-light backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.2),0_4px_15px_rgba(0,0,0,0.4)] ${textColor}`}>
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} /> <span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
{statusText} {statusText}
</div> </div>
{/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */} {/* گوی دکمه شیشه‌ای شناور برای ورود به پروژه */}
<div className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]"> <div className="absolute bottom-4 left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]">
<ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" /> <ArrowLeftIcon className="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-0.5" />
</div> </div>
</div> </div>
@@ -49,9 +47,8 @@ export function ProjectCard({ project }: any) {
<div className="my-6 h-px w-full bg-white/10" /> <div className="my-6 h-px w-full bg-white/10" />
<div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60"> <div className="mt-auto flex items-center justify-between text-sm font-light text-ivory/60">
<span>{t('yearLabel')} {project.year}</span> <span>سال: {project.year}</span>
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */} <span>{project.area} متر مربع</span>
<span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span>
</div> </div>
</div> </div>
</Link> </Link>
+1 -1
View File
@@ -34,7 +34,7 @@ export function SectionHeading({
}) })
return ( return (
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}> <Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
<InlineEdit <InlineEdit
as="h2" as="h2"
value={currentTitle} value={currentTitle}
+43 -12
View File
@@ -6,23 +6,49 @@ import { slugifyFa, uniqueSlug } from '../utils/slug'
export type TocItem = { id: string; text: string; level: 2 | 3 } export type TocItem = { id: string; text: string; level: 2 | 3 }
/** /**
* استخراج فهرست مطالب از HTML ویرایشگر — تیترهای H2 و H3 شناسه لنگر می‌گیرند * متن یک تیتر از HTML — همه گره‌های تودرتو پیمایش می‌شوند و <br> به فاصله تبدیل
* (با slugify و پشتیبانی فارسی) و HTML برگشتی همان است که باید رندر شود. * می‌شود تا تیترهای چندخطی یا شامل مارک (مثل متن انگلیسی) کامل خوانده شوند.
*/ */
export function extractToc(html: string): { items: TocItem[]; html: string } { function headingText(element: Element): string {
const parts: string[] = []
element.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? '')
else if (node.nodeType === Node.ELEMENT_NODE) {
const child = node as Element
parts.push(child.tagName === 'BR' ? ' ' : headingText(child))
}
})
return parts.join('')
}
/**
* استخراج فهرست مطالب از HTML ویرایشگر — همه تیترهای H2 و H3 در هر عمقی از سند
* (حتی داخل نقل‌قول) شناسه لنگر می‌گیرند؛ متن فارسی در شناسه حفظ می‌شود و تیترهای
* تکراری پسوند عددی می‌گیرند. HTML برگشتی همان است که باید رندر شود.
*/
export function extractToc(
html: string,
transform?: (root: HTMLElement) => void,
): { items: TocItem[]; html: string } {
if (!html || typeof window === 'undefined') return { items: [], html } if (!html || typeof window === 'undefined') return { items: [], html }
const doc = new DOMParser().parseFromString(html, 'text/html') const doc = new DOMParser().parseFromString(html, 'text/html')
const used = new Map<string, number>() const used = new Map<string, number>()
const items: TocItem[] = [] const items: TocItem[] = []
doc.body.querySelectorAll('h2, h3').forEach((el) => { doc.body.querySelectorAll('h2, h3').forEach((element, index) => {
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() const text = headingText(element).replace(/\s+/g, ' ').trim()
if (!text) return if (!text) return
const id = uniqueSlug(slugifyFa(text), used) // تیتر بدون حرف و رقم (فقط نشانه) شناسه ترتیبی می‌گیرد تا لنگرش بی‌نام نماند
el.setAttribute('id', id) const id = uniqueSlug(slugifyFa(text) || `heading-${index + 1}`, used)
items.push({ id, text, level: el.tagName === 'H3' ? 3 : 2 }) element.setAttribute('id', id)
items.push({ id, text, level: element.tagName === 'H3' ? 3 : 2 })
}) })
// تبدیل اختیاری (بسته‌بندی متن لاتین/فارسی برای اندازه دوزبانه) روی همان DOM اجرا می‌شود
transform?.(doc.body)
return { items, html: doc.body.innerHTML } return { items, html: doc.body.innerHTML }
} }
@@ -32,7 +58,11 @@ type TableOfContentsProps = {
className?: string className?: string
} }
/** فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver) */ /**
* فهرست مطالب کلیک‌پذیر با هایلایت خودکار تیتر فعال (IntersectionObserver).
* همه اندازه‌ها و ترازبندی‌ها منطقی‌اند (start/end) تا در چیدمان راست‌به‌چپ و
* چپ‌به‌راست — بر اساس جهت مقاله — درست بنشیند.
*/
export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) { export function TableOfContents({ items, title = 'فهرست مطالب', className = '' }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '') const [activeId, setActiveId] = useState<string>(items[0]?.id ?? '')
@@ -79,7 +109,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
{title} {title}
</p> </p>
<div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" /> <div className="mt-5 h-px w-12 bg-copper/50" aria-hidden="true" />
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-r border-ivory/10 pr-3"> {/* ارتفاع فهرست به ارتفاع در دسترس صفحه (زیر نوار چسبان) محدود می‌شود تا بریده نشود */}
<ul className="mt-4 max-h-[60vh] space-y-0.5 overflow-y-auto border-s border-ivory/10 ps-3 lg:max-h-[calc(100vh_-_15rem)]">
{items.map((item) => { {items.map((item) => {
const isActive = item.id === activeId const isActive = item.id === activeId
return ( return (
@@ -88,8 +119,8 @@ export function TableOfContents({ items, title = 'فهرست مطالب', classN
type="button" type="button"
onClick={(event) => go(event, item.id)} onClick={(event) => go(event, item.id)}
aria-current={isActive ? 'location' : undefined} aria-current={isActive ? 'location' : undefined}
className={`block w-full text-right text-sm font-light leading-8 transition-colors duration-200 ease-lux ${ className={`block w-full text-start text-sm font-light leading-8 transition-colors duration-200 ease-lux ${
item.level === 3 ? 'pr-4 text-[13px]' : '' item.level === 3 ? 'ps-4 text-[13px]' : ''
} ${ } ${
isActive isActive
? 'font-normal text-copper' ? 'font-normal text-copper'
@@ -25,10 +25,13 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { DEFAULT_IMAGE_ALT } from '../../data/constants'
import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link' import { buildLinkRel, isAllowedHref, isUrlLike, normalizeHref, parseLinkRel } from '../../utils/link'
import { ArticleImage } from './ArticleImage' import { ArticleImage } from './ArticleImage'
import { BidiTypography, bidiPluginKey } from './BidiTypography'
import { EnglishText } from './EnglishText' import { EnglishText } from './EnglishText'
import { LinkModal } from './LinkModal' import { LinkModal } from './LinkModal'
import { applyBlockFormat, handleHeadingEnter } from './blockFormat'
import type { LinkModalValue } from './LinkModal' import type { LinkModalValue } from './LinkModal'
/** /**
@@ -59,13 +62,22 @@ export const ARTICLE_EXTENSIONS = [
}), }),
LegacySmallSizeMark, LegacySmallSizeMark,
EnglishText, EnglishText,
BidiTypography,
] ]
type ArticleEditorProps = { type ArticleEditorProps = {
doc: Record<string, unknown> doc: Record<string, unknown>
onChange: (json: Record<string, unknown>) => void onChange: (json: Record<string, unknown>) => void
/**
* شناسه محتوا — با هر تغییر آن، بدنه ویرایشگر از `doc` جاری دوباره بارگذاری می‌شود
* (مثلاً وقتی مقاله از سرور می‌رسد). بدون این prop محتوا فقط یک‌بار مقداردهی می‌شود.
*/
contentKey?: string | number
} }
/** نمونه ویرایشگر TipTap — همان چیزی که useEditor برمی‌گرداند */
type EditorInstance = NonNullable<ReturnType<typeof useEditor>>
type TextDirection = 'rtl' | 'ltr' type TextDirection = 'rtl' | 'ltr'
/** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */ /** نحوه اعمال لینک: روی انتخاب فعلی، درج متن تازه یا تبدیل محدوده تایپ‌شده */
@@ -126,7 +138,7 @@ const Divider = () => <span className="mx-1 h-5 w-px bg-ivory/10" aria-hidden="t
* ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور، * ویرایشگر WYSIWYG مقاله (TipTap) — نود تصویر کپشن‌دار، مودال لینک سئو-محور،
* کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO. * کلید راست‌چین/چپ‌چین (فقط روی متن اثر دارد، نه نوار ابزار) و آپلود تصویر به MinIO.
*/ */
export function ArticleEditor({ doc, onChange }: ArticleEditorProps) { export function ArticleEditor({ doc, onChange, contentKey }: ArticleEditorProps) {
const { runSave } = useAdmin() const { runSave } = useAdmin()
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -192,6 +204,8 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
}) })
return false return false
}, },
// Enter داخل تیتر، بلوک تازه را پاراگراف می‌کند تا قالب تیتر سرایت نکند
handleKeyDown: (view, event) => handleHeadingEnter(view, event),
}, },
onUpdate: ({ editor: instance }) => onChange(instance.getJSON()), onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
}) })
@@ -201,14 +215,40 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
if (!editor) return if (!editor) return
editor.view.dom.setAttribute('dir', direction) editor.view.dom.setAttribute('dir', direction)
editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en') editor.view.dom.setAttribute('lang', direction === 'rtl' ? 'fa' : 'en')
// تزئین‌های دوزبانه (اندازه ۲ پیکسل کوچک‌تر متن لاتین) با جهت تازه بازمحاسبه می‌شوند
editor.view.dispatch(editor.state.tr.setMeta(bidiPluginKey, direction))
}, [editor, direction]) }, [editor, direction])
/**
* ترکیب (نمونه ویرایشگر، شناسه محتوا) که آخرین بار بارگذاری شده است.
* تغییرهای معمول سند (تایپ کاربر) نباید بدنه را از نو بارگذاری کنند.
*/
const loadedContentRef = useRef<{ editor: EditorInstance; key: string | number } | null>(null)
// بارگذاری بدنه با تغییر شناسه محتوا (رسیدن مقاله از سرور) یا ساخته‌شدن نمونه ویرایشگر
useEffect(() => {
if (!editor || contentKey === undefined) return
const loaded = loadedContentRef.current
if (loaded && loaded.editor === editor && loaded.key === contentKey) return
const incoming = doc && Object.keys(doc).length ? doc : null
if (!incoming) return
loadedContentRef.current = { editor, key: contentKey }
// emitUpdate=false تا بارگذاری، ذخیره خودکارِ بی‌مورد را فعال نکند
editor.commands.setContent(incoming, { emitUpdate: false })
}, [editor, contentKey, doc])
async function uploadAndInsert(file: File) { async function uploadAndInsert(file: File) {
if (!editor) return if (!editor) return
setUploading(true) setUploading(true)
await runSave(async () => { await runSave(async () => {
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
editor.chain().focus().setImage({ src: url, alt: file.name.replace(/\.[^.]+$/, '') }).run() // نام پیش‌فرض از نام فایل ساخته می‌شود و در نبود آن، مقدار پیش‌فرض ثابت می‌ماند
const defaultAlt = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim()
editor
.chain()
.focus()
.setImage({ src: url, alt: defaultAlt || DEFAULT_IMAGE_ALT })
.run()
}) })
setUploading(false) setUploading(false)
} }
@@ -271,28 +311,28 @@ export function ArticleEditor({ doc, onChange }: ArticleEditorProps) {
<ToolButton <ToolButton
label="پاراگراف" label="پاراگراف"
active={editor.isActive('paragraph')} active={editor.isActive('paragraph')}
onClick={() => editor.chain().focus().setParagraph().run()} onClick={() => applyBlockFormat(editor.view, 'paragraph')}
> >
<PilcrowIcon className="h-4 w-4" /> <PilcrowIcon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۱" label="تیتر ۱"
active={editor.isActive('heading', { level: 1 })} active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} onClick={() => applyBlockFormat(editor.view, 1)}
> >
<Heading1Icon className="h-4 w-4" /> <Heading1Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۲" label="تیتر ۲"
active={editor.isActive('heading', { level: 2 })} active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => applyBlockFormat(editor.view, 2)}
> >
<Heading2Icon className="h-4 w-4" /> <Heading2Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
<ToolButton <ToolButton
label="تیتر ۳" label="تیتر ۳"
active={editor.isActive('heading', { level: 3 })} active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} onClick={() => applyBlockFormat(editor.view, 3)}
> >
<Heading3Icon className="h-4 w-4" /> <Heading3Icon className="h-4 w-4" />
</ToolButton> </ToolButton>
@@ -0,0 +1,64 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { EditorState } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { LATIN_CLASS, RTL_CLASS, findScriptRuns } from '../../utils/bidiText'
/** نام مارک‌های قدیمی که خودشان اندازه/جهت متن را تعیین می‌کنند */
const LEGACY_MARKS = new Set(['englishText', 'smallSize'])
type BidiState = { dir: 'rtl' | 'ltr' }
/** کلید پلاگین — جهت متن با متای همین کلید به‌روز می‌شود */
export const bidiPluginKey = new PluginKey<BidiState>('bidiTypography')
/**
* تزئین دوزبانه متن ویرایشگر — اجرای لاتین (و در متن انگلیسی، اجرای فارسی) با یک
* span جهت‌دار نشانه‌گذاری می‌شود تا ویرایشگر هم دقیقاً همان اندازه «۲ پیکسل کوچک‌تر»
* خروجی سایت را نشان دهد. تزئین‌ها سند را تغییر نمی‌دهند و ذخیره نمی‌شوند.
*/
export const BidiTypography = Extension.create({
name: 'bidiTypography',
addProseMirrorPlugins() {
return [
new Plugin<BidiState>({
key: bidiPluginKey,
state: {
init: () => ({ dir: 'rtl' }),
apply: (tr, value) => {
const dir = tr.getMeta(bidiPluginKey) as BidiState['dir'] | undefined
return dir && dir !== value.dir ? { dir } : value
},
},
props: {
decorations: (state) =>
buildDecorations(state, bidiPluginKey.getState(state)?.dir ?? 'rtl'),
},
}),
]
},
})
function buildDecorations(state: EditorState, dir: 'rtl' | 'ltr'): DecorationSet {
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
const decorations: Decoration[] = []
const { doc, schema } = state
doc.descendants((node, pos, parent) => {
if (node.type.name === 'codeBlock' || parent?.type.name === 'codeBlock') return false
if (!node.isText || !node.text) return
if (node.marks.some((mark) => mark.type === schema.marks.code || LEGACY_MARKS.has(mark.type.name))) {
return
}
for (const run of findScriptRuns(node.text, dir)) {
decorations.push(
Decoration.inline(pos + run.from, pos + run.to, { class: className, dir: runDir }),
)
}
})
return DecorationSet.create(doc, decorations)
}
+30 -44
View File
@@ -1,8 +1,9 @@
import { useEffect, useId, useRef, useState } from 'react' import { useId, useRef, useState } from 'react'
import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react' import { ImageIcon, ImagePlusIcon, Loader2Icon } from 'lucide-react'
import { DEFAULT_IMAGE_TITLE } from '../../data/constants' import { DEFAULT_IMAGE_TITLE } from '../../data/constants'
import { adminApi } from '../../admin/adminClient' import { adminApi } from '../../admin/adminClient'
import { useAdmin } from '../../admin/AdminContext' import { useAdmin } from '../../admin/AdminContext'
import { useSmartTextField } from './useSmartTextField'
type ImageFieldProps = { type ImageFieldProps = {
label: string label: string
@@ -30,6 +31,7 @@ const META_FIELD_CLASS =
* انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ. * انتخاب و آپلود تصویر به MinIO با پیش‌نمایش بزرگ.
* ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»). * ترتیب فیلدها: ۱) عنوان تصویر (پیش‌فرض «Photo 1») ۲) متن جایگزین (پیش‌فرض «{عنوان} - تصویر شاخص»).
* هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود. * هر دو «دستی یا پیش‌فرض» هستند و Alt هرگز از URL ساخته نمی‌شود.
* پیش‌فرض خودکار با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند و با بلورِ خالی برمی‌گردد.
*/ */
export function ImageField({ export function ImageField({
label, label,
@@ -47,40 +49,25 @@ export function ImageField({
const titleId = useId() const titleId = useId()
const altId = useId() const altId = useId()
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [altText, setAltText] = useState(alt ?? '')
const [titleText, setTitleText] = useState(title ?? '')
const showTitle = title !== undefined || !!onSaveTitle const showTitle = title !== undefined || !!onSaveTitle
const showAlt = alt !== undefined || !!onSaveAlt const showAlt = alt !== undefined || !!onSaveAlt
// همگام‌سازی با مقادیر ذخیره‌شده (پس از به‌روزرسانی خوش‌بینانه در درخت داده) const titleField = useSmartTextField({
useEffect(() => { value: title ?? '',
setAltText(alt ?? '') fallback: titleFallback,
}, [alt]) hardFallback: DEFAULT_IMAGE_TITLE,
useEffect(() => { onCommit: onSaveTitle,
setTitleText(title ?? '') })
}, [title]) const altField = useSmartTextField({
value: alt ?? '',
fallback: altFallback,
hardFallback: label,
onCommit: onSaveAlt,
})
if (!isAuthenticated || !isEditMode) return null if (!isAuthenticated || !isEditMode) return null
/** عنوان نمایشی: ورودی کاربر → پیشنهاد → «Photo 1» (با تغییر عنوان مقاله زنده به‌روز می‌شود) */
const titleDisplayed = titleText.trim() || titleFallback.trim() || DEFAULT_IMAGE_TITLE
/** Alt نمایشی: ورودی کاربر → پیشنهاد عنوان‌محور (هرگز URL) */
const altDisplayed = altText.trim() || altFallback.trim()
const resolveTitle = () => titleDisplayed
const resolveAlt = () => altDisplayed || label
const persistMeta = async () => {
const nextTitle = resolveTitle()
const nextAlt = resolveAlt()
setTitleText(nextTitle)
setAltText(nextAlt)
if (showTitle && nextTitle !== (title ?? '').trim() && onSaveTitle) await onSaveTitle(nextTitle)
if (showAlt && nextAlt !== (alt ?? '').trim() && onSaveAlt) await onSaveAlt(nextAlt)
}
const pick = async (file: File | undefined) => { const pick = async (file: File | undefined) => {
if (!file) return if (!file) return
setUploading(true) setUploading(true)
@@ -89,18 +76,15 @@ export function ImageField({
const { url } = await adminApi.upload(file) const { url } = await adminApi.upload(file)
const ok = await onSave(url) const ok = await onSave(url)
if (!ok) throw new Error('ذخیره نشد') if (!ok) throw new Error('ذخیره نشد')
await persistMeta() // پیش‌فرض‌های Alt/Title همان لحظه ثبت می‌شوند تا تصویر بدون متادیتا نماند
if (showTitle) await titleField.commit()
if (showAlt) await altField.commit()
}) })
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const commitMeta = () => {
if (!onSaveTitle && !onSaveAlt) return
void persistMeta()
}
return ( return (
<div> <div>
<p className="text-xs font-bold text-ivory/80">{label}</p> <p className="text-xs font-bold text-ivory/80">{label}</p>
@@ -110,8 +94,8 @@ export function ImageField({
{value ? ( {value ? (
<img <img
src={value} src={value}
alt={altText || label} alt={altField.display || label}
title={titleText || undefined} title={titleField.display || undefined}
className="h-[27rem] w-full object-cover" className="h-[27rem] w-full object-cover"
/> />
) : ( ) : (
@@ -149,10 +133,11 @@ export function ImageField({
id={titleId} id={titleId}
type="text" type="text"
dir="auto" dir="auto"
value={titleDisplayed} value={titleField.display}
onChange={(event) => setTitleText(event.target.value)} onChange={(event) => titleField.onChange(event.target.value)}
onBlur={commitMeta} onFocus={titleField.onFocus}
placeholder={titleFallback || DEFAULT_IMAGE_TITLE} onBlur={titleField.onBlur}
placeholder={titleFallback.trim() || DEFAULT_IMAGE_TITLE}
className={META_FIELD_CLASS} className={META_FIELD_CLASS}
/> />
</div> </div>
@@ -167,11 +152,12 @@ export function ImageField({
id={altId} id={altId}
type="text" type="text"
dir="auto" dir="auto"
value={altDisplayed} value={altField.display}
onChange={(event) => setAltText(event.target.value)} onChange={(event) => altField.onChange(event.target.value)}
onBlur={commitMeta} onFocus={altField.onFocus}
onBlur={altField.onBlur}
placeholder={ placeholder={
altFallback || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها' altFallback.trim() || 'توضیح کوتاه تصویر برای سئو و صفحه‌خوان‌ها'
} }
className={META_FIELD_CLASS} className={META_FIELD_CLASS}
/> />
+44 -28
View File
@@ -13,29 +13,48 @@ type ImageAttrs = {
const DELETE_BUTTON_CLASS = const DELETE_BUTTON_CLASS =
'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20' 'flex h-7 w-7 shrink-0 items-center justify-center border border-red-400/50 bg-ink/85 text-red-300 backdrop-blur-sm transition-colors duration-150 hover:bg-red-500/20'
const INLINE_FIELD_CLASS =
'w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none'
const ADD_CAPTION_CLASS =
'mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper'
/** /**
* نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت: * نمایش تصویر مقاله در ویرایشگر — دقیقاً با همان ساختار خروجی سایت:
* <figure><img><figcaption>کپشن</figcaption></figure> * <figure><img><figcaption>کپشن</figcaption></figure>
* دو دکمه حذف مستقل دارد: یکی برای خود تصویر و یکی برای حذف کامل کپشن. *
* با حذف کپشن، عنصر <figcaption> به‌طور کامل از DOM حذف می‌شود و فقط تصویر می‌ماند. * ویرایش کاملاً درون‌خطی است (بدون پاپ‌اور/مودال):
* - هر تصویر یک «جعبه توضیح/کپشن» همراه خود دارد و متن آن در figcaption ذخیره می‌شود.
* - دکمه X کپشن را کامل حذف می‌کند: صفت کپشن به مقدار پیش‌فرض برمی‌گردد و عنصر
* figcaption با ورودی‌اش از DOM ویرایشگر (و از خروجی ذخیره‌شده) حذف می‌شود؛
* فقط تصویر می‌ماند و «افزودن کپشن» دوباره جعبه را باز می‌کند.
* - ویرایش Alt در ویرایشگر وجود ندارد؛ Alt هنگام درج از نام فایل ثبت می‌شود.
* - دکمه X روی تصویر کل تصویر را حذف می‌کند.
*/ */
export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) { export function ImageNodeView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
const { src, alt, title, caption } = node.attrs as ImageAttrs const { src, alt, title, caption } = node.attrs as ImageAttrs
const hasCaption = Boolean((caption ?? '').trim()) const hasCaption = Boolean((caption ?? '').trim())
const [editingCaption, setEditingCaption] = useState(false) const [captionOpen, setCaptionOpen] = useState(true)
const inputRef = useRef<HTMLInputElement>(null) const captionInputRef = useRef<HTMLInputElement>(null)
const focusOnOpen = useRef(false)
const showCaption = captionOpen || hasCaption
const showCaption = hasCaption || editingCaption // فقط وقتی کاربر «افزودن کپشن» را می‌زند ورودی فوکوس می‌گیرد (نه هنگام بارگذاری مقاله)
// هنگام افزودن کپشن تازه، ورودی خودکار فوکوس می‌گیرد
useEffect(() => { useEffect(() => {
if (editingCaption && !hasCaption) inputRef.current?.focus() if (!focusOnOpen.current) return
}, [editingCaption, hasCaption]) focusOnOpen.current = false
captionInputRef.current?.focus()
}, [captionOpen])
/** حذف کپشن: مقدار صفت پاک و عنصر figcaption کاملاً unmount می‌شود */ /** حذف کامل کپشن: عنصر figcaption و ورودی‌اش از DOM ویرایشگر بیرون می‌رود */
const removeCaption = () => { const removeCaption = () => {
updateAttributes({ caption: '' }) updateAttributes({ caption: '' })
setEditingCaption(false) setCaptionOpen(false)
}
const addCaption = () => {
focusOnOpen.current = true
setCaptionOpen(true)
} }
return ( return (
@@ -58,18 +77,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
{showCaption && ( {showCaption ? (
<figcaption className="article-figure__caption" contentEditable={false}> <figcaption className="article-figure__caption" contentEditable={false}>
<input <input
ref={inputRef} ref={captionInputRef}
type="text" type="text"
value={caption ?? ''} value={caption ?? ''}
onChange={(event) => updateAttributes({ caption: event.target.value })} onChange={(event) => updateAttributes({ caption: event.target.value })}
placeholder="توضیح تصویر (کپشن)…" placeholder="توضیح تصویر (کپشن)…"
aria-label="کپشن تصویر" aria-label="کپشن تصویر"
className="w-full border border-ivory/15 bg-ink px-3 py-1.5 text-xs font-light text-ivory placeholder:text-ivory/30 focus:border-copper focus:outline-none" className={INLINE_FIELD_CLASS}
/> />
{/* حذف فقط کپشن — تصویر دست‌نخورده می‌ماند */} {/* حذف کامل کپشن — تصویر دست‌نخورده می‌ماند */}
<button <button
type="button" type="button"
onClick={removeCaption} onClick={removeCaption}
@@ -80,21 +99,18 @@ export function ImageNodeView({ node, updateAttributes, deleteNode, selected }:
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
</figcaption> </figcaption>
) : (
<button
type="button"
contentEditable={false}
onClick={addCaption}
className={ADD_CAPTION_CLASS}
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)} )}
</figure> </figure>
{/* وقتی کپشنی وجود ندارد، فقط یک دکمه افزودن نمایش داده می‌شود */}
{!showCaption && (
<button
type="button"
contentEditable={false}
onClick={() => setEditingCaption(true)}
className="mt-1.5 inline-flex items-center gap-1.5 border border-dashed border-ivory/25 px-3 py-1 text-[11px] font-light text-ivory/50 transition-colors duration-150 hover:border-copper hover:text-copper"
>
<PlusIcon className="h-3 w-3" aria-hidden="true" />
افزودن کپشن
</button>
)}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }
@@ -0,0 +1,121 @@
import { setBlockType, splitBlock } from '@tiptap/pm/commands'
import type { EditorView } from '@tiptap/pm/view'
import type { ResolvedPos } from '@tiptap/pm/model'
/** قالب بلوک جاری — پاراگراف یا یکی از سطوح تیتر */
export type BlockFormat = 'paragraph' | 1 | 2 | 3
type LineBounds = {
/** ابتدای بلوک جاری */
start: number
/** پایان بلوک جاری */
end: number
/** ابتدای خط جاری (پس از نزدیک‌ترین شکست نرم قبل از نشانگر) */
lineStart: number
/** پایان خط جاری (نزدیک‌ترین شکست نرم پس از نشانگر) */
lineEnd: number
/** موقعیت شکست نرم قبل از خط جاری — null یعنی خط، ابتدای بلوک است */
before: number | null
/** موقعیت شکست نرم بعد از خط جاری — null یعنی خط، انتهای بلوک است */
after: number | null
}
/** مرزهای خط جاری داخل بلوک جاری — خطوطی که با شکست نرم (<br>) از هم جدا شده‌اند */
function lineBounds($from: ResolvedPos): LineBounds {
const start = $from.start()
const end = $from.end()
let lineStart = start
let lineEnd = end
let before: number | null = null
let after: number | null = null
$from.parent.forEach((node, offset) => {
if (node.type.name !== 'hardBreak') return
const pos = start + offset
if (pos < $from.pos) {
lineStart = pos + 1
before = pos
} else if (after === null) {
lineEnd = pos
after = pos
}
})
return { start, end, lineStart, lineEnd, before, after }
}
/**
* جدا کردن خط جاری از خطوط چسبیده با شکست نرم (Shift+Enter یا متن چسبانده‌شده از Word).
* بدون این جداسازی، عوض کردن قالب یک خط، قالب همه خطوط همان بلوک را با هم عوض می‌کند.
* اگر گزینش از خط عبور کند (قالب‌بندی چندخطیِ عمدی) دست‌نخورده می‌ماند.
*/
function isolateCurrentLine(view: EditorView): boolean {
const { state } = view
const { selection } = state
const bounds = lineBounds(selection.$from)
if (bounds.before === null && bounds.after === null) return false
const selectionInsideLine =
selection.empty ||
(selection.from >= bounds.lineStart && selection.to <= bounds.lineEnd)
if (!selectionInsideLine) return false
const { tr } = state
if (bounds.after !== null) {
tr.delete(bounds.after, bounds.after + 1)
tr.split(bounds.after)
}
if (bounds.before !== null) {
tr.delete(bounds.before, bounds.before + 1)
tr.split(bounds.before)
}
if (!tr.docChanged) return false
view.dispatch(tr)
return true
}
/**
* قالب‌بندی بلوک جاری (تیتر ۱/۲/۳ یا پاراگراف) — فقط همان خطی که نشانگر در آن است
* و به خطوط بالا و پایین کاری ندارد. کلیک دوباره روی همان سطح تیتر، آن را به
* پاراگراف برمی‌گرداند (رفتار toggle).
*/
export function applyBlockFormat(view: EditorView, format: BlockFormat): boolean {
view.focus()
isolateCurrentLine(view)
const { state } = view
const paragraph = state.schema.nodes.paragraph
const heading = state.schema.nodes.heading
if (!paragraph || !heading) return false
if (format === 'paragraph') return setBlockType(paragraph)(state, view.dispatch, view)
const current = state.selection.$from.parent
const sameHeading = current.type === heading && current.attrs.level === format
return sameHeading
? setBlockType(paragraph)(state, view.dispatch, view)
: setBlockType(heading, { level: format })(state, view.dispatch, view)
}
/**
* Enter داخل تیتر: بلوک تازه پاراگراف می‌شود (نه تیتر تازه) تا قالب تیتر به خط
* بعدی سرایت نکند و مرز بلوک‌ها تمیز بماند.
* این هندلر پیش از keymap پیش‌فرض اجرا می‌شود و فقط همین حالت را می‌گیرد.
*/
export function handleHeadingEnter(view: EditorView, event: KeyboardEvent): boolean {
if (event.key !== 'Enter' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
return false
}
const { state } = view
const { selection } = state
const paragraph = state.schema.nodes.paragraph
if (!paragraph || !selection.empty || selection.$from.parent.type.name !== 'heading') return false
// خط تازه با splitBlock ساخته و بعد به پاراگراف تبدیل می‌شود
if (!splitBlock(state, view.dispatch, view)) return false
setBlockType(paragraph)(view.state, view.dispatch, view)
return true
}
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useRef, useState } from 'react'
type SmartTextFieldOptions = {
/** مقدار ذخیره‌شده در بیرون (منبع حقیقت) */
value: string
/** پیشنهاد خودکار — وقتی کاربر چیزی ننوشته، همین متن نمایش داده و ذخیره می‌شود */
fallback?: string
/** آخرین گزینه وقتی پیشنهادی هم وجود ندارد (مثل «Photo 1») */
hardFallback?: string
/** ذخیره مقدار نهایی — فقط وقتی نسبت به مقدار ذخیره‌شده تغییر کرده باشد */
onCommit?: (next: string) => void | Promise<unknown>
}
/**
* فیلد متنی با «پیش‌فرض هوشمند» برای Alt/Title تصویر:
* - متن کاربر تا لحظه ذخیره فقط در حالت محلی می‌ماند؛ در حین تایپ نه trim می‌شود و نه از
* بیرون بازنویسی می‌شود، پس فاصله انتهای متن و جای نشانگر حفظ می‌شود.
* - با فوکوس، پیش‌فرض خودکار پاک می‌شود تا کاربر بلافاصله تایپ کند.
* - با بلور، اگر فیلد خالی بماند پیش‌فرض خودکار برمی‌گردد و مقدار نهایی ذخیره می‌شود.
*/
export function useSmartTextField({
value,
fallback = '',
hardFallback = '',
onCommit,
}: SmartTextFieldOptions) {
const [text, setText] = useState(value)
const [focused, setFocused] = useState(false)
// تازه‌ترین مقادیر در ref نگه داشته می‌شوند تا هندلرها به closure قدیمی وابسته نباشند
const textRef = useRef(text)
textRef.current = text
const focusedRef = useRef(focused)
focusedRef.current = focused
const fallbackRef = useRef(fallback)
fallbackRef.current = fallback
const hardFallbackRef = useRef(hardFallback)
hardFallbackRef.current = hardFallback
const valueRef = useRef(value)
valueRef.current = value
// همگام‌سازی با مقدار ذخیره‌شده از بیرون (بارگذاری مقاله، به‌روزرسانی خوش‌بینانه)
// اما هرگز در حین ویرایش کاربر — وگرنه نشانگر می‌پرد و فاصله‌ها می‌خورند
useEffect(() => {
if (focusedRef.current) return
setText(value)
}, [value])
/** مقدار نهایی فیلد: متن کاربر → پیشنهاد خودکار → پیش‌فرض ثابت */
const resolve = useCallback((): string => {
const current = textRef.current.trim()
if (current) return current
return fallbackRef.current.trim() || hardFallbackRef.current.trim()
}, [])
const commit = useCallback(async (): Promise<void> => {
const next = resolve()
setText(next)
if (onCommit && next !== valueRef.current.trim()) await onCommit(next)
}, [onCommit, resolve])
const onFocus = useCallback(() => {
setFocused(true)
// پیش‌فرض خودکار متن واقعی نیست؛ با فوکوس پاک می‌شود تا کاربر بلافاصله تایپ کند
const current = textRef.current.trim()
const auto = fallbackRef.current.trim() || hardFallbackRef.current.trim()
if (!current || current === auto) setText('')
}, [])
const onBlur = useCallback(() => {
setFocused(false)
void commit()
}, [commit])
const onChange = useCallback((next: string) => {
setText(next)
}, [])
/** متن نمایشی — در نبود متن کاربر، پیشنهاد خودکار نشان داده می‌شود */
const display = focused ? text : text !== '' ? text : fallback
return { display, focused, onFocus, onBlur, onChange, commit, resolve }
}
+3 -5
View File
@@ -4,14 +4,12 @@ import { Reveal } from '../Reveal';
import { ButtonLink } from '../Button'; import { ButtonLink } from '../Button';
import { ArticleCard } from '../ArticleCard'; import { ArticleCard } from '../ArticleCard';
import { useSectionHeading, useSite } from '../../api'; import { useSectionHeading, useSite } from '../../api';
import { useLang } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
import { AddItemButton, EditableItem } from '../../admin/Editable'; import { AddItemButton, EditableItem } from '../../admin/Editable';
export function Articles() { export function Articles() {
const { articles } = useSite(); const { articles } = useSite();
const { href } = useLang();
const heading = useSectionHeading('articles'); const heading = useSectionHeading('articles');
const save = useFieldSaver(); const save = useFieldSaver();
@@ -27,7 +25,7 @@ export function Articles() {
return ( return (
<section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title"> <section className="bg-char py-24 lg:py-32" aria-labelledby="articles-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl ltr:text-left rtl:text-right"> <Reveal className="max-w-2xl text-right">
<p className="text-sm font-light text-copper"> <p className="text-sm font-light text-copper">
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} /> <InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
</p> </p>
@@ -53,9 +51,9 @@ export function Articles() {
{heading.cta_label && heading.cta_href && ( {heading.cta_label && heading.cta_href && (
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light"> <ButtonLink to={heading.cta_href} variant="outline" className="font-light">
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> <InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</div> </div>
</Reveal> </Reveal>
+1 -3
View File
@@ -3,12 +3,10 @@ import { MoveHorizontalIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
export function BeforeAfter() { export function BeforeAfter() {
const { before_after } = useSite() const { before_after } = useSite()
const { t } = useLang()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState(52) const [position, setPosition] = useState(52)
const dragging = useRef(false) const dragging = useRef(false)
@@ -104,7 +102,7 @@ export function BeforeAfter() {
<button <button
type="button" type="button"
role="slider" role="slider"
aria-label={t('compareAria')} aria-label="مقایسه کانسپت و پروژه ساخته‌شده"
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuenow={Math.round(position)} aria-valuenow={Math.round(position)}
+2 -4
View File
@@ -4,7 +4,6 @@ import { PlusIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
type FaqProps = { type FaqProps = {
@@ -13,7 +12,6 @@ type FaqProps = {
export function Faq({ hideHeading = false }: FaqProps) { export function Faq({ hideHeading = false }: FaqProps) {
const { faqs } = useSite() const { faqs } = useSite()
const { t } = useLang()
const [open, setOpen] = useState<number | null>(0) const [open, setOpen] = useState<number | null>(0)
return ( return (
@@ -24,8 +22,8 @@ export function Faq({ hideHeading = false }: FaqProps) {
{!hideHeading && ( {!hideHeading && (
<div className="lg:col-span-4"> <div className="lg:col-span-4">
<SectionHeading <SectionHeading
title={t('fallbackFaqTitle')} title="سوالات متداول"
lead={t('fallbackFaqLead')} lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران."
headingKey="faq" headingKey="faq"
/> />
</div> </div>
@@ -7,14 +7,12 @@ import { ProjectCard } from '../ProjectCard'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSectionHeading, useSite, useStatusLabel } from '../../api' import { useSectionHeading, useSite, useStatusLabel } from '../../api'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { useLang } from '../../i18n/lang'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
import type { ProjectStatus } from '../../data/site' import type { ProjectStatus } from '../../data/site'
export function FeaturedProjects() { export function FeaturedProjects() {
const { projects, status_labels } = useSite() const { projects, status_labels } = useSite()
const { t, href } = useLang()
const heading = useSectionHeading('featured_projects') const heading = useSectionHeading('featured_projects')
const save = useFieldSaver() const save = useFieldSaver()
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
@@ -39,7 +37,7 @@ export function FeaturedProjects() {
<SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" /> <SectionHeading title={heading.title} lead={heading.lead} headingKey="featured_projects" />
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */} {/* دکمه‌های فیلتر مینیمال و شیشه‌ای (بدون کادر) */}
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}> <div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
const isSelected = status === filter.key const isSelected = status === filter.key
return ( return (
@@ -104,7 +102,7 @@ export function FeaturedProjects() {
<Reveal delay={0.1}> <Reveal delay={0.1}>
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<ButtonLink <ButtonLink
to={href(heading.cta_href)} to={heading.cta_href}
variant="outline" variant="outline"
className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]" className="group flex items-center gap-3 rounded-full border border-white/20 bg-white/[0.04] px-8 py-3.5 text-sm backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:border-copper hover:bg-copper/10 hover:shadow-[0_0_30px_rgba(197,143,92,0.35)]"
> >
@@ -113,12 +111,12 @@ export function FeaturedProjects() {
onSave={(v) => onSave={(v) =>
save('section-headings', heading.id, { cta_label: v }, (d) => { save('section-headings', heading.id, { cta_label: v }, (d) => {
const h = d.section_headings.find((s) => s.id === heading.id) const h = d.section_headings.find((s) => s.id === heading.id)
if (h) (h as unknown as Record<string, unknown>)["cta_label"] = v if (h) h.cta_label = v
}) })
} }
/> />
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char"> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" /> <ArrowLeftIcon className="h-3.5 w-3.5" />
</span> </span>
</ButtonLink> </ButtonLink>
</div> </div>
+12 -14
View File
@@ -4,12 +4,10 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang, formatNumber } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Gallery() { export function Gallery() {
const { gallery } = useSite() const { gallery } = useSite()
const { t, lang } = useLang()
const [active, setActive] = useState<number | null>(null) const [active, setActive] = useState<number | null>(null)
const close = useCallback(() => setActive(null), []) const close = useCallback(() => setActive(null), [])
@@ -32,8 +30,8 @@ export function Gallery() {
<section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="gallery-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading <SectionHeading
title={t('fallbackGalleryTitle')} title="گالری تصاویر"
lead={t('fallbackGalleryLead')} lead="قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش."
headingKey="gallery" headingKey="gallery"
align="center" align="center"
/> />
@@ -46,7 +44,7 @@ export function Gallery() {
type="button" type="button"
onClick={() => setActive(i)} onClick={() => setActive(i)}
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper" className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`} aria-label={`بزرگ‌نمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
> >
<img <img
src={image.src} src={image.src}
@@ -71,14 +69,14 @@ export function Gallery() {
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={t('viewImage')} aria-label="نمایش تصویر"
onClick={close} onClick={close}
> >
<button <button
type="button" type="button"
onClick={close} onClick={close}
className="absolute top-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper" className="absolute left-8 top-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
aria-label={t('close')} aria-label="بستن"
> >
<XIcon className="h-5 w-5" /> <XIcon className="h-5 w-5" />
</button> </button>
@@ -89,10 +87,10 @@ export function Gallery() {
e.stopPropagation() e.stopPropagation()
step(-1) step(-1)
}} }}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10" className="absolute right-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:right-10"
aria-label={t('prevImage')} aria-label="تصویر قبلی"
> >
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" /> <ChevronRightIcon className="h-6 w-6" />
</button> </button>
<button <button
type="button" type="button"
@@ -100,10 +98,10 @@ export function Gallery() {
e.stopPropagation() e.stopPropagation()
step(1) step(1)
}} }}
className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10" className="absolute left-6 flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper md:left-10"
aria-label={t('nextImage')} aria-label="تصویر بعدی"
> >
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" /> <ChevronLeftIcon className="h-6 w-6" />
</button> </button>
<motion.img <motion.img
+9 -11
View File
@@ -3,14 +3,12 @@ import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react' import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
export function Hero() { export function Hero() {
const { brand, hero } = useSite() const { brand, hero } = useSite()
const { t, href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const { scrollY } = useScroll() const { scrollY } = useScroll()
const imageY = useTransform(scrollY, [0, 700], [0, 110]) const imageY = useTransform(scrollY, [0, 700], [0, 110])
@@ -23,11 +21,11 @@ export function Hero() {
}) })
return ( return (
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}> <section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
{/* تصویر پس‌زمینه */} {/* تصویر پس‌زمینه */}
<motion.img <motion.img
src={brand.hero} src={brand.hero}
alt={t('heroImageAlt')} alt="نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش"
style={{ y: imageY }} style={{ y: imageY }}
className="absolute inset-0 h-[114%] w-full object-cover" className="absolute inset-0 h-[114%] w-full object-cover"
/> />
@@ -64,7 +62,7 @@ export function Hero() {
transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.85, delay: 2.2, ease: [0.23, 1, 0.32, 1] }}
className="mt-6 text-hero font-bold text-ivory leading-[1.3]" className="mt-6 text-hero font-bold text-ivory leading-[1.3]"
> >
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} /> <InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
</motion.h1> </motion.h1>
{/* زیرتیتر */} {/* زیرتیتر */}
@@ -86,21 +84,21 @@ export function Hero() {
> >
{/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */} {/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */}
<ButtonLink <ButtonLink
to={href(hero.cta_primary_href)} to={hero.cta_primary_href}
variant="copper" variant="copper"
className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300" className="!p-1.5 !pr-7 !rounded-full shadow-lg shadow-black/40 hover:shadow-copper/25 transition-all duration-300"
> >
<span className="text-sm font-medium tracking-wide text-ivory"> <span className="text-sm font-medium tracking-wide text-ivory">
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} /> <InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
</span> </span>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1"> <span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 group-hover:-translate-x-1">
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" /> <ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
</span> </span>
</ButtonLink> </ButtonLink>
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */} {/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
<ButtonLink <ButtonLink
to={href(hero.cta_secondary_href)} to={hero.cta_secondary_href}
variant="outline" variant="outline"
className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm" className="!rounded-full !px-8 !py-3.5 bg-char/40 backdrop-blur-md border border-ivory/20 text-ivory hover:border-copper/70 hover:text-copper transition-all duration-300 shadow-sm"
> >
@@ -114,7 +112,7 @@ export function Hero() {
initial={{ opacity: 0, x: -24 }} initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10" className="absolute bottom-28 left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:left-10"
> >
<p className="text-3xl font-bold text-copper font-mono"> <p className="text-3xl font-bold text-copper font-mono">
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} /> <InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
@@ -128,7 +126,7 @@ export function Hero() {
{/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */}
<a <a
href="#story" href="#story"
aria-label={t('scrollDown')} aria-label="رفتن به بخش بعدی"
className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper" className="group absolute bottom-7 right-1/2 z-10 flex translate-x-1/2 flex-col items-center gap-2 text-ivory/60 transition-colors duration-300 hover:text-copper"
> >
<span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100"> <span className="text-[11px] font-light tracking-widest opacity-80 group-hover:opacity-100">
+7 -9
View File
@@ -4,7 +4,6 @@ import Swal from 'sweetalert2'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { Button } from '../Button' import { Button } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
@@ -12,7 +11,6 @@ import { API_URL } from '../../config'
export function Newsletter() { export function Newsletter() {
const { newsletter } = useSite() const { newsletter } = useSite()
const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle') const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
@@ -31,7 +29,7 @@ export function Newsletter() {
background: '#1e1a16', background: '#1e1a16',
color: '#F5F3EF', color: '#F5F3EF',
iconColor: '#d5874c', iconColor: '#d5874c',
confirmButtonText: t('understood'), confirmButtonText: 'متوجه شدم',
buttonsStyling: false, buttonsStyling: false,
customClass: { customClass: {
container: 'backdrop-blur-md bg-ink/70', container: 'backdrop-blur-md bg-ink/70',
@@ -60,13 +58,13 @@ export function Newsletter() {
if (!emailValue) { if (!emailValue) {
setStatus('error') setStatus('error')
showErrorAlert(t('incompleteInfo'), t('emailRequired')) showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
return return
} }
if (!emailRegex.test(emailValue)) { if (!emailRegex.test(emailValue)) {
setStatus('error') setStatus('error')
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText')) showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
return return
} }
@@ -82,8 +80,8 @@ export function Newsletter() {
setStatus('error') setStatus('error')
const data = await res.json().catch(() => null) const data = await res.json().catch(() => null)
const detail = const detail =
typeof data?.detail === 'string' ? data.detail : t('emailCheck') typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
showErrorAlert(t('subscribeErrorTitle'), detail) showErrorAlert('خطا در عضویت', detail)
return return
} }
@@ -91,7 +89,7 @@ export function Newsletter() {
setEmail('') setEmail('')
} catch { } catch {
setStatus('error') setStatus('error')
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText')) showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
} finally { } finally {
setSending(false) setSending(false)
} }
@@ -148,7 +146,7 @@ export function Newsletter() {
disabled={sending} disabled={sending}
className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95" className="group flex items-center justify-center gap-2 rounded-full px-8 py-4 shadow-[0_8px_20px_rgba(0,0,0,0.3)] transition-all duration-300 hover:shadow-[0_12px_25px_rgba(0,0,0,0.45)] active:scale-95"
> >
<SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
<span> <span>
{sending ? newsletter.sending_label : ( {sending ? newsletter.sending_label : (
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} /> <InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
+7 -6
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useInView } from 'framer-motion'; import { useInView } from 'framer-motion';
import { useSite } from '../../api'; import { useSite } from '../../api';
import { useLang, formatNumber } from '../../i18n/lang';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { AddItemButton, EditableItem } from '../../admin/Editable'; import { AddItemButton, EditableItem } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
function toFa(n: number) {
return n.toLocaleString('fa-IR');
}
function Counter({ value }: {value: number;}) { function Counter({ value }: {value: number;}) {
const { lang } = useLang();
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: '-60px' }); const inView = useInView(ref, { once: true, margin: '-60px' });
const [display, setDisplay] = useState(0); const [display, setDisplay] = useState(0);
@@ -25,16 +27,15 @@ function Counter({ value }: {value: number;}) {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [inView, value]); }, [inView, value]);
return <span ref={ref}>{formatNumber(display, lang)}</span>; return <span ref={ref}>{toFa(display)}</span>;
} }
export function Stats() { export function Stats() {
const { stats } = useSite(); const { stats } = useSite();
const { t } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
return ( return (
<section className="bg-char py-16" aria-label={t('statsAria')}> <section className="bg-char py-16" aria-label="آمار و ارقام">
<div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10"> <div className="mx-auto grid max-w-site grid-cols-2 gap-y-12 px-6 lg:grid-cols-4 lg:px-10">
{stats.map((stat) => {stats.map((stat) =>
<EditableItem key={stat.id} resource="stats" item={stat}> <EditableItem key={stat.id} resource="stats" item={stat}>
@@ -47,7 +48,7 @@ export function Stats() {
const s = d.stats.find((x) => x.id === stat.id); const s = d.stats.find((x) => x.id === stat.id);
if (s) s.suffix = v; if (s) s.suffix = v;
})} })}
className="ltr:ml-1 rtl:mr-1 text-2xl" className="mr-1 text-2xl"
/> />
</p> </p>
<p className="mt-3 text-sm font-light text-ivory/55"> <p className="mt-3 text-sm font-light text-ivory/55">
+3 -5
View File
@@ -3,7 +3,6 @@ import { ArrowLeftIcon } from 'lucide-react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { ButtonLink } from '../Button' import { ButtonLink } from '../Button'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { useFieldSaver } from '../../admin/AdminContext' import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
@@ -18,7 +17,6 @@ type StoryProps = {
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) { export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
const { story } = useSite() const { story } = useSite()
const { href } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const saveStory = (field: string, value: unknown) => const saveStory = (field: string, value: unknown) =>
@@ -89,9 +87,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
{showLink && ( {showLink && (
<Reveal delay={0.22}> <Reveal delay={0.22}>
<ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light"> <ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> <InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</Reveal> </Reveal>
)} )}
@@ -104,7 +102,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
</div> </div>
<div className="relative grid grid-cols-2 gap-4"> <div className="relative grid grid-cols-2 gap-4">
<SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" /> <SingletonPill resource="story" label="تصاویر و نقل‌قول بخش درباره ما" className="absolute -top-10 right-0" />
{story.bento.map((item, i) => ( {story.bento.map((item, i) => (
<Reveal key={item.src + i} delay={0.06 * i}> <Reveal key={item.src + i} delay={0.06 * i}>
<div className="overflow-hidden"> <div className="overflow-hidden">
+2 -4
View File
@@ -2,18 +2,16 @@ import React from 'react'
import { Reveal } from '../Reveal' import { Reveal } from '../Reveal'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { useSite } from '../../api' import { useSite } from '../../api'
import { useLang } from '../../i18n/lang'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
export function Team() { export function Team() {
const { team } = useSite() const { team } = useSite()
const { t } = useLang()
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="team-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<SectionHeading <SectionHeading
title={t('fallbackTeamTitle')} title="تیم ما"
lead={t('fallbackTeamLead')} lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند."
headingKey="team" headingKey="team"
align="center" align="center"
/> />
+8 -10
View File
@@ -3,12 +3,10 @@ import { AnimatePresence, motion } from 'framer-motion';
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react'; import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
import { SectionHeading } from '../SectionHeading'; import { SectionHeading } from '../SectionHeading';
import { useSite } from '../../api'; import { useSite } from '../../api';
import { useLang } from '../../i18n/lang';
import { EditableItem } from '../../admin/Editable'; import { EditableItem } from '../../admin/Editable';
export function Testimonials() { export function Testimonials() {
const { testimonials } = useSite(); const { testimonials } = useSite();
const { t } = useLang();
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))]; const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
if (!current) return null; if (!current) return null;
@@ -46,20 +44,20 @@ export function Testimonials() {
<button <button
type="button" type="button"
onClick={() => go(1)} onClick={() => go(1)}
aria-label={t('prevTestimonial')} aria-label="نظر قبلی"
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95" className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
> >
<ChevronRightIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:translate-x-0.5 ltr:group-hover:-translate-x-0.5" /> <ChevronRightIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" />
</button> </button>
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}> <div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
{testimonials.map((item, i) => ( {testimonials.map((t, i) => (
<button <button
key={item.id} key={t.id}
type="button" type="button"
role="tab" role="tab"
aria-selected={i === index} aria-selected={i === index}
aria-label={`${t("testimonialByPrefix")} ${item.name}`} aria-label={`نظر ${t.name}`}
onClick={() => setIndex(i)} onClick={() => setIndex(i)}
className={`h-2 rounded-full transition-all duration-300 ease-lux ${ className={`h-2 rounded-full transition-all duration-300 ease-lux ${
i === index i === index
@@ -73,10 +71,10 @@ export function Testimonials() {
<button <button
type="button" type="button"
onClick={() => go(-1)} onClick={() => go(-1)}
aria-label={t('nextTestimonial')} aria-label="نظر بعدی"
className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95" className="group flex h-12 w-12 items-center justify-center rounded-full border border-copper/35 bg-char/80 text-ivory/80 backdrop-blur-md shadow-[0_4px_20px_rgba(0,0,0,0.35)] transition-all duration-300 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_25px_rgba(197,143,92,0.45)] active:scale-95"
> >
<ChevronLeftIcon className="h-5 w-5 ltr:rotate-180 transition-transform duration-300 ease-lux rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" /> <ChevronLeftIcon className="h-5 w-5 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" />
</button> </button>
</div> </div>
</div> </div>
+2 -4
View File
@@ -1,7 +1,6 @@
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { motion, useScroll, useTransform, useInView } from 'framer-motion' import { motion, useScroll, useTransform, useInView } from 'framer-motion'
import { useSite, type TimelineItem } from '../../api' import { useSite, type TimelineItem } from '../../api'
import { useLang } from '../../i18n/lang'
import { SectionHeading } from '../SectionHeading' import { SectionHeading } from '../SectionHeading'
import { AddItemButton, EditableItem } from '../../admin/Editable' import { AddItemButton, EditableItem } from '../../admin/Editable'
@@ -50,7 +49,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
export function Timeline() { export function Timeline() {
const { timeline } = useSite() const { timeline } = useSite()
const { t } = useLang()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({ const { scrollYProgress } = useScroll({
target: ref, target: ref,
@@ -64,8 +62,8 @@ export function Timeline() {
<div className="mb-16"> <div className="mb-16">
<SectionHeading <SectionHeading
title={t('fallbackTimelineTitle')} title="مسیر ما"
lead={t('fallbackTimelineLead')} lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید."
headingKey="timeline" headingKey="timeline"
align="center" align="center"
/> />
+1 -3
View File
@@ -5,11 +5,9 @@ import { useSectionHeading, useSite } from '../../api';
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable'; import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
import { InlineEdit } from '../admin/InlineEdit'; import { InlineEdit } from '../admin/InlineEdit';
import { useFieldSaver } from '../../admin/AdminContext'; import { useFieldSaver } from '../../admin/AdminContext';
import { useLang, formatNumber } from '../../i18n/lang';
export function WhyUs() { export function WhyUs() {
const { why_us } = useSite(); const { why_us } = useSite();
const { lang } = useLang();
const heading = useSectionHeading('why_us'); const heading = useSectionHeading('why_us');
const save = useFieldSaver(); const save = useFieldSaver();
@@ -45,7 +43,7 @@ export function WhyUs() {
<Reveal key={item.id} delay={0.05 * i}> <Reveal key={item.id} delay={0.05 * i}>
<EditableItem resource="why_us" item={item}> <EditableItem resource="why_us" item={item}>
<div className="flex h-full flex-col border-t border-ivory/10 py-8"> <div className="flex h-full flex-col border-t border-ivory/10 py-8">
<span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span> <span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
<h3 className="mt-4 text-lg font-bold text-ivory"> <h3 className="mt-4 text-lg font-bold text-ivory">
<InlineEdit <InlineEdit
value={item.title} value={item.title}
+3
View File
@@ -5,6 +5,9 @@
/** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */ /** عنوان پیش‌فرض تصویر، وقتی کاربر عنوانی وارد نکرده باشد */
export const DEFAULT_IMAGE_TITLE = 'Photo 1' export const DEFAULT_IMAGE_TITLE = 'Photo 1'
/** متن جایگزین پیش‌فرض تصویرهای داخل متن (وقتی نام فایل چیزی به دست نمی‌دهد) */
export const DEFAULT_IMAGE_ALT = 'تصویر مقاله'
/** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */ /** پسوند پیشنهاد Alt برای تصویر شاخص — در کنار عنوان رکورد قرار می‌گیرد */
export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص' export const FEATURED_IMAGE_ALT_SUFFIX = 'تصویر شاخص'
+1 -16
View File
@@ -1,12 +1,9 @@
// انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود. // انواع مشترک داده‌ها — همه محتوای متنی سایت از بک‌اند (GET /api/site) بارگذاری می‌شود.
// فیلدهای *_en معادل انگلیسی‌اند (اختیاری — خالی یعنی بدون ترجمه و برگشت به فارسی).
export type Service = { export type Service = {
id: number; id: number;
title: string; title: string;
title_en?: string;
desc: string; desc: string;
desc_en?: string;
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape'; icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
}; };
@@ -16,26 +13,17 @@ export type Project = {
id: number; id: number;
slug: string; slug: string;
title: string; title: string;
title_en?: string;
location: string; location: string;
location_en?: string;
year: string; year: string;
year_en?: string;
status: ProjectStatus; status: ProjectStatus;
image: string; image: string;
gallery: string[]; gallery: string[];
area: string; area: string;
area_en?: string;
floors: string; floors: string;
floors_en?: string;
units: string; units: string;
units_en?: string;
client: string; client: string;
client_en?: string;
summary: string; summary: string;
summary_en?: string;
body: string[]; body: string[];
body_en?: string[] | null;
}; };
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */ /** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
@@ -45,12 +33,9 @@ export type ArticleStatus = 'draft' | 'published';
export type Article = { export type Article = {
id: number; id: number;
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string; slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
image: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
/** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */ /** سند TipTap — ردیف‌های قدیمی ممکن است هنوز لیست پاراگراف باشند */
body: ArticleBodyDoc | string[]; body: ArticleBodyDoc | string[];
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
body_en?: ArticleBodyDoc | null;
/** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */ /** پیش‌نویس فقط برای ادمین (از انتهای مدیریتی) می‌آید؛ بازدیدکننده نمی‌بیند */
status: ArticleStatus; status: ArticleStatus;
/** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */
-212
View File
@@ -1,212 +0,0 @@
// دیکشنری دوزبانه رشته‌های هاردکد رابط کاربری (بدون محتوای دیتابیس).
// کلید فارسی مرجع است؛ متن‌های دیتابیس (سئو، پروژه‌ها، مقاله‌ها و...) از localize.ts می‌آیند.
export const fa = {
// عمومی
brandFallback: 'گروه ساختمانی آرامش',
loading: 'در حال بارگذاری',
errorTitle: 'اتصال به سرور برقرار نشد',
errorText: 'محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.',
retry: 'تلاش دوباره',
backToTop: 'بازگشت به بالا',
switchLang: 'English',
switchLangAria: 'تغییر زبان به انگلیسی',
understood: 'متوجه شدم',
// هدر و ناوبری
mainNav: 'ناوبری اصلی',
mobileNav: 'ناوبری موبایل',
openMenu: 'باز کردن منو',
closeMenu: 'بستن منو',
callBrand: 'تماس تلفنی با گروه ساختمانی آرامش',
// فرم تماس — پیام‌های اعتبارسنجی و خطا
incompleteInfo: 'اطلاعات ناقص است',
nameRequired: 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).',
phoneInvalidTitle: 'شماره تلفن نامعتبر است',
phoneInvalidText: 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.',
submitErrorTitle: 'خطا در ثبت درخواست',
formCheck: 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.',
connectionErrorTitle: 'خطا در اتصال به سرور',
connectionErrorText: 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.',
// خبرنامه
emailRequired: 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.',
emailInvalidTitle: 'فرمت ایمیل نامعتبر است',
emailInvalidText: 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).',
subscribeErrorTitle: 'خطا در عضویت',
emailCheck: 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.',
// تیتر بخش‌ها — فقط جایگزین وقتی دیتابیس خالی است
fallbackGalleryTitle: 'گالری تصاویر',
fallbackGalleryLead: 'قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش.',
fallbackTeamTitle: 'تیم ما',
fallbackTeamLead: 'کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند.',
fallbackTimelineTitle: 'مسیر ما',
fallbackTimelineLead: 'از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید.',
fallbackFaqTitle: 'سوالات متداول',
fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.',
// کارت پروژه
yearLabel: 'سال:',
sqm: 'متر مربع',
// جزئیات پروژه
projectNotFound: 'این پروژه پیدا نشد',
projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.',
backToProjects: 'بازگشت به پروژه‌ها',
specLocation: 'موقعیت',
specYear: 'سال',
specArea: 'زیربنا',
specFloors: 'تعداد طبقات',
specUnits: 'واحدها',
specClient: 'کارفرما',
breadcrumbAria: 'مسیر صفحه',
home: 'صفحه اصلی',
projects: 'پروژه‌ها',
projectDetailsAria: 'جزئیات پروژه',
aboutProject: 'درباره پروژه',
similarProjectCta: 'پروژه‌ای مشابه دارید؟',
relatedProjectsAria: 'پروژه‌های مرتبط',
otherProjects: 'پروژه‌های دیگر',
// مقاله و وبلاگ
articleNotFound: 'این مقاله پیدا نشد',
articleNotFoundText: 'فهرست کامل مقالات وبلاگ را ببینید.',
backToBlog: 'بازگشت به وبلاگ',
blog: 'وبلاگ',
relatedArticlesAria: 'مقالات مرتبط',
relatedArticles: 'مقالات مرتبط',
readArticlePrefix: 'خواندن مقاله',
articleImagePrefix: 'تصویر مقاله',
featuredArticle: 'مقاله ویژه',
readMore: 'ادامه مطلب',
draft: 'پیش‌نویس',
writeNewArticle: 'نوشتن مقاله جدید',
blogEmpty: 'هنوز مقاله‌ای منتشر نشده است',
blogEmptyText: 'به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.',
articlesAria: 'مقالات',
// برچسب‌های دسترس‌پذیری بخش‌ها
statsAria: 'آمار و ارقام',
projectsListAria: 'فهرست پروژه‌ها',
projectFilterAria: 'فیلتر وضعیت پروژه',
officesAria: 'دفاتر ما',
heroAria: 'معرفی گروه ساختمانی آرامش',
heroImageAlt: 'نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش',
heroTitleAria: 'تیتر اصلی صفحه',
scrollDown: 'رفتن به بخش بعدی',
compareAria: 'مقایسه کانسپت و پروژه ساخته‌شده',
zoomImagePrefix: 'بزرگ‌نمایی تصویر شماره',
viewImage: 'نمایش تصویر',
close: 'بستن',
prevImage: 'تصویر قبلی',
nextImage: 'تصویر بعدی',
prevTestimonial: 'نظر قبلی',
nextTestimonial: 'نظر بعدی',
selectTestimonialAria: 'انتخاب نظر',
testimonialByPrefix: 'نظر',
}
export type Dict = typeof fa
export const en: Dict = {
brandFallback: 'Aramesh Construction Group',
loading: 'Loading',
errorTitle: 'Could not reach the server',
errorText: 'Site content is loaded from the server; please check your internet connection or the server status.',
retry: 'Try again',
backToTop: 'Back to top',
switchLang: 'فا',
switchLangAria: 'Switch language to Persian (Farsi)',
understood: 'Got it',
mainNav: 'Main navigation',
mobileNav: 'Mobile navigation',
openMenu: 'Open menu',
closeMenu: 'Close menu',
callBrand: 'Call Aramesh Construction Group',
incompleteInfo: 'Incomplete information',
nameRequired: 'Please enter your full name (at least 3 characters).',
phoneInvalidTitle: 'Invalid phone number',
phoneInvalidText: 'Please enter a valid phone number so our experts can get in touch with you.',
submitErrorTitle: 'Error submitting the request',
formCheck: 'Please check the form and try again.',
connectionErrorTitle: 'Server connection error',
connectionErrorText: 'Could not reach the server. Please try again later.',
emailRequired: 'Please enter your email address to subscribe to the newsletter.',
emailInvalidTitle: 'Invalid email format',
emailInvalidText: 'Please enter your email correctly (e.g. example@domain.com).',
subscribeErrorTitle: 'Subscription error',
emailCheck: 'Please check your email and try again.',
fallbackGalleryTitle: 'Gallery',
fallbackGalleryLead: 'Frames from the construction sites and delivered projects of Aramesh Construction Group.',
fallbackTeamTitle: 'Our Team',
fallbackTeamLead: 'The people who make each project’s key decisions — and answer for them.',
fallbackTimelineTitle: 'Our Path',
fallbackTimelineLead: 'From a four-person office to 47 projects. Walk through the timeline of our work.',
fallbackFaqTitle: 'Frequently Asked Questions',
fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.',
yearLabel: 'Year:',
sqm: 'm²',
projectNotFound: 'Project not found',
projectNotFoundText: 'The address may have changed. Browse the full list of projects.',
backToProjects: 'Back to projects',
specLocation: 'Location',
specYear: 'Year',
specArea: 'Built area',
specFloors: 'Floors',
specUnits: 'Units',
specClient: 'Client',
breadcrumbAria: 'Breadcrumb',
home: 'Home',
projects: 'Projects',
projectDetailsAria: 'Project details',
aboutProject: 'About the project',
similarProjectCta: 'Have a similar project in mind?',
relatedProjectsAria: 'Related projects',
otherProjects: 'More projects',
articleNotFound: 'Article not found',
articleNotFoundText: 'Browse the full list of blog articles.',
backToBlog: 'Back to blog',
blog: 'Blog',
relatedArticlesAria: 'Related articles',
relatedArticles: 'Related articles',
readArticlePrefix: 'Read article',
articleImagePrefix: 'Image of article',
featuredArticle: 'Featured article',
readMore: 'Read more',
draft: 'Draft',
writeNewArticle: 'Write a new article',
blogEmpty: 'No articles published yet',
blogEmptyText: 'Our first articles will be published here soon.',
articlesAria: 'Articles',
statsAria: 'Stats and figures',
projectsListAria: 'Project list',
projectFilterAria: 'Filter projects by status',
officesAria: 'Our offices',
heroAria: 'Introduction of Aramesh Construction Group',
heroImageAlt: 'Night view of a residential tower built by Aramesh Construction Group',
heroTitleAria: 'Main headline',
scrollDown: 'Go to the next section',
compareAria: 'Compare the concept with the built project',
zoomImagePrefix: 'Zoom image No.',
viewImage: 'View image',
close: 'Close',
prevImage: 'Previous image',
nextImage: 'Next image',
prevTestimonial: 'Previous testimonial',
nextTestimonial: 'Next testimonial',
selectTestimonialAria: 'Select testimonial',
testimonialByPrefix: 'Testimonial by',
}
export const dict = { fa, en }
export type DictKey = keyof Dict
-100
View File
@@ -1,100 +0,0 @@
import { createContext, useContext, useEffect, useMemo } from 'react'
import type { ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { dict } from './dict'
import type { DictKey } from './dict'
export type Lang = 'fa' | 'en'
// زبان سایت از خود URL می‌آید: /en/... انگلیسی و بقیه فارسی — هر زبان یک نشانی واقعی و قابل ایندکس است.
const LANG_COOKIE = 'lang'
/** تشخیص زبان از مسیر — /en و هر زیرمسیر آن انگلیسی است */
export function langFromPathname(pathname: string): Lang {
return pathname === '/en' || pathname.startsWith('/en/') ? 'en' : 'fa'
}
/** مسیر معادل در زبان دیگر — برای دکمه تغییر زبان */
export function counterpartPath(pathname: string, lang: Lang): string {
if (lang === 'fa') return pathname === '/' ? '/en' : `/en${pathname}`
return pathname === '/en' ? '/' : pathname.slice(3)
}
/** تبدیل مسیر داخلی دیتابیس (مثل /about) به مسیر زبان جاری — لینک‌های خارجی و tel/mailto دست‌نخورده می‌مانند */
export function localeHref(href: string, lang: Lang): string {
if (!href.startsWith('/')) return href
if (lang === 'fa') return href
return href === '/' ? '/en' : `/en${href}`
}
export function readLangCookie(): Lang | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${LANG_COOKIE}=(fa|en)`))
return (match?.[1] as Lang) ?? null
}
export function writeLangCookie(lang: Lang): void {
document.cookie = `${LANG_COOKIE}=${lang}; path=/; max-age=31536000; samesite=lax`
}
type LangContextValue = {
lang: Lang
dir: 'rtl' | 'ltr'
/** متن‌های هاردکد رابط کاربری به زبان جاری */
t: (key: DictKey) => string
/** مسیر داخلی دیتابیس با پیشوند زبان جاری */
href: (path: string) => string
}
const LangContext = createContext<LangContextValue>({
lang: 'fa',
dir: 'rtl',
t: (key) => dict.fa[key],
href: (path) => path,
})
export function LangProvider({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
const navigate = useNavigate()
const lang = langFromPathname(pathname)
const dir = lang === 'fa' ? 'rtl' : 'ltr'
// زبان و جهت سند — واریانت‌های rtl:/ltr: تیلویند و دسترس‌پذیری به آن وابسته‌اند
useEffect(() => {
document.documentElement.setAttribute('lang', lang)
document.documentElement.setAttribute('dir', dir)
}, [lang, dir])
// انتخاب زبان در کوکی — برای بازدید بعدی؛ صفحه‌های مدیریتی زبان بازدیدکننده را عوض نمی‌کنند
useEffect(() => {
if (!pathname.startsWith('/admin')) writeLangCookie(lang)
}, [lang, pathname])
// بازدیدکننده‌ای که قبلاً انگلیسی انتخاب کرده، هنگام فرود روی / یک بار به /en می‌رود
useEffect(() => {
if (pathname !== '/' || readLangCookie() !== 'en') return
if (sessionStorage.getItem('langAutoRedirect') === '1') return
sessionStorage.setItem('langAutoRedirect', '1')
navigate('/en', { replace: true })
}, [pathname, navigate])
const value = useMemo<LangContextValue>(
() => ({
lang,
dir,
t: (key) => dict[lang][key],
href: (path) => localeHref(path, lang),
}),
[lang, dir],
)
return <LangContext.Provider value={value}>{children}</LangContext.Provider>
}
export function useLang(): LangContextValue {
return useContext(LangContext)
}
/** عدد با رقم‌های زبان جاری — فارسی fa-IR و انگلیسی لاتین */
export function formatNumber(value: number, lang: Lang): string {
return value.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')
}
-209
View File
@@ -1,209 +0,0 @@
// بومی‌سازی درخت داده سایت — انتخاب فیلدهای انگلیسی با برگشت به فارسی.
// فرانت‌اند هر دو زبان را در یک درخواست از GET /api/site می‌گیرد و سوییچ زبان
// بی‌درنگ و بدون درخواست تازه انجام می‌شود. داده خام برای پنل مدیریت دست‌نخورده می‌ماند.
import type { Article, ArticleBodyDoc } from '../data/site'
import type { SiteData } from '../api'
import type { Lang } from './lang'
/** متن انگلیسی فقط وقتی جایگزین می‌شود که واقعاً چیزی نوشته شده باشد */
const s = (fa: string, en?: string | null): string => (en && en.trim() ? en : fa)
/** لیست انگلیسی فقط وقتی جایگزین می‌شود که غیرخالی باشد */
const list = <T,>(fa: T[], en?: T[] | null): T[] => (en && en.length ? en : fa)
/** سند TipTap انگلیسی فقط وقتی متن واقعی دارد جایگزین بدنه فارسی می‌شود */
function docHasText(doc: unknown): boolean {
if (!doc || typeof doc !== 'object') return false
const content = (doc as ArticleBodyDoc).content
if (!Array.isArray(content)) return false
let found = false
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object' || found) return
const n = node as { type?: string; text?: string; content?: unknown[] }
if (n.type === 'text' && typeof n.text === 'string' && n.text.trim()) found = true
n.content?.forEach(walk)
}
content.forEach(walk)
return found
}
export function localizeSite(data: SiteData, lang: Lang): SiteData {
if (lang === 'fa') return data
return {
brand: {
...data.brand,
name: s(data.brand.name, data.brand.name_en),
address: s(data.brand.address, data.brand.address_en),
},
nav: data.nav.map((n) => ({ ...n, label: s(n.label, n.label_en) })),
hero: {
...data.hero,
kicker: s(data.hero.kicker, data.hero.kicker_en),
title: s(data.hero.title, data.hero.title_en),
title_highlight: s(data.hero.title_highlight, data.hero.title_highlight_en),
subtitle: s(data.hero.subtitle, data.hero.subtitle_en),
badge_value: s(data.hero.badge_value, data.hero.badge_value_en),
badge_label: s(data.hero.badge_label, data.hero.badge_label_en),
cta_primary_label: s(data.hero.cta_primary_label, data.hero.cta_primary_label_en),
cta_secondary_label: s(data.hero.cta_secondary_label, data.hero.cta_secondary_label_en),
scroll_label: s(data.hero.scroll_label, data.hero.scroll_label_en),
},
story: {
...data.story,
title: s(data.story.title, data.story.title_en),
paragraphs: list(data.story.paragraphs, data.story.paragraphs_en),
quote: s(data.story.quote, data.story.quote_en),
quote_author: s(data.story.quote_author, data.story.quote_author_en),
stat1_value: s(data.story.stat1_value, data.story.stat1_value_en),
stat1_label: s(data.story.stat1_label, data.story.stat1_label_en),
stat2_value: s(data.story.stat2_value, data.story.stat2_value_en),
stat2_label: s(data.story.stat2_label, data.story.stat2_label_en),
link_label: s(data.story.link_label, data.story.link_label_en),
bento: list(data.story.bento, data.story.bento_en),
},
section_headings: data.section_headings.map((h) => ({
...h,
label: s(h.label, h.label_en),
title: s(h.title, h.title_en),
lead: s(h.lead, h.lead_en),
cta_label: s(h.cta_label, h.cta_label_en),
})),
page_heroes: data.page_heroes.map((p) => ({
...p,
title: s(p.title, p.title_en),
lead: s(p.lead, p.lead_en),
})),
services: data.services.map((sv) => ({
...sv,
title: s(sv.title, sv.title_en),
desc: s(sv.desc, sv.desc_en),
})),
projects: data.projects.map((p) => ({
...p,
title: s(p.title, p.title_en),
location: s(p.location, p.location_en),
year: s(p.year, p.year_en),
area: s(p.area, p.area_en),
floors: s(p.floors, p.floors_en),
units: s(p.units, p.units_en),
client: s(p.client, p.client_en),
summary: s(p.summary, p.summary_en),
body: list(p.body, p.body_en),
})),
articles: data.articles.map((a: Article) => ({
...a,
title: s(a.title, a.title_en),
date: s(a.date, a.date_en),
category: s(a.category, a.category_en),
read: s(a.read, a.read_en),
excerpt: s(a.excerpt, a.excerpt_en),
body: docHasText(a.body_en) ? a.body_en! : a.body,
})),
testimonials: data.testimonials.map((tm) => ({
...tm,
name: s(tm.name, tm.name_en),
role: s(tm.role, tm.role_en),
quote: s(tm.quote, tm.quote_en),
})),
faqs: data.faqs.map((f) => ({
...f,
question: s(f.question, f.question_en),
answer: s(f.answer, f.answer_en),
})),
team: data.team.map((m) => ({
...m,
name: s(m.name, m.name_en),
role: s(m.role, m.role_en),
bio: s(m.bio, m.bio_en),
})),
timeline: data.timeline.map((tm) => ({
...tm,
year: s(tm.year, tm.year_en),
title: s(tm.title, tm.title_en),
desc: s(tm.desc, tm.desc_en),
})),
stats: data.stats.map((st) => ({
...st,
suffix: s(st.suffix, st.suffix_en),
label: s(st.label, st.label_en),
})),
why_us: data.why_us.map((w) => ({
...w,
title: s(w.title, w.title_en),
desc: s(w.desc, w.desc_en),
})),
before_after: {
...data.before_after,
title: s(data.before_after.title, data.before_after.title_en),
lead: s(data.before_after.lead, data.before_after.lead_en),
before_label: s(data.before_after.before_label, data.before_after.before_label_en),
after_label: s(data.before_after.after_label, data.before_after.after_label_en),
},
investment_models: data.investment_models.map((im) => ({
...im,
title: s(im.title, im.title_en),
desc: s(im.desc, im.desc_en),
points: list(im.points, im.points_en),
})),
process_steps: data.process_steps.map((ps) => ({
...ps,
step: s(ps.step, ps.step_en),
title: s(ps.title, ps.title_en),
desc: s(ps.desc, ps.desc_en),
})),
offices: data.offices.map((o) => ({
...o,
city: s(o.city, o.city_en),
address: s(o.address, o.address_en),
hours: s(o.hours, o.hours_en),
})),
gallery: data.gallery.map((g) => ({ ...g, alt: s(g.alt, g.alt_en) })),
status_labels: data.status_labels.map((sl) => ({ ...sl, label: s(sl.label, sl.label_en) })),
contact_section: {
...data.contact_section,
label: s(data.contact_section.label, data.contact_section.label_en),
title: s(data.contact_section.title, data.contact_section.title_en),
lead: s(data.contact_section.lead, data.contact_section.lead_en),
address_label: s(data.contact_section.address_label, data.contact_section.address_label_en),
phone_label: s(data.contact_section.phone_label, data.contact_section.phone_label_en),
email_label: s(data.contact_section.email_label, data.contact_section.email_label_en),
name_label: s(data.contact_section.name_label, data.contact_section.name_label_en),
name_placeholder: s(data.contact_section.name_placeholder, data.contact_section.name_placeholder_en),
phone_field_label: s(data.contact_section.phone_field_label, data.contact_section.phone_field_label_en),
phone_placeholder: s(data.contact_section.phone_placeholder, data.contact_section.phone_placeholder_en),
note_label: s(data.contact_section.note_label, data.contact_section.note_label_en),
note_optional_label: s(data.contact_section.note_optional_label, data.contact_section.note_optional_label_en),
note_placeholder: s(data.contact_section.note_placeholder, data.contact_section.note_placeholder_en),
submit_label: s(data.contact_section.submit_label, data.contact_section.submit_label_en),
sending_label: s(data.contact_section.sending_label, data.contact_section.sending_label_en),
success_title: s(data.contact_section.success_title, data.contact_section.success_title_en),
success_text: s(data.contact_section.success_text, data.contact_section.success_text_en),
success_button_label: s(data.contact_section.success_button_label, data.contact_section.success_button_label_en),
},
newsletter: {
...data.newsletter,
title: s(data.newsletter.title, data.newsletter.title_en),
lead: s(data.newsletter.lead, data.newsletter.lead_en),
placeholder: s(data.newsletter.placeholder, data.newsletter.placeholder_en),
button_label: s(data.newsletter.button_label, data.newsletter.button_label_en),
sending_label: s(data.newsletter.sending_label, data.newsletter.sending_label_en),
success_text: s(data.newsletter.success_text, data.newsletter.success_text_en),
},
footer: {
...data.footer,
about_text: s(data.footer.about_text, data.footer.about_text_en),
pages_heading: s(data.footer.pages_heading, data.footer.pages_heading_en),
services_heading: s(data.footer.services_heading, data.footer.services_heading_en),
contact_heading: s(data.footer.contact_heading, data.footer.contact_heading_en),
copyright: s(data.footer.copyright, data.footer.copyright_en),
tagline: s(data.footer.tagline, data.footer.tagline_en),
socials: list(data.footer.socials, data.footer.socials_en),
},
seo_meta: data.seo_meta.map((m) => ({
...m,
title: s(m.title, m.title_en),
description: s(m.description, m.description_en),
})),
}
}
+34 -23
View File
@@ -7,9 +7,6 @@
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */ /* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
/* فونت سایت سلف‌هاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
Pinar فونت اصلی است (۵ وزن) و Vazirmatn به‌عنوان جایگزین پشتیبان می‌ماند.
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده می‌کند (Inter در صورت نصب بودن). */
@font-face { @font-face {
font-family: 'Pinar'; font-family: 'Pinar';
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'), src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
@@ -72,21 +69,6 @@ body {
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif; font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* نسخه انگلیسی — فونت لاتین و تنوع‌سنجی (tracking) مناسب خط لاتین */
html[lang='en'] body {
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
letter-spacing: -0.01em;
}
html[lang='en'] .article-body {
line-height: 1.8;
}
html[lang='en'] .article-body p {
margin-top: 1.25rem;
}
html[lang='en'] .article-body h2,
html[lang='en'] .article-body h3 {
line-height: 1.4;
}
::selection { ::selection {
background-color: #d5874c; background-color: #d5874c;
color: #1e1a16; color: #1e1a16;
@@ -130,8 +112,6 @@ html[lang='en'] .article-body h3 {
} }
.article-body h2 { .article-body h2 {
font-size: 16px; font-size: 16px;
padding-inline-start: 0.85rem;
border-inline-start: 3px solid #d5874c;
} }
.article-body h3 { .article-body h3 {
font-size: 14px; font-size: 14px;
@@ -154,6 +134,37 @@ html[lang='en'] .article-body h3 {
[data-dir='ltr'] .article-body h3 { [data-dir='ltr'] .article-body h3 {
font-size: 12px; font-size: 12px;
} }
/* ---------- تایپوگرافی دوزبانه (فارسی و لاتین در یک خط) ----------
متن لاتین همیشه ۲ پیکسل کوچک‌تر از متن فارسیِ همان بلوک است:
تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲.
calc(1em - 2px) نسبت به اندازه همان بلوک حساب می‌شود، پس قاعده در همه سطح‌ها درست
می‌ماند و خط پایه هم جابه‌جا نمی‌شود (line-height نسبی است).
در مقاله انگلیسی (data-dir=ltr) پایه همان اندازه کوچک‌تر است و واژه‌های فارسی
۲ پیکسل بزرگ‌تر می‌شوند تا تفاضل همیشه ۲ پیکسل بماند. */
.article-body .latin,
.article-body .persian,
.article-body .english-text {
unicode-bidi: isolate;
}
.article-body .latin,
.article-body .english-text {
font-size: calc(1em - 2px);
}
.article-body .persian {
font-size: 1em;
}
[data-dir='ltr'] .article-body .latin,
[data-dir='ltr'] .article-body .english-text {
font-size: 1em;
}
[data-dir='ltr'] .article-body .persian {
font-size: calc(1em + 2px);
}
.article-body span[data-small="true"] { .article-body span[data-small="true"] {
font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */ font-size: 12px; /* مارک اختصاصی برای سایز ۱۲ - انگلیسی */
} }
@@ -161,12 +172,12 @@ html[lang='en'] .article-body h3 {
.english-text { .english-text {
direction: ltr; direction: ltr;
unicode-bidi: isolate; unicode-bidi: isolate;
font-size: 12px; /* اندازه از قواعد دوزبانه بالا می‌آید: ۲ پیکسل کوچک‌تر از متن فارسی همان بلوک */
} }
.article-body ul, .article-body ul,
.article-body ol { .article-body ol {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-inline-start: 1.5rem; padding-right: 1.5rem;
} }
.article-body ul { .article-body ul {
list-style: disc; list-style: disc;
@@ -184,7 +195,7 @@ html[lang='en'] .article-body h3 {
} }
.article-body blockquote { .article-body blockquote {
margin-top: 2rem; margin-top: 2rem;
border-inline-start: 3px solid #d5874c; border-right: 3px solid #d5874c;
background: rgba(30, 26, 22, 0.5); background: rgba(30, 26, 22, 0.5);
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
font-style: normal; font-style: normal;
+55 -34
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ArrowLeftIcon, ChevronLeftIcon } from 'lucide-react'; import { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { renderToHTMLString } from '@tiptap/static-renderer'; import { renderToHTMLString } from '@tiptap/static-renderer';
import { Reveal } from '../components/Reveal'; import { Reveal } from '../components/Reveal';
import { ButtonLink } from '../components/Button'; import { ButtonLink } from '../components/Button';
@@ -9,21 +9,26 @@ import { ArticleMeta } from '../components/ArticleMeta';
import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents'; import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents';
import { Newsletter } from '../components/home/Newsletter'; import { Newsletter } from '../components/home/Newsletter';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor';
import { docToText } from '../utils/tiptapDoc';
import { detectDirection } from '../utils/direction';
import { wrapScriptRuns } from '../utils/bidiText';
import type { Article } from '../data/site'; import type { Article } from '../data/site';
/** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ /** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */
function renderBody(article: Article): { html: string | null; toc: TocItem[]; legacy: string[] } { function renderBody(
article: Article,
dir: 'rtl' | 'ltr',
): { html: string | null; toc: TocItem[]; legacy: string[] } {
const body = article.body as unknown; const body = article.body as unknown;
if (Array.isArray(body)) return { html: null, toc: [], legacy: body }; if (Array.isArray(body)) return { html: null, toc: [], legacy: body };
if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') { if (body && typeof body === 'object' && (body as { type?: string }).type === 'doc') {
try { try {
const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never }); const html = renderToHTMLString({ extensions: ARTICLE_EXTENSIONS, content: body as never });
// تیترهای H2/H3 شناسه لنگر می‌گیرند تا فهرست مطالب به آن‌ها بپرد // تیترها شناسه لنگر می‌گیرند و متن لاتین/فارسی برای قاعده «۲ پیکسل کوچک‌تر» بسته‌بندی می‌شود
const { items, html: htmlWithIds } = extractToc(html); const { items, html: htmlWithIds } = extractToc(html, (root) => wrapScriptRuns(root, dir));
return { html: htmlWithIds, toc: items, legacy: [] }; return { html: htmlWithIds, toc: items, legacy: [] };
} catch { } catch {
return { html: null, toc: [], legacy: [] }; return { html: null, toc: [], legacy: [] };
@@ -35,7 +40,6 @@ function renderBody(article: Article): { html: string | null; toc: TocItem[]; le
export function ArticleDetail() { export function ArticleDetail() {
const { slug } = useParams<{slug: string;}>(); const { slug } = useParams<{slug: string;}>();
const { articles, brand } = useSite(); const { articles, brand } = useSite();
const { t, href, lang } = useLang();
const article = articles.find((a) => a.slug === slug); const article = articles.find((a) => a.slug === slug);
// عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله
@@ -45,43 +49,53 @@ export function ArticleDetail() {
title: `${article.title} | ${brand.name}`, title: `${article.title} | ${brand.name}`,
// توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده
description: article.meta_description || article.excerpt, description: article.meta_description || article.excerpt,
path: href(`/blog/${article.slug}`), path: `/blog/${article.slug}`,
image: article.image, image: article.image,
type: 'article', type: 'article',
lang,
jsonLd: { jsonLd: {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Article', '@type': 'Article',
headline: article.title, headline: article.title,
description: article.meta_description || article.excerpt, description: article.meta_description || article.excerpt,
image: article.image ? new URL(article.image, window.location.origin).href : undefined, image: article.image ? new URL(article.image, window.location.origin).href : undefined,
inLanguage: lang, inLanguage: 'fa',
author: { '@type': 'Organization', name: article.author?.trim() || brand.name }, author: { '@type': 'Organization', name: article.author?.trim() || brand },
publisher: { '@type': 'Organization', name: brand }, publisher: { '@type': 'Organization', name: brand },
mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href, mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href,
}, },
}); });
}, [article?.id, lang, href]); }, [article?.id]);
if (!article) { if (!article) {
return ( return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1> <h1 className="text-section font-bold text-ivory">این مقاله پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p> <p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p>
<ButtonLink to={href('/blog')} variant="copper" className="mt-8"> <ButtonLink to="/blog" variant="copper" className="mt-8">
{t('backToBlog')} بازگشت به وبلاگ
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</section>); </section>);
} }
const { html, toc, legacy } = renderBody(article); const dir = detectDirection(article.title, article.excerpt, docToText(article.body));
const { html, toc, legacy } = renderBody(article, dir);
const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3); const related = articles.filter((a) => a.slug !== article.slug && a.status !== 'draft').slice(0, 3);
/**
* جهت غالب متن مقاله — بدنه (با data-dir، طبق قاعده CSS مقاله) و سمت فهرست مطالب
* از همین پیروی می‌کنند: فارسی راست‌به‌چپ (فهرست سمت راست)، انگلیسی چپ‌به‌راست (فهرست سمت چپ).
*/
const hasToc = toc.length > 0;
// در چیدمان راست‌به‌چپ ستون شماره ۱ سمت راست است؛ پس ستون‌ها صریح شماره می‌خورند
const gridColumns = hasToc ? 'lg:grid-cols-[17rem_minmax(0,1fr)]' : '';
const bodyColumns = 'lg:col-start-2';
const tocColumns = 'lg:col-start-1';
return ( return (
<> <>
<section className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden"> <section dir={dir} className="relative flex h-[56vh] min-h-[400px] items-end overflow-hidden">
<img <img
src={article.image} src={article.image}
alt={article.image_alt || article.title} alt={article.image_alt || article.title}
@@ -95,15 +109,15 @@ export function ArticleDetail() {
aria-hidden="true" /> aria-hidden="true" />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50"> <nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper"> <Link to="/" className="transition-colors hover:text-copper">
{t('home')} صفحه اصلی
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> {dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
<Link to={href('/blog')} className="transition-colors hover:text-copper"> <Link to="/blog" className="transition-colors hover:text-copper">
{t('blog')} وبلاگ
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> {dir === 'ltr' ? <ChevronRightIcon className="h-3.5 w-3.5" /> : <ChevronLeftIcon className="h-3.5 w-3.5" />}
<span className="text-copper">{article.category}</span> <span className="text-copper">{article.category}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1>
@@ -115,10 +129,14 @@ export function ArticleDetail() {
<article className="bg-ink py-20 lg:py-28"> <article className="bg-ink py-20 lg:py-28">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14"> <div
<div className="min-w-0"> dir={dir}
data-dir={dir}
className={`mx-auto grid max-w-5xl gap-12 lg:gap-14 ${gridColumns}`}
>
<div className={`min-w-0 lg:row-start-1 ${hasToc ? bodyColumns : ''}`}>
<Reveal> <Reveal>
<p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80"> <p className="border-s-2 border-copper ps-6 text-lg font-light leading-10 text-ivory/80">
{article.excerpt} {article.excerpt}
</p> </p>
</Reveal> </Reveal>
@@ -137,9 +155,12 @@ export function ArticleDetail() {
)} )}
</div> </div>
{/* فهرست مطالب — در دسکتاپ کنار متن چسبان می‌ماند */} {/* فهرست مطالب — در دسکتاپ کنار متن می‌چسبد و همراه مقاله پایین می‌آید */}
{toc.length > 0 && ( {hasToc && (
<aside className="order-first lg:order-none"> <aside
dir={dir}
className={`order-first lg:order-none lg:row-start-1 ${tocColumns}`}
>
<TableOfContents items={toc} className="lg:sticky lg:top-28" /> <TableOfContents items={toc} className="lg:sticky lg:top-28" />
</aside> </aside>
)} )}
@@ -147,9 +168,9 @@ export function ArticleDetail() {
</div> </div>
</article> </article>
<section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}> <section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2> <h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => {related.map((item, i) =>
+14 -17
View File
@@ -6,7 +6,6 @@ import { Reveal } from '../components/Reveal'
import { ArticleCard } from '../components/ArticleCard' import { ArticleCard } from '../components/ArticleCard'
import { Newsletter } from '../components/home/Newsletter' import { Newsletter } from '../components/home/Newsletter'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { EditableItem } from '../admin/Editable' import { EditableItem } from '../admin/Editable'
import { useAdmin } from '../admin/AdminContext' import { useAdmin } from '../admin/AdminContext'
import { formatJalali } from '../utils/date' import { formatJalali } from '../utils/date'
@@ -14,19 +13,17 @@ import { toPersianDigits } from '../utils/digits'
import { PlusIcon } from 'lucide-react' import { PlusIcon } from 'lucide-react'
function DraftBadge({ className = '' }: { className?: string }) { function DraftBadge({ className = '' }: { className?: string }) {
const { t } = useLang()
return ( return (
<span <span
className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`} className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`}
> >
{t('draft')} پیش‌نویس
</span> </span>
) )
} }
export function Blog() { export function Blog() {
const { articles } = useSite() const { articles } = useSite()
const { t, href } = useLang()
const { isEditMode } = useAdmin() const { isEditMode } = useAdmin()
// دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود // دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود
@@ -37,7 +34,7 @@ export function Blog() {
className="inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105" className="inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
{t('writeNewArticle')} نوشتن مقاله جدید
</Link> </Link>
) : null ) : null
@@ -46,11 +43,11 @@ export function Blog() {
return ( return (
<> <>
<PageHero pageKey="blog" /> <PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}> <section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 text-center lg:px-10"> <div className="mx-auto max-w-site px-6 text-center lg:px-10">
<h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2> <h2 className="text-3xl font-bold text-ivory">هنوز مقاله‌ای منتشر نشده است</h2>
<p className="mt-4 text-sm font-light text-ivory/60"> <p className="mt-4 text-sm font-light text-ivory/60">
{t('blogEmptyText')} به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.
</p> </p>
<div className="mt-8 flex justify-center">{writeButton}</div> <div className="mt-8 flex justify-center">{writeButton}</div>
</div> </div>
@@ -66,28 +63,28 @@ export function Blog() {
<> <>
<PageHero pageKey="blog" /> <PageHero pageKey="blog" />
<section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}> <section className="bg-ink py-24 lg:py-32" aria-label="مقالات">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<div className="mb-10 flex justify-start">{writeButton}</div> <div className="mb-10 flex justify-start">{writeButton}</div>
<Reveal> <Reveal>
<EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}> <EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}>
<Link <Link
to={href(`/blog/${featured.slug}`)} to={`/blog/${featured.slug}`}
className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2" className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2"
aria-label={`${t('readArticlePrefix')} ${featured.title}`} aria-label={`خواندن مقاله ${featured.title}`}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<img <img
src={featured.image} src={featured.image}
alt={`${t('articleImagePrefix')} ${featured.title}`} alt={`تصویر مقاله ${featured.title}`}
className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105"
/> />
</div> </div>
<div className="flex flex-col justify-center p-8 lg:p-12"> <div className="flex flex-col justify-center p-8 lg:p-12">
<p className="text-xs font-light text-copper"> <p className="text-xs font-light text-copper">
{t('featuredArticle')} — {featured.category} مقاله ویژه — {featured.category}
{featured.status === 'draft' && ( {featured.status === 'draft' && (
<DraftBadge className="inline-block align-middle ltr:ml-3 rtl:mr-3" /> <DraftBadge className="mr-3 inline-block align-middle" />
)} )}
</p> </p>
<h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl"> <h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl">
@@ -103,8 +100,8 @@ export function Blog() {
</span> </span>
</span> </span>
<span className="flex items-center gap-2 text-copper"> <span className="flex items-center gap-2 text-copper">
{t('readMore')} ادامه مطلب
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-200 ease-lux rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" />
</span> </span>
</div> </div>
</div> </div>
@@ -118,7 +115,7 @@ export function Blog() {
<EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}> <EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}>
<div className="relative h-full"> <div className="relative h-full">
{article.status === 'draft' && ( {article.status === 'draft' && (
<DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" /> <DraftBadge className="absolute left-3 top-3 z-10" />
)} )}
<ArticleCard article={article} /> <ArticleCard article={article} />
</div> </div>
+1 -3
View File
@@ -3,18 +3,16 @@ import { PageHero } from '../components/PageHero'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { Reveal } from '../components/Reveal' import { Reveal } from '../components/Reveal'
import { useSite } from '../api' import { useSite } from '../api'
import { useLang } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable' import { AddItemButton, EditableItem } from '../admin/Editable'
export function ContactPage() { export function ContactPage() {
const { offices } = useSite() const { offices } = useSite()
const { t } = useLang()
return ( return (
<> <>
<PageHero pageKey="contact" /> <PageHero pageKey="contact" />
<section className="bg-ink py-24" aria-label={t('officesAria')}> <section className="bg-ink py-24" aria-label="دفاتر ما">
<div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10"> <div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10">
{offices.map((office, i) => ( {offices.map((office, i) => (
<Reveal key={office.id} delay={0.06 * i} className="h-full"> <Reveal key={office.id} delay={0.06 * i} className="h-full">
+1 -3
View File
@@ -5,12 +5,10 @@ import { SectionHeading } from '../components/SectionHeading'
import { Stats } from '../components/home/Stats' import { Stats } from '../components/home/Stats'
import { ContactSection } from '../components/ContactSection' import { ContactSection } from '../components/ContactSection'
import { useSectionHeading, useSite } from '../api' import { useSectionHeading, useSite } from '../api'
import { useLang, formatNumber } from '../i18n/lang'
import { AddItemButton, EditableItem } from '../admin/Editable' import { AddItemButton, EditableItem } from '../admin/Editable'
export function Investment() { export function Investment() {
const { investment_models } = useSite() const { investment_models } = useSite()
const { lang } = useLang()
const heading = useSectionHeading('investment_models') const heading = useSectionHeading('investment_models')
return ( return (
@@ -28,7 +26,7 @@ export function Investment() {
{/* لایه نوری ظریف که در زمان هاور روشن می‌شود */} {/* لایه نوری ظریف که در زمان هاور روشن می‌شود */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" /> <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
<p className="relative z-10 text-sm font-bold text-copper/80">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</p> <p className="relative z-10 text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p>
<h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper"> <h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
{model.title} {model.title}
+27 -30
View File
@@ -8,13 +8,11 @@ import { ContactSection } from '../components/ContactSection'
import { Lightbox } from '../components/Lightbox' import { Lightbox } from '../components/Lightbox'
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
export function ProjectDetail() { export function ProjectDetail() {
const { slug } = useParams<{ slug: string }>() const { slug } = useParams<{ slug: string }>()
const { projects, brand } = useSite() const { projects, brand } = useSite()
const { t, href, lang } = useLang()
const project = projects.find((p) => p.slug === slug) const project = projects.find((p) => p.slug === slug)
const statusLabel = useStatusLabel(project?.status ?? '') const statusLabel = useStatusLabel(project?.status ?? '')
const [lightbox, setLightbox] = useState<number | null>(null) const [lightbox, setLightbox] = useState<number | null>(null)
@@ -25,22 +23,21 @@ export function ProjectDetail() {
applySeo({ applySeo({
title: `${project.title} | ${brand.name}`, title: `${project.title} | ${brand.name}`,
description: project.summary, description: project.summary,
path: href(`/projects/${project.slug}`), path: `/projects/${project.slug}`,
image: project.image, image: project.image,
lang,
}); });
}, [project?.id, lang, href]); }, [project?.id]);
if (!project) { if (!project) {
return ( return (
<section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center">
<h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1> <h1 className="text-section font-bold text-ivory">این پروژه پیدا نشد</h1>
<p className="mt-4 text-sm font-light text-ivory/60"> <p className="mt-4 text-sm font-light text-ivory/60">
{t('projectNotFoundText')} ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.
</p> </p>
<ButtonLink to={href('/projects')} variant="copper" className="mt-8"> <ButtonLink to="/projects" variant="copper" className="mt-8">
{t('backToProjects')} بازگشت به پروژه‌ها
<ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> <ArrowLeftIcon className="h-4 w-4" />
</ButtonLink> </ButtonLink>
</section> </section>
) )
@@ -49,12 +46,12 @@ export function ProjectDetail() {
const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3) const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3)
const facts = [ const facts = [
{ label: t('specLocation'), value: project.location }, { label: 'موقعیت', value: project.location },
{ label: t('specYear'), value: project.year }, { label: 'سال', value: project.year },
{ label: t('specArea'), value: project.area }, { label: 'زیربنا', value: project.area },
{ label: t('specFloors'), value: project.floors }, { label: 'تعداد طبقات', value: project.floors },
{ label: t('specUnits'), value: project.units }, { label: 'واحدها', value: project.units },
{ label: t('specClient'), value: project.client }, { label: 'کارفرما', value: project.client },
] ]
return ( return (
@@ -68,15 +65,15 @@ export function ProjectDetail() {
aria-hidden="true" aria-hidden="true"
/> />
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10"> <div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50"> <nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
<Link to={href('/')} className="transition-colors hover:text-copper"> <Link to="/" className="transition-colors hover:text-copper">
{t('home')} صفحه اصلی
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <ChevronLeftIcon className="h-3.5 w-3.5" />
<Link to={href('/projects')} className="transition-colors hover:text-copper"> <Link to="/projects" className="transition-colors hover:text-copper">
{t('projects')} پروژه‌ها
</Link> </Link>
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <ChevronLeftIcon className="h-3.5 w-3.5" />
<span className="text-copper">{project.title}</span> <span className="text-copper">{project.title}</span>
</nav> </nav>
<h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1>
@@ -84,7 +81,7 @@ export function ProjectDetail() {
</div> </div>
</section> </section>
<section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}> <section className="bg-char py-20 lg:py-28" aria-label="جزئیات پروژه">
<div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10"> <div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10">
<Reveal className="order-1"> <Reveal className="order-1">
@@ -92,7 +89,7 @@ export function ProjectDetail() {
<span aria-hidden="true" className="h-px w-8 bg-copper" /> <span aria-hidden="true" className="h-px w-8 bg-copper" />
{statusLabel} {statusLabel}
</span> </span>
<h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2> <h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2>
{project.body.map((para) => ( {project.body.map((para) => (
<p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70"> <p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70">
@@ -102,7 +99,7 @@ export function ProjectDetail() {
<dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10"> <dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10">
{facts.map((f) => ( {facts.map((f) => (
<div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right"> <div key={f.label} className="bg-ink p-6 md:p-8 text-right">
<dt className="text-xs font-light text-ivory/50">{f.label}</dt> <dt className="text-xs font-light text-ivory/50">{f.label}</dt>
<dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd> <dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd>
</div> </div>
@@ -110,8 +107,8 @@ export function ProjectDetail() {
</dl> </dl>
<div className="mt-10"> <div className="mt-10">
<ButtonLink to={href('/contact')} variant="outline" className="font-light"> <ButtonLink to="/contact" variant="outline" className="font-light">
{t('similarProjectCta')} پروژه‌ای مشابه دارید؟
</ButtonLink> </ButtonLink>
</div> </div>
</Reveal> </Reveal>
@@ -149,9 +146,9 @@ export function ProjectDetail() {
</div> </div>
</section> </section>
<section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}> <section className="bg-ink py-20 lg:py-28" aria-label="پروژه‌های مرتبط">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2> <h2 className="text-section font-bold text-ivory">پروژه‌های دیگر</h2>
<div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-6 h-px w-24 bg-copper/70" />
<div className="mt-12 grid gap-6 lg:grid-cols-3"> <div className="mt-12 grid gap-6 lg:grid-cols-3">
{related.map((item, i) => ( {related.map((item, i) => (
+2 -4
View File
@@ -6,14 +6,12 @@ import { Gallery } from '../components/home/Gallery';
import { ContactSection } from '../components/ContactSection'; import { ContactSection } from '../components/ContactSection';
import type { ProjectStatus } from '../data/site'; import type { ProjectStatus } from '../data/site';
import { useSite, useStatusLabel } from '../api'; import { useSite, useStatusLabel } from '../api';
import { useLang } from '../i18n/lang';
import { useFieldSaver } from '../admin/AdminContext'; import { useFieldSaver } from '../admin/AdminContext';
import { InlineEdit } from '../components/admin/InlineEdit'; import { InlineEdit } from '../components/admin/InlineEdit';
import { AddItemButton, EditableItem } from '../admin/Editable'; import { AddItemButton, EditableItem } from '../admin/Editable';
export function Projects() { export function Projects() {
const { projects, status_labels } = useSite(); const { projects, status_labels } = useSite();
const { t } = useLang();
const save = useFieldSaver(); const save = useFieldSaver();
const saveStatusLabel = (key: string, value: string) => { const saveStatusLabel = (key: string, value: string) => {
@@ -38,11 +36,11 @@ export function Projects() {
<> <>
<PageHero pageKey="projects" /> <PageHero pageKey="projects" />
<section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}> <section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
{/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */} {/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */}
<div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}> <div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه">
{FILTERS.map((filter, index) => { {FILTERS.map((filter, index) => {
const isSelected = status === filter.key; const isSelected = status === filter.key;
return ( return (
+4 -38
View File
@@ -1,10 +1,7 @@
// تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD. // تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD.
// محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را // محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را
// سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py). // سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py).
import { localeHref, langFromPathname } from './i18n/lang'
import type { Lang } from './i18n/lang'
export function setMetaDescription(content: string): void { export function setMetaDescription(content: string): void {
// بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند // بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند
if (!content.trim()) return if (!content.trim()) return
@@ -19,13 +16,11 @@ export function setMetaDescription(content: string): void {
/** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */ /** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */
export function seoKeyForPath(pathname: string): string | null { export function seoKeyForPath(pathname: string): string | null {
// زبان از پیشوند /en جدا می‌شود — کلیدهای سئو برای هر دو زبان یکی است if (pathname === '/') return 'home'
const clean = pathname === '/en' ? '/' : pathname.startsWith('/en/') ? pathname.slice(3) : pathname const seg = pathname.split('/')[1] ?? ''
if (clean === '/') return 'home'
const seg = clean.split('/')[1] ?? ''
const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact'] const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact']
// مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند // مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند
return known.includes(seg) && clean === `/${seg}` ? seg : null return known.includes(seg) && pathname === `/${seg}` ? seg : null
} }
function upsertMeta(attr: 'name' | 'property', key: string, content: string): void { function upsertMeta(attr: 'name' | 'property', key: string, content: string): void {
@@ -48,30 +43,6 @@ function upsertCanonical(path: string): void {
el.setAttribute('href', new URL(path, window.location.origin).href) el.setAttribute('href', new URL(path, window.location.origin).href)
} }
const HREFLANGS: [Lang, string][] = [
['fa', 'fa'],
['en', 'en'],
['fa', 'x-default'],
]
/** لینک‌های alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر */
function upsertAlternates(path: string): void {
for (const [targetLang, hreflang] of HREFLANGS) {
// x-default همیشه نسخه فارسی است
const target = localeHref(path, targetLang)
const attr = `data-hreflang="${hreflang}"`
let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`)
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', 'alternate')
el.setAttribute('hreflang', hreflang)
el.setAttribute('data-hreflang', hreflang)
document.head.appendChild(el)
}
el.setAttribute('href', new URL(target, window.location.origin).href)
}
}
function setJsonLd(data: Record<string, unknown> | null): void { function setJsonLd(data: Record<string, unknown> | null): void {
const id = 'seo-jsonld' const id = 'seo-jsonld'
let el = document.getElementById(id) let el = document.getElementById(id)
@@ -96,8 +67,6 @@ export type SeoInput = {
/** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */ /** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */
image?: string image?: string
type?: 'website' | 'article' type?: 'website' | 'article'
/** زبان جاری — برای og:locale و لینک‌های hreflang */
lang?: Lang
jsonLd?: Record<string, unknown> | null jsonLd?: Record<string, unknown> | null
} }
@@ -107,7 +76,6 @@ export function applySeo(input: SeoInput): void {
const description = (input.description ?? '').trim() const description = (input.description ?? '').trim()
const path = input.path ?? window.location.pathname const path = input.path ?? window.location.pathname
const image = (input.image ?? '').trim() const image = (input.image ?? '').trim()
const lang: Lang = input.lang ?? langFromPathname(path)
document.title = title document.title = title
setMetaDescription(description) setMetaDescription(description)
@@ -116,13 +84,11 @@ export function applySeo(input: SeoInput): void {
upsertMeta('property', 'og:description', description) upsertMeta('property', 'og:description', description)
upsertMeta('property', 'og:type', input.type ?? 'website') upsertMeta('property', 'og:type', input.type ?? 'website')
upsertMeta('property', 'og:url', new URL(path, window.location.origin).href) upsertMeta('property', 'og:url', new URL(path, window.location.origin).href)
upsertMeta('property', 'og:locale', lang === 'en' ? 'en_US' : 'fa_IR')
upsertMeta('name', 'twitter:card', 'summary_large_image') upsertMeta('name', 'twitter:card', 'summary_large_image')
if (image) { if (image) {
upsertMeta('property', 'og:image', new URL(image, window.location.origin).href) upsertMeta('property', 'og:image', new URL(image, window.location.origin).href)
} else { } else {
document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove() document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove()
} }
upsertAlternates(path)
setJsonLd(input.jsonLd ?? null) setJsonLd(input.jsonLd ?? null)
} }
+74
View File
@@ -0,0 +1,74 @@
/**
* دوزبانه‌سازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچک‌تر» از متن
* فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در
* مقاله انگلیسی برعکس، واژه‌های فارسی ۲ پیکسل بزرگ‌تر می‌شوند.
*
* اجرای غیرغالب متن با یک span جهت‌دار جدا می‌شود تا نه جهت متن به‌هم بریزد
* (unicode-bidi: isolate) و نه خط پایه جابه‌جا شود؛ اندازه در CSS با
* calc(1em - 2px) نسبت به همان بلوک حساب می‌شود.
*/
/** اجرای متن لاتین — حروف/ارقام لاتین به‌همراه فاصله و نشانه‌های داخلی */
const LATIN_PATTERN =
"[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*"
/** اجرای متن فارسی/عربی — با نیم‌فاصله و ارقام فارسی */
const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*'
/** کلاس span متن لاتین و متن فارسی — همین‌ها در CSS اندازه ۲ پیکسل تفاضلی می‌گیرند */
export const LATIN_CLASS = 'latin'
export const RTL_CLASS = 'persian'
/** عناصری که متنشان دست‌نخورده می‌ماند (کد، مارک‌های قدیمی و اجرای بسته‌بندی‌شده) */
const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian'
/** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */
export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] {
const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g')
const runs: { from: number; to: number }[] = []
for (const match of text.matchAll(scan)) {
const from = match.index ?? 0
runs.push({ from, to: from + match[0].length })
}
return runs
}
/**
* بسته‌بندی اجرای غیرغالب همه گره‌های متنی داخل یک ریشه HTML.
* در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا می‌شود.
*/
export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void {
const document = root.ownerDocument
const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS
const runDir = dir === 'rtl' ? 'ltr' : 'rtl'
const texts: Text[] = []
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node as Text
if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue
if (!text.data.trim()) continue
if (findScriptRuns(text.data, dir).length) texts.push(text)
}
for (const text of texts) {
const value = text.data
const fragment = document.createDocumentFragment()
let cursor = 0
for (const run of findScriptRuns(value, dir)) {
if (run.from > cursor) fragment.append(value.slice(cursor, run.from))
const span = document.createElement('span')
span.className = className
span.setAttribute('dir', runDir)
span.textContent = value.slice(run.from, run.to)
fragment.append(span)
cursor = run.to
}
if (cursor < value.length) fragment.append(value.slice(cursor))
text.replaceWith(fragment)
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* تشخیص جهت غالب متن — پایه چیدمان مقاله و فهرست مطالب.
* سایت فارسی است؛ اما مقاله‌ای که متنش لاتین است باید چپ‌به‌راست خوانده شود.
*/
/** خط‌های راست‌به‌چپ: عبری، عربی و فارسی */
const RTL_CHARS = /[\u0590-\u05ff\u0600-\u06ff\u0700-\u074f\ufb50-\ufdff\ufe70-\ufeff]/
/** ارقام فارسی/عربی — در شمارش جهت بی‌طرف‌اند */
const RTL_DIGITS = /[\u0660-\u0669\u06f0-\u06f9]/
/** حروف لاتین (با علامت‌ها) */
const LTR_CHARS = /[A-Za-z\u00c0-\u024f]/
/**
* جهت غالب نمونه‌های متن: فارسی/عربی → rtl و لاتین → ltr.
* در حالت مساوی (مثلاً متن کوتاه یا فقط رقم) پیش‌فرض سایت، راست‌به‌چپ، برمی‌گردد.
*/
export function detectDirection(...samples: (string | null | undefined)[]): 'rtl' | 'ltr' {
let rtl = 0
let ltr = 0
for (const sample of samples) {
for (const char of sample ?? '') {
if (RTL_DIGITS.test(char)) continue
if (RTL_CHARS.test(char)) rtl += 1
else if (LTR_CHARS.test(char)) ltr += 1
}
}
return rtl >= ltr ? 'rtl' : 'ltr'
}
+14 -18
View File
@@ -1,31 +1,27 @@
import slugify from 'slugify'
/** /**
* تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن. * تبدیل متن (فارسی یا انگلیسی) به شناسه/لنگر امن برای فهرست مطالب.
* slugify حروف فارسی و عربی را به معادل لاتین ترانویسی می‌کند تا لنگرهای * حروف و اعداد فارسی/عربی و لاتین در شناسه می‌مانند (در URL به‌صورت percent-encoded
* فهرست مطالب در همه مرورگرها ASCII، پایدار و قابل اشتراک‌گذاری بمانند. * نوشته می‌شوند و در همه مرورگرها کار می‌کنند)؛ فقط نشانه‌ها و فاصله‌ها به خط تیره
* تبدیل می‌شوند. نیم‌فاصله و نویسه‌های نامرئی جهت هم حذف می‌شوند تا تیترهای تکراری
* شناسه یکتا بگیرند.
*/ */
export function slugifyFa(input: string): string { export function slugifyFa(input: string): string {
const normalized = (input ?? '') return (input ?? '')
.toString() .toString()
.normalize('NFC') .normalize('NFC')
// نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → فاصله // نیم‌فاصله، اتصال‌دهنده و نویسه‌های نامرئی جهت → حذف
.replace(/[\u200b-\u200f\u202a-\u202e]/g, ' ') .replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
// یکسان‌سازی حروف عربی با معادل فارسی // یکسان‌سازی حروف عربی با معادل فارسی
.replace(/[يى]/g, 'ی') .replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک') .replace(/ك/g, 'ک')
.replace(/[ۀة]/g, 'ه') .replace(/[ۀة]/g, 'ه')
.toLowerCase()
// فقط حروف/اعداد یونیکد (فارسی، عربی، لاتین و...) و فاصله/خط تیره بماند
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim() .trim()
.replace(/\s+/g, '-')
const slug = slugify(normalized, { .replace(/-{2,}/g, '-')
lower: true, .replace(/^-+|-+$/g, '')
trim: true,
replacement: '-',
// فقط حروف/اعداد/فاصله/خط تیره بماند — بقیه نشانه‌ها حذف شوند
remove: /[^\w\s-]/g,
})
return slug.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '')
} }
/** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */ /** ساخت شناسه یکتا — تیترهای تکراری پسوند عددی می‌گیرند (مثل about، about-2) */
+28
View File
@@ -3,6 +3,8 @@
* هم چک‌لیست سئو و هم محاسبه زمان مطالعه از همین‌جا استفاده می‌کنند (DRY). * هم چک‌لیست سئو و هم محاسبه زمان مطالعه از همین‌جا استفاده می‌کنند (DRY).
*/ */
import type { ArticleBodyDoc } from '../data/site'
type DocNode = { type DocNode = {
type?: string type?: string
text?: string text?: string
@@ -73,3 +75,29 @@ export function firstParagraph(body: unknown): string {
const paragraph = content?.find((child) => child?.type === 'paragraph') const paragraph = content?.find((child) => child?.type === 'paragraph')
return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : '' return paragraph ? docToText({ type: 'doc', content: [paragraph] }) : ''
} }
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as ArticleBodyDoc
/**
* تبدیل بدنه ذخیره‌شده مقاله به سند TipTap برای بارگذاری در ویرایشگر.
* ردیف‌های قدیمی متن را به‌صورت آرایه پاراگراف نگه داشته‌اند و ممکن است رشته JSON هم
* برگردد؛ هر دو شکل به سند تبدیل می‌شوند تا هنگام ویرایش، متن قبلی از دست نرود.
*/
export function toArticleBodyDoc(body: unknown): ArticleBodyDoc {
if (Array.isArray(body)) {
const paragraphs = body
.map((paragraph) => String(paragraph ?? '').trim())
.filter(Boolean)
.map((text) => ({ type: 'paragraph', content: [{ type: 'text', text }] }))
return paragraphs.length ? { type: 'doc', content: paragraphs } : { ...EMPTY_DOC }
}
if (typeof body === 'string') {
try {
return toArticleBodyDoc(JSON.parse(body))
} catch {
return { ...EMPTY_DOC }
}
}
if (body && typeof body === 'object') return body as ArticleBodyDoc
return { ...EMPTY_DOC }
}
+3 -1
View File
@@ -5,8 +5,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
// در حالت توسعه، /api و /media به Traefik پروکسی می‌شوند (پورت ۵۱۷۳ روی هاست) port: 5174, // سرور توسعه فرانت‌اند روی این پورت اجرا می‌شود تا تداخل نکند
strictPort: true, // جلوگیری از تغییر خودکار پورت
proxy: { proxy: {
// در حالت توسعه، درخواست‌های API و Media به Traefik (پورت ۵۱۷۳) فرستاده می‌شوند
'/api': 'http://localhost:5173', '/api': 'http://localhost:5173',
'/media': 'http://localhost:5173', '/media': 'http://localhost:5173',
}, },