Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853ad67db5 | ||
|
|
805f4594d4 | ||
|
|
fe6957bf83 | ||
|
|
53673d0f9b | ||
|
|
9d55f521a0 | ||
|
|
4fb57dfd67 | ||
|
|
6b49d035c2 | ||
|
|
6bb6226e06 | ||
|
|
9242b87677 |
@@ -15,6 +15,119 @@ POSITION_TABLES = [
|
||||
"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:
|
||||
"""ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
|
||||
@@ -32,6 +145,14 @@ def run_ddl_migrations() -> None:
|
||||
conn.execute(
|
||||
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:
|
||||
@@ -66,6 +187,122 @@ def migrate_article_bodies() -> None:
|
||||
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:
|
||||
run_ddl_migrations()
|
||||
migrate_article_bodies()
|
||||
backfill_en_content()
|
||||
|
||||
@@ -10,6 +10,10 @@ def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ستونهای *_en معادل انگلیسی فیلدهای متنیِ قابل نمایش هستند؛
|
||||
# مقدار خالی یعنی «ترجمهای ثبت نشده» و فرانتاند به نسخه فارسی برمیگردد.
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
|
||||
@@ -23,6 +27,7 @@ class Brand(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
name_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
logo: Mapped[str] = mapped_column(Text, default="")
|
||||
hero: Mapped[str] = mapped_column(Text, default="")
|
||||
contact_bg: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -30,6 +35,7 @@ class Brand(Base):
|
||||
phone_href: Mapped[str] = mapped_column(String(128), default="")
|
||||
email: Mapped[str] = mapped_column(String(128), default="")
|
||||
address: Mapped[str] = mapped_column(Text, default="")
|
||||
address_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Service(Base):
|
||||
@@ -39,7 +45,9 @@ class Service(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), 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")
|
||||
|
||||
|
||||
@@ -51,17 +59,26 @@ class Project(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
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_en: Mapped[str] = mapped_column(String(128), 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
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
gallery: Mapped[list] = mapped_column(JSON, default=list)
|
||||
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_en: 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_en: Mapped[str] = mapped_column(String(128), 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_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class Article(Base):
|
||||
@@ -72,12 +89,18 @@ class Article(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
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_en: 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="")
|
||||
read: Mapped[str] = mapped_column(String(32), default="")
|
||||
read_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
excerpt: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list)
|
||||
excerpt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[list] = mapped_column(JSON, default=list) # سند TipTap (تاریخی: لیست پاراگراف)
|
||||
body_en: Mapped[dict] = mapped_column(JSON, default=dict) # سند TipTap انگلیسی
|
||||
# draft | published — پیشنویس فقط برای ادمین دیده میشود
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
|
||||
@@ -91,8 +114,11 @@ class Testimonial(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
quote: Mapped[str] = mapped_column(Text, default="")
|
||||
quote_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Faq(Base):
|
||||
@@ -102,7 +128,9 @@ class Faq(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
question_en: Mapped[str] = mapped_column(Text, default="")
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
answer_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TeamMember(Base):
|
||||
@@ -112,8 +140,11 @@ class TeamMember(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
bio: Mapped[str] = mapped_column(Text, default="")
|
||||
bio_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class TimelineItem(Base):
|
||||
@@ -123,8 +154,11 @@ class TimelineItem(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
year: Mapped[str] = mapped_column(String(32))
|
||||
year_en: Mapped[str] = mapped_column(String(32), default="")
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
@@ -136,7 +170,9 @@ class Stat(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
value: Mapped[int] = mapped_column(Integer)
|
||||
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_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class WhyUsItem(Base):
|
||||
@@ -146,7 +182,9 @@ class WhyUsItem(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class BeforeAfter(Base):
|
||||
@@ -154,11 +192,15 @@ class BeforeAfter(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[str] = mapped_column(Text, default="")
|
||||
before: Mapped[str] = mapped_column(Text, default="")
|
||||
after: Mapped[str] = mapped_column(Text, 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_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NavItem(Base):
|
||||
@@ -168,6 +210,7 @@ class NavItem(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
href: Mapped[str] = mapped_column(String(128))
|
||||
|
||||
|
||||
@@ -178,16 +221,25 @@ class Hero(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
kicker: Mapped[str] = mapped_column(Text, default="")
|
||||
kicker_en: 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_en: Mapped[str] = mapped_column(String(128), 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_en: Mapped[str] = mapped_column(String(32), 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_en: 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_en: 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_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class StorySection(Base):
|
||||
@@ -197,16 +249,26 @@ class StorySection(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
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_en: Mapped[str] = mapped_column(Text, 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_en: Mapped[str] = mapped_column(String(128), 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_en: 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="")
|
||||
bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
|
||||
bento_en: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}] انگلیسی
|
||||
|
||||
|
||||
class SectionHeadingContent(Base):
|
||||
@@ -217,10 +279,14 @@ class SectionHeadingContent(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
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_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
lead: Mapped[str] = mapped_column(Text, default="")
|
||||
lead_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, 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="")
|
||||
|
||||
|
||||
@@ -232,7 +298,9 @@ class PageHeroContent(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
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_en: Mapped[str] = mapped_column(Text, default="")
|
||||
image: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
@@ -243,8 +311,11 @@ class InvestmentModel(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
title_en: Mapped[str] = mapped_column(String(256), 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_en: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class ProcessStep(Base):
|
||||
@@ -254,8 +325,11 @@ class ProcessStep(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
desc: Mapped[str] = mapped_column(Text, default="")
|
||||
desc_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class Office(Base):
|
||||
@@ -265,8 +339,11 @@ class Office(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
city: Mapped[str] = mapped_column(String(64))
|
||||
city_en: Mapped[str] = mapped_column(String(64), 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_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class GalleryImage(Base):
|
||||
@@ -277,6 +354,7 @@ class GalleryImage(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
src: Mapped[str] = mapped_column(Text, default="")
|
||||
alt: Mapped[str] = mapped_column(Text, default="")
|
||||
alt_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class StatusLabel(Base):
|
||||
@@ -287,6 +365,7 @@ class StatusLabel(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
label_en: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class ContactSectionContent(Base):
|
||||
@@ -296,23 +375,41 @@ class ContactSectionContent(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[str] = mapped_column(String(256), 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_en: 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_en: 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_en: Mapped[str] = mapped_column(String(128), 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_en: 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_en: Mapped[str] = mapped_column(String(64), 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_en: Mapped[str] = mapped_column(String(128), 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_en: Mapped[str] = mapped_column(String(256), 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_en: Mapped[str] = mapped_column(String(128), default="")
|
||||
|
||||
|
||||
class NewsletterSectionContent(Base):
|
||||
@@ -322,11 +419,17 @@ class NewsletterSectionContent(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: Mapped[str] = mapped_column(Text, 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_en: 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_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
|
||||
|
||||
class FooterContent(Base):
|
||||
@@ -336,12 +439,19 @@ class FooterContent(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_en: 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_en: Mapped[str] = mapped_column(String(64), 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_en: Mapped[str] = mapped_column(String(256), default="")
|
||||
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):
|
||||
@@ -352,7 +462,9 @@ class SeoMeta(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
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_en: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
|
||||
class ContactMessage(Base):
|
||||
|
||||
@@ -10,12 +10,16 @@ class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# فیلدهای *_en معادل انگلیسیاند؛ خالی بودنشان یعنی «بدون ترجمه».
|
||||
|
||||
|
||||
# ---------- خروجیها ----------
|
||||
|
||||
|
||||
class BrandOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
logo: str
|
||||
hero: str
|
||||
contact_bg: str
|
||||
@@ -23,12 +27,15 @@ class BrandOut(ORMModel):
|
||||
phone_href: str
|
||||
email: str
|
||||
address: str
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
icon: str
|
||||
|
||||
|
||||
@@ -36,29 +43,44 @@ class ProjectOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
location: str
|
||||
location_en: str = ""
|
||||
year: str
|
||||
year_en: str = ""
|
||||
status: str
|
||||
image: str
|
||||
gallery: list[str]
|
||||
area: str
|
||||
area_en: str = ""
|
||||
floors: str
|
||||
floors_en: str = ""
|
||||
units: str
|
||||
units_en: str = ""
|
||||
client: str
|
||||
client_en: str = ""
|
||||
summary: str
|
||||
summary_en: str = ""
|
||||
body: list[str]
|
||||
body_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ArticleOut(ORMModel):
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
date: str
|
||||
date_en: str = ""
|
||||
category: str
|
||||
category_en: str = ""
|
||||
image: str
|
||||
read: str
|
||||
read_en: str = ""
|
||||
excerpt: str
|
||||
excerpt_en: str = ""
|
||||
body: dict
|
||||
body_en: Optional[dict] = None
|
||||
status: str
|
||||
seo_keyphrase: str
|
||||
|
||||
@@ -66,28 +88,39 @@ class ArticleOut(ORMModel):
|
||||
class TestimonialOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqOut(ORMModel):
|
||||
id: int
|
||||
question: str
|
||||
question_en: str = ""
|
||||
answer: str
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
name_en: str = ""
|
||||
role: str
|
||||
role_en: str = ""
|
||||
bio: str
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemOut(ORMModel):
|
||||
id: int
|
||||
year: str
|
||||
year_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
@@ -95,69 +128,101 @@ class StatOut(ORMModel):
|
||||
id: int
|
||||
value: int
|
||||
suffix: str
|
||||
suffix_en: str = ""
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
before: str
|
||||
after: str
|
||||
before_label: str
|
||||
before_label_en: str = ""
|
||||
after_label: str
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
href: str
|
||||
|
||||
|
||||
class HeroOut(ORMModel):
|
||||
id: int
|
||||
kicker: str
|
||||
kicker_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
title_highlight: str
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str
|
||||
subtitle_en: str = ""
|
||||
badge_value: str
|
||||
badge_value_en: str = ""
|
||||
badge_label: str
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str
|
||||
cta_secondary_label: str
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str
|
||||
scroll_label: str
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
paragraphs: list[str]
|
||||
paragraphs_en: Optional[list[str]] = None
|
||||
quote: str
|
||||
quote_en: str = ""
|
||||
quote_author: str
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str
|
||||
stat2_label_en: str = ""
|
||||
link_label: str
|
||||
link_label_en: str = ""
|
||||
link_href: str
|
||||
bento: list[dict]
|
||||
bento_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SectionHeadingOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
cta_label: str
|
||||
cta_label_en: str = ""
|
||||
cta_href: str
|
||||
|
||||
|
||||
@@ -165,91 +230,137 @@ class PageHeroOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
image: str
|
||||
|
||||
|
||||
class InvestmentModelOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
points: list[str]
|
||||
points_en: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ProcessStepOut(ORMModel):
|
||||
id: int
|
||||
step: str
|
||||
step_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
desc: str
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeOut(ORMModel):
|
||||
id: int
|
||||
city: str
|
||||
city_en: str = ""
|
||||
address: str
|
||||
address_en: str = ""
|
||||
hours: str
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageOut(ORMModel):
|
||||
id: int
|
||||
src: str
|
||||
alt: str
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelOut(ORMModel):
|
||||
id: int
|
||||
key: str
|
||||
label: str
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionOut(ORMModel):
|
||||
id: int
|
||||
label: str
|
||||
label_en: str = ""
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
address_label: str
|
||||
address_label_en: str = ""
|
||||
phone_label: str
|
||||
phone_label_en: str = ""
|
||||
email_label: str
|
||||
email_label_en: str = ""
|
||||
name_label: str
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str
|
||||
submit_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_title: str
|
||||
success_title_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
success_button_label: str
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterOut(ORMModel):
|
||||
id: int
|
||||
title: str
|
||||
title_en: str = ""
|
||||
lead: str
|
||||
lead_en: str = ""
|
||||
placeholder: str
|
||||
placeholder_en: str = ""
|
||||
button_label: str
|
||||
button_label_en: str = ""
|
||||
sending_label: str
|
||||
sending_label_en: str = ""
|
||||
success_text: str
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterOut(ORMModel):
|
||||
id: int
|
||||
about_text: str
|
||||
about_text_en: str = ""
|
||||
pages_heading: str
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str
|
||||
contact_heading_en: str = ""
|
||||
copyright: str
|
||||
copyright_en: str = ""
|
||||
tagline: str
|
||||
tagline_en: str = ""
|
||||
socials: list[dict]
|
||||
socials_en: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class SeoMetaOut(ORMModel):
|
||||
id: int
|
||||
page_key: str
|
||||
title: str
|
||||
title_en: str = ""
|
||||
description: str
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class ContactMessageOut(ORMModel):
|
||||
@@ -301,6 +412,7 @@ class ChangePasswordIn(BaseModel):
|
||||
|
||||
class BrandIn(BaseModel):
|
||||
name: str = ""
|
||||
name_en: str = ""
|
||||
logo: str = ""
|
||||
hero: str = ""
|
||||
contact_bg: str = ""
|
||||
@@ -308,28 +420,40 @@ class BrandIn(BaseModel):
|
||||
phone_href: str = ""
|
||||
email: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
|
||||
|
||||
class ServiceIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
icon: str = "draft"
|
||||
|
||||
|
||||
class ProjectIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
location: str = ""
|
||||
location_en: str = ""
|
||||
year: str = ""
|
||||
year_en: str = ""
|
||||
status: Literal["done", "progress"] = "done"
|
||||
image: str = ""
|
||||
gallery: list[str] = []
|
||||
area: str = ""
|
||||
area_en: str = ""
|
||||
floors: str = ""
|
||||
floors_en: str = ""
|
||||
units: str = ""
|
||||
units_en: str = ""
|
||||
client: str = ""
|
||||
client_en: str = ""
|
||||
summary: str = ""
|
||||
summary_en: str = ""
|
||||
body: list[str] = []
|
||||
body_en: list[str] = []
|
||||
|
||||
|
||||
EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
@@ -347,189 +471,287 @@ def validate_doc_body(value: object) -> dict:
|
||||
class ArticleIn(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=128)
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
date: str = ""
|
||||
date_en: str = ""
|
||||
category: str = ""
|
||||
category_en: str = ""
|
||||
image: str = ""
|
||||
read: str = ""
|
||||
read_en: str = ""
|
||||
excerpt: str = ""
|
||||
excerpt_en: str = ""
|
||||
body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
body_en: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
seo_keyphrase: str = Field(default="", max_length=128)
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
|
||||
|
||||
class TestimonialIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
|
||||
|
||||
class FaqIn(BaseModel):
|
||||
question: str = Field(min_length=1)
|
||||
question_en: str = ""
|
||||
answer: str = ""
|
||||
answer_en: str = ""
|
||||
|
||||
|
||||
class TeamMemberIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
name_en: str = ""
|
||||
role: str = ""
|
||||
role_en: str = ""
|
||||
bio: str = ""
|
||||
bio_en: str = ""
|
||||
|
||||
|
||||
class TimelineItemIn(BaseModel):
|
||||
year: str = Field(min_length=1, max_length=32)
|
||||
year_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class StatIn(BaseModel):
|
||||
value: int
|
||||
suffix: str = ""
|
||||
suffix_en: str = ""
|
||||
label: str = Field(min_length=1, max_length=128)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class WhyUsItemIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class BeforeAfterIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
before: str = ""
|
||||
after: str = ""
|
||||
before_label: str = ""
|
||||
before_label_en: str = ""
|
||||
after_label: str = ""
|
||||
after_label_en: str = ""
|
||||
|
||||
|
||||
class NavItemIn(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
href: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class HeroIn(BaseModel):
|
||||
kicker: str = ""
|
||||
kicker_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
title_highlight: str = ""
|
||||
title_highlight_en: str = ""
|
||||
subtitle: str = ""
|
||||
subtitle_en: str = ""
|
||||
badge_value: str = ""
|
||||
badge_value_en: str = ""
|
||||
badge_label: str = ""
|
||||
badge_label_en: str = ""
|
||||
cta_primary_label: str = ""
|
||||
cta_primary_label_en: str = ""
|
||||
cta_primary_href: str = ""
|
||||
cta_secondary_label: str = ""
|
||||
cta_secondary_label_en: str = ""
|
||||
cta_secondary_href: str = ""
|
||||
scroll_label: str = ""
|
||||
scroll_label_en: str = ""
|
||||
|
||||
|
||||
class StoryIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
paragraphs: list[str] = []
|
||||
paragraphs_en: list[str] = []
|
||||
quote: str = ""
|
||||
quote_en: str = ""
|
||||
quote_author: str = ""
|
||||
quote_author_en: str = ""
|
||||
stat1_value: str = ""
|
||||
stat1_value_en: str = ""
|
||||
stat1_label: str = ""
|
||||
stat1_label_en: str = ""
|
||||
stat2_value: str = ""
|
||||
stat2_value_en: str = ""
|
||||
stat2_label: str = ""
|
||||
stat2_label_en: str = ""
|
||||
link_label: str = ""
|
||||
link_label_en: str = ""
|
||||
link_href: str = ""
|
||||
bento: list[dict] = []
|
||||
bento_en: list[dict] = []
|
||||
|
||||
|
||||
class SectionHeadingIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=64)
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
cta_label: str = ""
|
||||
cta_label_en: str = ""
|
||||
cta_href: str = ""
|
||||
|
||||
|
||||
class PageHeroIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
image: str = ""
|
||||
|
||||
|
||||
class InvestmentModelIn(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
points: list[str] = []
|
||||
points_en: list[str] = []
|
||||
|
||||
|
||||
class ProcessStepIn(BaseModel):
|
||||
step: str = ""
|
||||
step_en: str = ""
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
title_en: str = ""
|
||||
desc: str = ""
|
||||
desc_en: str = ""
|
||||
|
||||
|
||||
class OfficeIn(BaseModel):
|
||||
city: str = Field(min_length=1, max_length=64)
|
||||
city_en: str = ""
|
||||
address: str = ""
|
||||
address_en: str = ""
|
||||
hours: str = ""
|
||||
hours_en: str = ""
|
||||
|
||||
|
||||
class GalleryImageIn(BaseModel):
|
||||
src: str = ""
|
||||
alt: str = ""
|
||||
alt_en: str = ""
|
||||
|
||||
|
||||
class StatusLabelIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=16)
|
||||
label: str = Field(min_length=1, max_length=64)
|
||||
label_en: str = ""
|
||||
|
||||
|
||||
class ContactSectionIn(BaseModel):
|
||||
label: str = ""
|
||||
label_en: str = ""
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
address_label: str = ""
|
||||
address_label_en: str = ""
|
||||
phone_label: str = ""
|
||||
phone_label_en: str = ""
|
||||
email_label: str = ""
|
||||
email_label_en: str = ""
|
||||
name_label: str = ""
|
||||
name_label_en: str = ""
|
||||
name_placeholder: str = ""
|
||||
name_placeholder_en: str = ""
|
||||
phone_field_label: str = ""
|
||||
phone_field_label_en: str = ""
|
||||
phone_placeholder: str = ""
|
||||
phone_placeholder_en: str = ""
|
||||
note_label: str = ""
|
||||
note_label_en: str = ""
|
||||
note_optional_label: str = ""
|
||||
note_optional_label_en: str = ""
|
||||
note_placeholder: str = ""
|
||||
note_placeholder_en: str = ""
|
||||
submit_label: str = ""
|
||||
submit_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_title: str = ""
|
||||
success_title_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
success_button_label: str = ""
|
||||
success_button_label_en: str = ""
|
||||
|
||||
|
||||
class NewsletterSectionIn(BaseModel):
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
lead: str = ""
|
||||
lead_en: str = ""
|
||||
placeholder: str = ""
|
||||
placeholder_en: str = ""
|
||||
button_label: str = ""
|
||||
button_label_en: str = ""
|
||||
sending_label: str = ""
|
||||
sending_label_en: str = ""
|
||||
success_text: str = ""
|
||||
success_text_en: str = ""
|
||||
|
||||
|
||||
class FooterIn(BaseModel):
|
||||
about_text: str = ""
|
||||
about_text_en: str = ""
|
||||
pages_heading: str = ""
|
||||
pages_heading_en: str = ""
|
||||
services_heading: str = ""
|
||||
services_heading_en: str = ""
|
||||
contact_heading: str = ""
|
||||
contact_heading_en: str = ""
|
||||
copyright: str = ""
|
||||
copyright_en: str = ""
|
||||
tagline: str = ""
|
||||
tagline_en: str = ""
|
||||
socials: list[dict] = []
|
||||
socials_en: list[dict] = []
|
||||
|
||||
|
||||
class SeoMetaIn(BaseModel):
|
||||
page_key: str = Field(min_length=1, max_length=32)
|
||||
title: str = ""
|
||||
title_en: str = ""
|
||||
description: str = ""
|
||||
description_en: str = ""
|
||||
|
||||
|
||||
class SeoMetaPatch(BaseModel):
|
||||
page_key: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
|
||||
|
||||
class MessageReadIn(BaseModel):
|
||||
@@ -551,15 +773,22 @@ ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
|
||||
class ArticlePatch(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
title_en: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
date_en: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_en: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
read: Optional[str] = None
|
||||
read_en: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
excerpt_en: Optional[str] = None
|
||||
body: Optional[dict] = None
|
||||
body_en: Optional[dict] = None
|
||||
status: Optional[Literal["draft", "published"]] = None
|
||||
seo_keyphrase: Optional[str] = None
|
||||
_validate_body = field_validator("body")(validate_doc_body)
|
||||
_validate_body_en = field_validator("body_en")(validate_doc_body)
|
||||
TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
|
||||
FaqPatch = _make_patch("FaqPatch", FaqIn)
|
||||
TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
|
||||
|
||||
@@ -11,7 +11,7 @@ seed میشوند و دادههای قبلی دستنخورده می
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from . import models, seed_data
|
||||
from . import models, seed_data, seed_data_en
|
||||
from .auth import hash_password
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
@@ -46,6 +46,35 @@ CONTENT: list[tuple[Any, list[dict], str]] = [
|
||||
(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:
|
||||
db = SessionLocal()
|
||||
@@ -57,16 +86,19 @@ def seed(force: bool = False) -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
for model, rows, label in CONTENT:
|
||||
for (model, rows, label), en_rows in zip(CONTENT, EN_CONTENT):
|
||||
if db.query(model).count() > 0:
|
||||
continue
|
||||
for idx, row in enumerate(rows):
|
||||
payload = dict(row)
|
||||
# فایل فارسی مرجع است؛ فقط فیلدهای *_en از فایل انگلیسی اضافه میشود
|
||||
payload = {**dict(en_rows[idx]), **dict(row)} if idx < len(en_rows) else dict(row)
|
||||
if hasattr(model, "position") and "position" not in payload:
|
||||
payload["position"] = idx
|
||||
# بدنه مقالات در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article and isinstance(payload.get("body"), list):
|
||||
payload["body"] = paragraphs_to_doc(payload["body"])
|
||||
# بدنه مقاله (فارسی و انگلیسی) در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
|
||||
if model is models.Article:
|
||||
for body_key in ("body", "body_en"):
|
||||
if isinstance(payload.get(body_key), list):
|
||||
payload[body_key] = paragraphs_to_doc(payload[body_key])
|
||||
db.add(model(**payload))
|
||||
|
||||
if db.query(models.AdminUser).count() == 0:
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"""ترجمه انگلیسی دادههای اولیه سایت — ساختار یکبهیک با 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."},
|
||||
]
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
|
||||
این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
|
||||
title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
title / description / OG / canonical / hreflang / JSON-LD را از دیتابیس داخل آن تزریق میکند —
|
||||
بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
|
||||
|
||||
دو زبانه بودن: نسخه فارسی روی مسیرهای اصلی و نسخه انگلیسی روی /en/... سرو میشود؛
|
||||
هر دو در sitemap با hreflang متقابل و در head هر صفحه با link alternate معرفی میشوند.
|
||||
"""
|
||||
|
||||
import html
|
||||
@@ -22,6 +25,10 @@ from .database import get_db
|
||||
router = APIRouter()
|
||||
|
||||
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:
|
||||
@@ -33,6 +40,29 @@ def _absolute(path: str) -> str:
|
||||
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:
|
||||
try:
|
||||
return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
|
||||
@@ -64,17 +94,19 @@ def _shell(
|
||||
title: str,
|
||||
description: str,
|
||||
path: str,
|
||||
lang: str = "fa",
|
||||
og_image: str = "",
|
||||
og_type: str = "website",
|
||||
jsonld: dict | None = None,
|
||||
noscript: str = "",
|
||||
) -> HTMLResponse:
|
||||
"""index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
|
||||
title = title.strip() or "گروه ساختمانی آرامش"
|
||||
title = title.strip() or (FALLBACK_EN_BRAND if lang == "en" else FALLBACK_FA_BRAND)
|
||||
description = description.strip()
|
||||
canonical = _absolute(path)
|
||||
image = _absolute(og_image)
|
||||
esc = html.escape
|
||||
alternates = _alternates(path, lang)
|
||||
|
||||
# تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
|
||||
tags = [
|
||||
@@ -83,6 +115,7 @@ def _shell(
|
||||
f'<meta property="og:description" content="{esc(description)}" />',
|
||||
f'<meta property="og:type" content="{esc(og_type)}" />',
|
||||
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" />',
|
||||
]
|
||||
if image:
|
||||
@@ -95,28 +128,61 @@ def _shell(
|
||||
raw = _read_index()
|
||||
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>"
|
||||
return HTMLResponse(
|
||||
f'<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{block}</head><body>{body}</body></html>"
|
||||
f'<!doctype html><html {lang_attr}><head><meta charset="utf-8">'
|
||||
f"<title>{esc(title)}</title>{alternates}{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 = raw.replace("</head>", f" {block}\n </head>", 1)
|
||||
raw = raw.replace("</head>", f" {alternates}\n {block}\n </head>", 1)
|
||||
if noscript:
|
||||
raw = raw.replace("</body>", f"<noscript>{noscript}</noscript>\n </body>", 1)
|
||||
return HTMLResponse(raw)
|
||||
|
||||
|
||||
def _brand_name(db: Session) -> str:
|
||||
def _brand_name(db: Session, lang: str = "fa") -> str:
|
||||
brand = db.query(models.Brand).first()
|
||||
return brand.name if brand else "گروه ساختمانی آرامش"
|
||||
if brand is None:
|
||||
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:
|
||||
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 ----------
|
||||
|
||||
|
||||
@@ -128,26 +194,35 @@ def robots(db: Session = Depends(get_db)):
|
||||
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)
|
||||
def sitemap(db: Session = Depends(get_db)):
|
||||
urls = list(STATIC_PATHS)
|
||||
urls += [
|
||||
fa_urls = list(STATIC_PATHS)
|
||||
fa_urls += [
|
||||
f"/blog/{a.slug}"
|
||||
for a in db.query(models.Article)
|
||||
.filter(models.Article.status == "published")
|
||||
.order_by(models.Article.position, models.Article.id)
|
||||
.all()
|
||||
]
|
||||
urls += [
|
||||
fa_urls += [
|
||||
f"/projects/{p.slug}"
|
||||
for p in db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
|
||||
]
|
||||
items = "\n".join(
|
||||
f" <url><loc>{html.escape(_absolute(u))}</loc></url>" for u in urls
|
||||
)
|
||||
items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls)
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
|
||||
'xmlns:xhtml="http://www.w3.org/1999/xhtml">\n'
|
||||
f"{items}\n</urlset>\n"
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml; charset=utf-8")
|
||||
@@ -163,10 +238,16 @@ def blog_list(db: Session = Depends(get_db)):
|
||||
title=meta.title if meta and meta.title else "وبلاگ | " + _brand_name(db),
|
||||
description=meta.description if meta else "",
|
||||
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)
|
||||
def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
article = (
|
||||
@@ -179,22 +260,23 @@ def blog_detail(slug: str, db: Session = Depends(get_db)):
|
||||
return _shell(title=_brand_name(db), description="", path=f"/blog/{slug}")
|
||||
|
||||
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])
|
||||
jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": article.title,
|
||||
"description": article.excerpt,
|
||||
"headline": title,
|
||||
"description": excerpt,
|
||||
"image": _absolute(article.image),
|
||||
"inLanguage": "fa",
|
||||
"author": {"@type": "Organization", "name": brand},
|
||||
"publisher": {"@type": "Organization", "name": brand},
|
||||
"mainEntityOfPage": _absolute(f"/blog/{article.slug}"),
|
||||
}
|
||||
noscript = f"<h1>{html.escape(article.title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
noscript = f"<h1>{html.escape(title)}</h1><p>{html.escape(first_text)}</p>"
|
||||
return _shell(
|
||||
title=f"{article.title} | {brand}",
|
||||
description=article.excerpt,
|
||||
title=f"{title} | {brand}",
|
||||
description=excerpt,
|
||||
path=f"/blog/{article.slug}",
|
||||
og_image=article.image,
|
||||
og_type="article",
|
||||
@@ -223,17 +305,132 @@ def project_detail(slug: str, db: Session = Depends(get_db)):
|
||||
return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
|
||||
|
||||
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(
|
||||
title=f"{project.title} | {brand}",
|
||||
description=project.summary,
|
||||
path=f"/projects/{project.slug}",
|
||||
og_image=project.image,
|
||||
jsonld={
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": project.title,
|
||||
"description": project.summary,
|
||||
"image": _absolute(project.image),
|
||||
"inLanguage": "fa",
|
||||
},
|
||||
jsonld=jsonld,
|
||||
)
|
||||
|
||||
|
||||
# ---------- صفحات انگلیسی (/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")
|
||||
|
||||
@@ -180,3 +180,91 @@ def test_head_not_injected_for_draft(client):
|
||||
def test_head_replaces_title_once(client):
|
||||
res = client.get("/blog/published-post")
|
||||
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"
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
|
||||
- traefik.http.routers.api.entrypoints=websecure
|
||||
- traefik.http.routers.api.tls.certresolver=le
|
||||
- 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.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.entrypoints=websecure
|
||||
- traefik.http.routers.seo.tls.certresolver=le
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
|
||||
- --entrypoints.web.address=:80
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-5173}:80"
|
||||
@@ -122,7 +124,8 @@ services:
|
||||
- traefik.http.routers.api.priority=100
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
|
||||
- traefik.http.routers.seo.rule=PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)
|
||||
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
|
||||
- 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.priority=50
|
||||
- traefik.http.routers.seo.service=api
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// تست موقت رندر استاتیک و ابزارها — پس از اجرا حذف میشود
|
||||
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)
|
||||
@@ -1,10 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -9,6 +9,12 @@ The **Pinar** font is installed and served locally. The application no longer re
|
||||
**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
|
||||
* **Projects Map Restoration**: Successfully re-integrated and mounted the interactive project overview map directly at the top of the Projects page to display all project markers.
|
||||
* **Project Details Update**: Revamped and updated the presentation of project details for better clarity and structure.
|
||||
* **Stats Section Relocation**: Moved the statistics (metrics) section higher up in the page layout for better visibility and impact.
|
||||
* **FAQ Section Redesign**: Refactored the FAQ layout into a centered vertical stack with full RTL/LTR responsiveness, featuring an integrated centered header block (title, description, and accent line) and direction-aware (`text-start`) accordion content.
|
||||
* **Newsletter Section Polish**: Updated the newsletter container to a clean solid `bg-copper` background while introducing high-contrast button styling (`bg-ink` default transitioning to white on hover) preserving site-wide kinetic animation logic.
|
||||
* **Article Cards Spacing**: Refined vertical layout spacing and responsive gaps between title, excerpt, and footer blocks without impacting typography scales.
|
||||
* **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.
|
||||
@@ -60,3 +66,11 @@ npm run build
|
||||
* `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/data/site.ts` — Shared TypeScript data models and interfaces.
|
||||
|
||||
```
|
||||
|
||||
حالا اگر بخواهید این تغییرات جدید را هم در پیام کامیت خود بیاورید، میتوانید از این کامیت یکخطی استفاده کنید:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update README with project details revamp, stats section relocation, FAQ layout, and map restoration"
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
@@ -1041,6 +1044,92 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
|
||||
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "26.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
|
||||
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -1973,6 +2062,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2450,6 +2545,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
|
||||
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2783,6 +2887,12 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
|
||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.407",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz",
|
||||
@@ -3157,6 +3267,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -3310,6 +3426,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -3637,6 +3759,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -3650,6 +3778,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3753,6 +3887,39 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.10.0.tgz",
|
||||
"integrity": "sha512-pZIJ1/rsB1jDjT3eBTfMaeSY45lRKdCuZtqXqTnkUrn0UJO9IwtbywT5ym3/cz57MGEELwAwQSYjB/Fj9TlktQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.2.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.1.1",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||
"@maplibre/mlt": "^1.3.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.3",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
@@ -3796,6 +3963,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
@@ -3817,6 +3993,12 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -4042,6 +4224,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4081,6 +4275,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.5.0.tgz",
|
||||
"integrity": "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -4253,6 +4456,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -4413,6 +4622,12 @@
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4444,6 +4659,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -4501,6 +4722,15 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -4623,6 +4853,15 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -4653,6 +4892,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -5046,6 +5294,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npx vite",
|
||||
"build": "npx vite build",
|
||||
"build:map-worker": "esbuild src/map/pmtiles.worker.ts --bundle --format=esm --outfile=public/map/pmtiles-worker.mjs",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"preview": "npx vite preview"
|
||||
},
|
||||
@@ -19,9 +20,12 @@
|
||||
"@tiptap/static-renderer": "^3.31.3",
|
||||
"framer-motion": "^11.5.4",
|
||||
"lucide-react": "0.522.0",
|
||||
"maplibre-gl": "^6.10.0",
|
||||
"pmtiles": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-select": "^5.10.2",
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
10,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
13,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b2 = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b2[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b2[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j2 = b2[i]; j2 < b2[i + 1]; ++j2) {
|
||||
r[j2] = j2 - b2[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b: b2, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x2 = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x2 = (x2 & 52428) >> 2 | (x2 & 13107) << 2;
|
||||
x2 = (x2 & 61680) >> 4 | (x2 & 3855) << 4;
|
||||
rev[i] = ((x2 & 65280) >> 8 | (x2 & 255) << 8) >> 1;
|
||||
}
|
||||
var x2;
|
||||
var i;
|
||||
var hMap = function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v2 = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v2 | (1 << r_1) - 1; v2 <= m; ++v2) {
|
||||
co[rev[v2] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
};
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d2, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d2, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d2[o] | d2[o + 1] << 8 | d2[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v2, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v2.length)
|
||||
e = v2.length;
|
||||
return new u8(v2.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b2 = fleb[i];
|
||||
add = bits(dat, pos, (1 << b2) - 1) + fl[i];
|
||||
pos += b2;
|
||||
}
|
||||
var d2 = dm[bits16(dat, pos) & dms], dsym = d2 >> 4;
|
||||
if (!d2)
|
||||
err(3);
|
||||
pos += d2 & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b2 = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b2) - 1, pos += b2;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var gzs = function(d2) {
|
||||
if (d2[0] != 31 || d2[1] != 139 || d2[2] != 8)
|
||||
err(6, "invalid gzip data");
|
||||
var flg = d2[3];
|
||||
var st = 10;
|
||||
if (flg & 4)
|
||||
st += (d2[10] | d2[11] << 8) + 2;
|
||||
for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d2[st++])
|
||||
;
|
||||
return st + (flg & 2);
|
||||
};
|
||||
var gzl = function(d2) {
|
||||
var l = d2.length;
|
||||
return (d2[l - 4] | d2[l - 3] << 8 | d2[l - 2] << 16 | d2[l - 1] << 24) >>> 0;
|
||||
};
|
||||
var zls = function(d2, dict) {
|
||||
if ((d2[0] & 15) != 8 || d2[0] >> 4 > 7 || (d2[0] << 8 | d2[1]) % 31)
|
||||
err(6, "invalid zlib data");
|
||||
if ((d2[1] >> 5 & 1) == +!dict)
|
||||
err(6, "invalid zlib data: " + (d2[1] & 32 ? "need" : "unexpected") + " dictionary");
|
||||
return (d2[1] >> 3 & 4) + 2;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function gunzipSync(data, opts) {
|
||||
var st = gzs(data);
|
||||
if (st + 8 > data.length)
|
||||
err(6, "invalid gzip data");
|
||||
return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary);
|
||||
}
|
||||
function unzlibSync(data, opts) {
|
||||
return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
function decompressSync(data, opts) {
|
||||
return data[0] == 31 && data[1] == 139 && data[2] == 8 ? gunzipSync(data, opts) : (data[0] & 15) != 8 || data[0] >> 4 > 7 || (data[0] << 8 | data[1]) % 31 ? inflateSync(data, opts) : unzlibSync(data, opts);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// node_modules/pmtiles/dist/esm/index.js
|
||||
var j = Object.defineProperty;
|
||||
var S = Math.pow;
|
||||
var d = (o, t) => j(o, "name", { value: t, configurable: true });
|
||||
var h = (o, t, e) => new Promise((r, n) => {
|
||||
var s = (c) => {
|
||||
try {
|
||||
a(e.next(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, i = (c) => {
|
||||
try {
|
||||
a(e.throw(c));
|
||||
} catch (l) {
|
||||
n(l);
|
||||
}
|
||||
}, a = (c) => c.done ? r(c.value) : Promise.resolve(c.value).then(s, i);
|
||||
a((e = e.apply(o, t)).next());
|
||||
});
|
||||
var re = d((o, t) => {
|
||||
let e = false, r = "", n = L.GridLayer.extend({ createTile: d((s, i) => {
|
||||
let a = document.createElement("img"), c = new AbortController(), l = c.signal;
|
||||
return a.cancel = () => {
|
||||
c.abort();
|
||||
}, e || (o.getHeader().then((u) => {
|
||||
u.tileType === 1 || u.tileType === 6 ? console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details.") : u.tileType === 2 ? r = "image/png" : u.tileType === 3 ? r = "image/jpeg" : u.tileType === 4 ? r = "image/webp" : u.tileType === 5 && (r = "image/avif");
|
||||
}), e = true), o.getZxy(s.z, s.x, s.y, l).then((u) => {
|
||||
if (u) {
|
||||
let m = new Blob([u.data], { type: r }), g = window.URL.createObjectURL(m);
|
||||
a.src = g;
|
||||
} else a.style.display = "none";
|
||||
a.cancel = void 0, i(void 0, a);
|
||||
}).catch((u) => {
|
||||
if (u.name !== "AbortError") throw u;
|
||||
}), a;
|
||||
}, "createTile"), _removeTile: d(function(s) {
|
||||
let i = this._tiles[s];
|
||||
i && (i.el.cancel && i.el.cancel(), i.el.src && window.URL.revokeObjectURL(i.el.src), i.el.width = 0, i.el.height = 0, i.el.deleted = true, L.DomUtil.remove(i.el), delete this._tiles[s], this.fire("tileunload", { tile: i.el, coords: this._keyToTileCoords(s) }));
|
||||
}, "_removeTile") });
|
||||
return new n(t);
|
||||
}, "leafletRasterLayer");
|
||||
var z = d((o) => (t, e) => {
|
||||
if (e instanceof AbortController) return o(t, e);
|
||||
let r = new AbortController();
|
||||
return o(t, r).then((n) => e(void 0, n.data, n.cacheControl || "", n.expires || ""), (n) => e(n)).catch((n) => e(n)), { cancel: d(() => r.abort(), "cancel") };
|
||||
}, "v3compat");
|
||||
var A = class A2 {
|
||||
constructor(t) {
|
||||
this.tilev4 = d((t2, e) => h(this, null, function* () {
|
||||
if (t2.type === "json") {
|
||||
let g = t2.url.substr(10), p = this.tiles.get(g);
|
||||
if (p || (p = new w(g), this.tiles.set(g, p)), this.metadata) {
|
||||
let K = yield p.getTileJson(t2.url);
|
||||
return e.signal.throwIfAborted(), { data: K };
|
||||
}
|
||||
let f = yield p.getHeader();
|
||||
return e.signal.throwIfAborted(), (f.minLon >= f.maxLon || f.minLat >= f.maxLat) && console.error(`Bounds of PMTiles archive ${f.minLon},${f.minLat},${f.maxLon},${f.maxLat} are not valid.`), { data: { tiles: [`${t2.url}/{z}/{x}/{y}`], minzoom: f.minZoom, maxzoom: f.maxZoom, bounds: [f.minLon, f.minLat, f.maxLon, f.maxLat] } };
|
||||
}
|
||||
let r = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/), n = t2.url.match(r);
|
||||
if (!n) throw new Error("Invalid PMTiles protocol URL");
|
||||
let s = n[1], i = this.tiles.get(s);
|
||||
i || (i = new w(s), this.tiles.set(s, i));
|
||||
let a = n[2], c = n[3], l = n[4], u = yield i == null ? void 0 : i.getZxy(+a, +c, +l, e.signal);
|
||||
if (e.signal.throwIfAborted(), u) return { data: new Uint8Array(u.data), cacheControl: u.cacheControl, expires: u.expires };
|
||||
let m = yield i.getHeader();
|
||||
if (m.tileType === 1 || m.tileType === 6) {
|
||||
if (this.errorOnMissingTile) throw new Error("Tile not found.");
|
||||
return { data: new Uint8Array() };
|
||||
}
|
||||
return { data: null };
|
||||
}), "tilev4");
|
||||
this.tile = z(this.tilev4);
|
||||
this.tiles = /* @__PURE__ */ new Map(), this.metadata = (t == null ? void 0 : t.metadata) || false, this.errorOnMissingTile = (t == null ? void 0 : t.errorOnMissingTile) || false;
|
||||
}
|
||||
add(t) {
|
||||
this.tiles.set(t.source.getKey(), t);
|
||||
}
|
||||
get(t) {
|
||||
return this.tiles.get(t);
|
||||
}
|
||||
};
|
||||
d(A, "Protocol");
|
||||
var B = A;
|
||||
function b(o, t) {
|
||||
return (t >>> 0) * 4294967296 + (o >>> 0);
|
||||
}
|
||||
d(b, "toNum");
|
||||
function N(o, t) {
|
||||
let e = t.buf, r = e[t.pos++], n = (r & 112) >> 4;
|
||||
if (r < 128 || (r = e[t.pos++], n |= (r & 127) << 3, r < 128) || (r = e[t.pos++], n |= (r & 127) << 10, r < 128) || (r = e[t.pos++], n |= (r & 127) << 17, r < 128) || (r = e[t.pos++], n |= (r & 127) << 24, r < 128) || (r = e[t.pos++], n |= (r & 1) << 31, r < 128)) return b(o, n);
|
||||
throw new Error("Expected varint not more than 10 bytes");
|
||||
}
|
||||
d(N, "readVarintRemainder");
|
||||
function x(o) {
|
||||
let t = o.buf, e = t[o.pos++], r = e & 127;
|
||||
return e < 128 || (e = t[o.pos++], r |= (e & 127) << 7, e < 128) || (e = t[o.pos++], r |= (e & 127) << 14, e < 128) || (e = t[o.pos++], r |= (e & 127) << 21, e < 128) ? r : (e = t[o.pos], r |= (e & 15) << 28, N(r, o));
|
||||
}
|
||||
d(x, "readVarint");
|
||||
function I(o, t, e, r, n) {
|
||||
return n === 0 ? r !== 0 ? [o - 1 - e, o - 1 - t] : [e, t] : [t, e];
|
||||
}
|
||||
d(I, "rotate");
|
||||
function q(o, t, e) {
|
||||
if (o > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
if (t >= 1 << o || e >= 1 << o) throw new Error("tile x/y outside zoom level bounds");
|
||||
let r = ((1 << o) * (1 << o) - 1) / 3, n = o - 1, [s, i] = [t, e];
|
||||
for (let a = 1 << n; a > 0; a >>= 1) {
|
||||
let c = s & a, l = i & a;
|
||||
r += (3 * c ^ l) * (1 << n), [s, i] = I(a, s, i, c, l), n--;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(q, "zxyToTileId");
|
||||
function G(o) {
|
||||
let t = 3 * o + 1;
|
||||
return t < 4294967296 ? 31 - Math.clz32(t) : 63 - Math.clz32(t / 4294967296);
|
||||
}
|
||||
d(G, "tileIdToZ");
|
||||
function ie(o) {
|
||||
let t = G(o) >> 1;
|
||||
if (t > 26) throw new Error("Tile zoom level exceeds max safe number limit (26)");
|
||||
let e = ((1 << t) * (1 << t) - 1) / 3, r = o - e, n = 0, s = 0, i = 1 << t;
|
||||
for (let a = 1; a < i; a <<= 1) {
|
||||
let c = a & r / 2, l = a & (r ^ c);
|
||||
[n, s] = I(a, n, s, c, l), r = r / 2, n += c, s += l;
|
||||
}
|
||||
return [t, n, s];
|
||||
}
|
||||
d(ie, "tileIdToZxy");
|
||||
var J = ((s) => (s[s.Unknown = 0] = "Unknown", s[s.None = 1] = "None", s[s.Gzip = 2] = "Gzip", s[s.Brotli = 3] = "Brotli", s[s.Zstd = 4] = "Zstd", s))(J || {});
|
||||
function P(o, t) {
|
||||
return h(this, null, function* () {
|
||||
if (t === 1 || t === 0) return o;
|
||||
if (t === 2) {
|
||||
if (typeof globalThis.DecompressionStream == "undefined") return decompressSync(new Uint8Array(o));
|
||||
let e = new Response(o).body;
|
||||
if (!e) throw new Error("Failed to read response stream");
|
||||
let r = e.pipeThrough(new globalThis.DecompressionStream("gzip"));
|
||||
return new Response(r).arrayBuffer();
|
||||
}
|
||||
throw new Error("Compression method not supported");
|
||||
});
|
||||
}
|
||||
d(P, "defaultDecompress");
|
||||
var O = ((a) => (a[a.Unknown = 0] = "Unknown", a[a.Mvt = 1] = "Mvt", a[a.Png = 2] = "Png", a[a.Jpeg = 3] = "Jpeg", a[a.Webp = 4] = "Webp", a[a.Avif = 5] = "Avif", a[a.Mlt = 6] = "Mlt", a))(O || {});
|
||||
function _(o) {
|
||||
return o === 1 ? ".mvt" : o === 2 ? ".png" : o === 3 ? ".jpg" : o === 4 ? ".webp" : o === 5 ? ".avif" : o === 6 ? ".mlt" : "";
|
||||
}
|
||||
d(_, "tileTypeExt");
|
||||
var Y = 127;
|
||||
function Q(o, t) {
|
||||
let e = 0, r = o.length - 1;
|
||||
for (; e <= r; ) {
|
||||
let n = r + e >> 1, s = t - o[n].tileId;
|
||||
if (s > 0) e = n + 1;
|
||||
else if (s < 0) r = n - 1;
|
||||
else return o[n];
|
||||
}
|
||||
return r >= 0 && (o[r].runLength === 0 || t - o[r].tileId < o[r].runLength) ? o[r] : null;
|
||||
}
|
||||
d(Q, "findTile");
|
||||
var T = class T2 {
|
||||
constructor(t) {
|
||||
this.file = t;
|
||||
}
|
||||
getKey() {
|
||||
return this.file.name;
|
||||
}
|
||||
getBytes(t, e) {
|
||||
return h(this, null, function* () {
|
||||
return { data: yield this.file.slice(t, t + e).arrayBuffer() };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(T, "FileSource");
|
||||
var D = class D2 {
|
||||
constructor(t, e = new Headers(), r = void 0) {
|
||||
var a, c;
|
||||
this.url = t, this.customHeaders = e, this.credentials = r, this.mustReload = false;
|
||||
let n = "";
|
||||
"navigator" in globalThis && (n = (c = (a = globalThis.navigator) == null ? void 0 : a.userAgent) != null ? c : "");
|
||||
let s = n.indexOf("Windows") > -1, i = /Chrome|Chromium|Edg|OPR|Brave/.test(n);
|
||||
this.chromeWindowsNoCache = false, s && i && (this.chromeWindowsNoCache = true);
|
||||
}
|
||||
getKey() {
|
||||
return this.url;
|
||||
}
|
||||
setHeaders(t) {
|
||||
this.customHeaders = t;
|
||||
}
|
||||
getBytes(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s, i;
|
||||
r ? i = r : (s = new AbortController(), i = s.signal);
|
||||
let a = new Headers(this.customHeaders);
|
||||
a.set("range", `bytes=${t}-${t + e - 1}`);
|
||||
let c;
|
||||
this.mustReload ? c = "reload" : this.chromeWindowsNoCache && (c = "no-store");
|
||||
let l = yield fetch(this.url, { signal: i, cache: c, headers: a, credentials: this.credentials });
|
||||
if (t === 0 && l.status === 416) {
|
||||
let p = l.headers.get("Content-Range");
|
||||
if (!p || !p.startsWith("bytes */")) throw new Error("Missing content-length on 416 response");
|
||||
let f = +p.substr(8);
|
||||
a.set("range", `bytes=0-${f - 1}`), l = yield fetch(this.url, { signal: i, cache: "reload", headers: a, credentials: this.credentials });
|
||||
}
|
||||
let u = l.headers.get("Etag");
|
||||
if (u != null && u.startsWith("W/") && (u = null), l.status === 416 || n && u && u !== n) throw this.mustReload = true, new v(`Server returned non-matching ETag ${n} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);
|
||||
if (l.status >= 300) throw new Error(`Bad response code: ${l.status}`);
|
||||
let m = l.headers.get("Content-Length");
|
||||
if (l.status === 200 && (!m || +m > e)) throw s && s.abort(), new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");
|
||||
return { data: yield l.arrayBuffer(), etag: u || void 0, cacheControl: l.headers.get("Cache-Control") || void 0, expires: l.headers.get("Expires") || void 0 };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(D, "FetchSource");
|
||||
var E = D;
|
||||
function y(o, t) {
|
||||
let e = o.getUint32(t + 4, true), r = o.getUint32(t + 0, true);
|
||||
return e * S(2, 32) + r;
|
||||
}
|
||||
d(y, "getUint64");
|
||||
function X(o, t) {
|
||||
let e = new DataView(o), r = e.getUint8(7);
|
||||
if (r > 3) throw new Error(`Archive is spec version ${r} but this library supports up to spec version 3`);
|
||||
return { specVersion: r, rootDirectoryOffset: y(e, 8), rootDirectoryLength: y(e, 16), jsonMetadataOffset: y(e, 24), jsonMetadataLength: y(e, 32), leafDirectoryOffset: y(e, 40), leafDirectoryLength: y(e, 48), tileDataOffset: y(e, 56), tileDataLength: y(e, 64), numAddressedTiles: y(e, 72), numTileEntries: y(e, 80), numTileContents: y(e, 88), clustered: e.getUint8(96) === 1, internalCompression: e.getUint8(97), tileCompression: e.getUint8(98), tileType: e.getUint8(99), minZoom: e.getUint8(100), maxZoom: e.getUint8(101), minLon: e.getInt32(102, true) / 1e7, minLat: e.getInt32(106, true) / 1e7, maxLon: e.getInt32(110, true) / 1e7, maxLat: e.getInt32(114, true) / 1e7, centerZoom: e.getUint8(118), centerLon: e.getInt32(119, true) / 1e7, centerLat: e.getInt32(123, true) / 1e7, etag: t };
|
||||
}
|
||||
d(X, "bytesToHeader");
|
||||
function Z(o) {
|
||||
let t = { buf: new Uint8Array(o), pos: 0 }, e = x(t), r = [], n = 0;
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
r.push({ tileId: n + i, offset: 0, length: 0, runLength: 1 }), n += i;
|
||||
}
|
||||
for (let s = 0; s < e; s++) r[s].runLength = x(t);
|
||||
for (let s = 0; s < e; s++) r[s].length = x(t);
|
||||
for (let s = 0; s < e; s++) {
|
||||
let i = x(t);
|
||||
i === 0 && s > 0 ? r[s].offset = r[s - 1].offset + r[s - 1].length : r[s].offset = i - 1;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
d(Z, "deserializeIndex");
|
||||
var R = class R2 extends Error {
|
||||
};
|
||||
d(R, "EtagMismatch");
|
||||
var v = R;
|
||||
function V(o, t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield o.getBytes(0, 16384);
|
||||
if (new DataView(e.data).getUint16(0, true) !== 19792) throw new Error("Wrong magic number for PMTiles archive");
|
||||
let n = e.data.slice(0, Y), s = X(n, e.etag), i = e.data.slice(s.rootDirectoryOffset, s.rootDirectoryOffset + s.rootDirectoryLength), a = `${o.getKey()}|${s.etag || ""}|${s.rootDirectoryOffset}|${s.rootDirectoryLength}`, c = Z(yield t(i, s.internalCompression));
|
||||
return [s, [a, c.length, c]];
|
||||
});
|
||||
}
|
||||
d(V, "getHeaderAndRoot");
|
||||
function F(o, t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = yield o.getBytes(e, r, s, n.etag), a = yield t(i.data, n.internalCompression), c = Z(a);
|
||||
if (c.length === 0) throw new Error("Empty directory is invalid");
|
||||
return c;
|
||||
});
|
||||
}
|
||||
d(F, "getDirectory");
|
||||
var U = class U2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, r.data;
|
||||
let n = yield V(t, this.decompress);
|
||||
return n[1] && this.cache.set(n[1][0], { lastUsed: this.counter++, data: n[1][2] }), this.cache.set(e, { lastUsed: this.counter++, data: n[0] }), this.prune(), n[0];
|
||||
});
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) return a.lastUsed = this.counter++, a.data;
|
||||
let c = yield F(t, this.decompress, e, r, n, s);
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: c }), this.prune(), c;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size > this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
this.cache.delete(t.getKey());
|
||||
});
|
||||
}
|
||||
};
|
||||
d(U, "ResolvedValueCache");
|
||||
var M = class M2 {
|
||||
constructor(t = 100, e = true, r = P) {
|
||||
this.cache = /* @__PURE__ */ new Map(), this.invalidations = /* @__PURE__ */ new Map(), this.pendingFetches = /* @__PURE__ */ new Map(), this.maxCacheEntries = t, this.counter = 1, this.decompress = r;
|
||||
}
|
||||
getHeader(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey(), r = this.cache.get(e);
|
||||
if (r) return r.lastUsed = this.counter++, yield r.data;
|
||||
let n = new Promise((s, i) => {
|
||||
V(t, this.decompress).then((a) => {
|
||||
a[1] && this.cache.set(a[1][0], { lastUsed: this.counter++, data: Promise.resolve(a[1][2]) }), s(a[0]), this.prune();
|
||||
}).catch((a) => {
|
||||
i(a);
|
||||
});
|
||||
});
|
||||
return this.cache.set(e, { lastUsed: this.counter++, data: n }), n;
|
||||
});
|
||||
}
|
||||
trackSignal(t, e, r) {
|
||||
e.refs++, r.addEventListener("abort", () => {
|
||||
--e.refs <= 0 && this.pendingFetches.get(t) === e && (e.controller.abort(), this.cache.delete(t), this.pendingFetches.delete(t));
|
||||
}, { once: true });
|
||||
}
|
||||
getDirectory(t, e, r, n, s) {
|
||||
return h(this, null, function* () {
|
||||
let i = `${t.getKey()}|${n.etag || ""}|${e}|${r}`, a = this.cache.get(i);
|
||||
if (a) {
|
||||
a.lastUsed = this.counter++;
|
||||
let m = this.pendingFetches.get(i);
|
||||
return m && this.trackSignal(i, m, s != null ? s : new AbortController().signal), yield a.data;
|
||||
}
|
||||
let c = new AbortController(), l = { controller: c, refs: 0 };
|
||||
this.trackSignal(i, l, s != null ? s : new AbortController().signal), this.pendingFetches.set(i, l);
|
||||
let u = new Promise((m, g) => {
|
||||
F(t, this.decompress, e, r, n, c.signal).then((p) => {
|
||||
this.pendingFetches.delete(i), m(p), this.prune();
|
||||
}).catch((p) => {
|
||||
g(p);
|
||||
});
|
||||
});
|
||||
return this.cache.set(i, { lastUsed: this.counter++, data: u }), u;
|
||||
});
|
||||
}
|
||||
prune() {
|
||||
if (this.cache.size >= this.maxCacheEntries) {
|
||||
let t = 1 / 0, e;
|
||||
this.cache.forEach((r, n) => {
|
||||
r.lastUsed < t && (t = r.lastUsed, e = n);
|
||||
}), e && this.cache.delete(e);
|
||||
}
|
||||
}
|
||||
invalidate(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = t.getKey();
|
||||
if (this.invalidations.get(e)) return yield this.invalidations.get(e);
|
||||
this.cache.delete(t.getKey());
|
||||
let r = new Promise((n, s) => {
|
||||
this.getHeader(t).then((i) => {
|
||||
n(), this.invalidations.delete(e);
|
||||
}).catch((i) => {
|
||||
s(i);
|
||||
});
|
||||
});
|
||||
this.invalidations.set(e, r);
|
||||
});
|
||||
}
|
||||
};
|
||||
d(M, "SharedPromiseCache");
|
||||
var C = M;
|
||||
var H = class H2 {
|
||||
constructor(t, e, r) {
|
||||
typeof t == "string" ? this.source = new E(t) : this.source = t, r ? this.decompress = r : this.decompress = P, e ? this.cache = e : this.cache = new C();
|
||||
}
|
||||
getHeader() {
|
||||
return h(this, null, function* () {
|
||||
return yield this.cache.getHeader(this.source);
|
||||
});
|
||||
}
|
||||
getZxyAttempt(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
let s = q(t, e, r), i = yield this.cache.getHeader(this.source);
|
||||
if (n == null || n.throwIfAborted(), t < i.minZoom || t > i.maxZoom) return;
|
||||
let a = i.rootDirectoryOffset, c = i.rootDirectoryLength;
|
||||
for (let l = 0; l <= 3; l++) {
|
||||
let u = yield this.cache.getDirectory(this.source, a, c, i, n);
|
||||
n == null || n.throwIfAborted();
|
||||
let m = Q(u, s);
|
||||
if (m) {
|
||||
if (m.runLength > 0) {
|
||||
let g = yield this.source.getBytes(i.tileDataOffset + m.offset, m.length, n, i.etag);
|
||||
return { data: yield this.decompress(g.data, i.tileCompression), cacheControl: g.cacheControl, expires: g.expires };
|
||||
}
|
||||
a = i.leafDirectoryOffset + m.offset, c = m.length;
|
||||
} else return;
|
||||
}
|
||||
throw new Error("Maximum directory depth exceeded");
|
||||
});
|
||||
}
|
||||
getZxy(t, e, r, n) {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getZxyAttempt(t, e, r, n);
|
||||
} catch (s) {
|
||||
if (s instanceof v) return this.cache.invalidate(this.source), yield this.getZxyAttempt(t, e, r, n);
|
||||
throw s;
|
||||
}
|
||||
});
|
||||
}
|
||||
getMetadataAttempt() {
|
||||
return h(this, null, function* () {
|
||||
let t = yield this.cache.getHeader(this.source), e = yield this.source.getBytes(t.jsonMetadataOffset, t.jsonMetadataLength, void 0, t.etag), r = yield this.decompress(e.data, t.internalCompression), n = new TextDecoder("utf-8");
|
||||
return JSON.parse(n.decode(r));
|
||||
});
|
||||
}
|
||||
getMetadata() {
|
||||
return h(this, null, function* () {
|
||||
try {
|
||||
return yield this.getMetadataAttempt();
|
||||
} catch (t) {
|
||||
if (t instanceof v) return this.cache.invalidate(this.source), yield this.getMetadataAttempt();
|
||||
throw t;
|
||||
}
|
||||
});
|
||||
}
|
||||
getTileJson(t) {
|
||||
return h(this, null, function* () {
|
||||
let e = yield this.getHeader(), r = yield this.getMetadata(), n = _(e.tileType);
|
||||
return { tilejson: "3.0.0", scheme: "xyz", tiles: [`${t}/{z}/{x}/{y}${n}`], vector_layers: r.vector_layers, attribution: r.attribution, description: r.description, name: r.name, version: r.version, bounds: [e.minLon, e.minLat, e.maxLon, e.maxLat], center: [e.centerLon, e.centerLat, e.centerZoom], minzoom: e.minZoom, maxzoom: e.maxZoom };
|
||||
});
|
||||
}
|
||||
};
|
||||
d(H, "PMTiles");
|
||||
var w = H;
|
||||
|
||||
// src/map/pmtiles.worker.ts
|
||||
var workerScope = self;
|
||||
workerScope.addProtocol("pmtiles", new B().tile);
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 283 KiB |
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* بهروزرسانی یکبارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO.
|
||||
*
|
||||
* ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» میشود — هم روی دیسک
|
||||
* (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از
|
||||
* موفقیت پشتیبان، پالت تازه نوشته و آپلود میشود.
|
||||
*
|
||||
* چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره
|
||||
* میشوند، نه JSON خام؛ پس تنها راه درست بهروزرسانی، PUT روی همان API است.
|
||||
*
|
||||
* اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیشفرض هیچ تغییری نمیدهد:
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --palette
|
||||
* node --experimental-strip-types scripts/update-map-palette.ts --restore
|
||||
* ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO
|
||||
*
|
||||
* در همهٔ حالتها (جز restore) ترتیب امن رعایت میشود: خواندن و اعتبارسنجی →
|
||||
* پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود.
|
||||
*
|
||||
* کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده میشوند (MINIO_ROOT_USER /
|
||||
* MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نامها.
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* پالت تازه */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** نگاشت دستهها به رنگهای تازهٔ برند — تنها منبع حقیقت این اسکریپت */
|
||||
const PALETTE = {
|
||||
background: '#181512',
|
||||
urban: '#27221c',
|
||||
highway: '#6a5544',
|
||||
street: '#4a3a2d',
|
||||
alley: '#352c25',
|
||||
water: '#172c32',
|
||||
parks: '#20312b',
|
||||
nature: '#314238',
|
||||
mainText: '#ead9c6',
|
||||
subText: '#a99584',
|
||||
borders: '#3e362e',
|
||||
} as const
|
||||
|
||||
type Category = keyof typeof PALETTE
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* قواعد دستهبندی لایهها */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Layer = {
|
||||
id: string
|
||||
type: string
|
||||
'source-layer'?: string
|
||||
paint?: Record<string, unknown>
|
||||
layout?: Record<string, unknown>
|
||||
}
|
||||
type StyleDocument = {
|
||||
version?: number
|
||||
layers?: Layer[]
|
||||
sources?: Record<string, unknown>
|
||||
/** الگوی نشانی گلیفها — باید به پوشهٔ v2.0 باکت اشاره کند */
|
||||
glyphs?: string
|
||||
}
|
||||
|
||||
/** پوشهٔ فونتهای آفلاین در باکت MinIO */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
/** الگوی استاندارد گلیف — جانگهدارها دستنخورده میمانند */
|
||||
const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf'
|
||||
|
||||
/**
|
||||
* اصلاح مسیر گلیفها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به
|
||||
* «v2.0/{fontstack}/{range}.pbf» تبدیل میشود تا با ساختار پوشههای باکت
|
||||
* بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمیکند.
|
||||
*/
|
||||
function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null {
|
||||
const current = (style.glyphs ?? '').trim()
|
||||
const marker = current.indexOf('{fontstack}')
|
||||
const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}`
|
||||
if (next === current) return null
|
||||
style.glyphs = next
|
||||
return { from: current || '(خالی)', to: next }
|
||||
}
|
||||
|
||||
/** آیا مقدار text-field به یک فیلد «نام» اشاره میکند؟ (name، name_en، name:latin و…) */
|
||||
const NAME_REFERENCE = /name(:[a-z-]+)?/i
|
||||
|
||||
/**
|
||||
* فونتهای محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است.
|
||||
* تنها پوشههای Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه
|
||||
* فقط لاتیناند.
|
||||
*/
|
||||
const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold']
|
||||
|
||||
/**
|
||||
* بازگردانی متن برچسبها به قالب دوزبانهٔ اصلی، بهتفکیک لایه.
|
||||
* (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راهها)
|
||||
*/
|
||||
const TEXT_FIELD_RESTORE: Record<string, string> = {
|
||||
water_name: '{name:latin}\n{name:nonlatin}',
|
||||
place_other: '{name:latin}\n{name:nonlatin}',
|
||||
place_suburb: '{name:latin}\n{name:nonlatin}',
|
||||
place_village: '{name:latin}\n{name:nonlatin}',
|
||||
place_town: '{name:latin}\n{name:nonlatin}',
|
||||
place_city: '{name:latin}\n{name:nonlatin}',
|
||||
place_city_large: '{name:latin}\n{name:nonlatin}',
|
||||
place_state: '{name:latin}\n{name:nonlatin}',
|
||||
highway_name_other: '{name:latin} {name:nonlatin}',
|
||||
place_country_other: '{name:latin}',
|
||||
place_country_minor: '{name:latin}',
|
||||
place_country_major: '{name:latin}',
|
||||
}
|
||||
|
||||
/** قالب پیشفرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */
|
||||
const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}'
|
||||
|
||||
function referencesName(value: unknown): boolean {
|
||||
if (typeof value === 'string') return NAME_REFERENCE.test(value)
|
||||
if (Array.isArray(value)) return value.some(referencesName)
|
||||
if (value && typeof value === 'object') return Object.values(value).some(referencesName)
|
||||
return false
|
||||
}
|
||||
|
||||
/** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */
|
||||
function persianFontFor(stack: string[]): string {
|
||||
const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font))
|
||||
if (local) return local
|
||||
return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular'
|
||||
}
|
||||
|
||||
type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } }
|
||||
|
||||
/**
|
||||
* آمادهسازی برچسبها برای فارسی:
|
||||
* ۱) متن به قالب دوزبانهٔ اصلی برمیگردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است).
|
||||
* ۲) پشتهٔ فونت بازچینی میشود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛
|
||||
* چون مپلیبر برای هر پشته فقط یک فایل گلیف میگیرد، همان فونت اول باید هر دو خط
|
||||
* را پوشش دهد و Noto Sans لاتین را هم دارد.
|
||||
*/
|
||||
function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } {
|
||||
const changed: LabelChange[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const layer of style.layers ?? []) {
|
||||
const layout = layer.layout
|
||||
if (layer.type !== 'symbol' || !layout) continue
|
||||
|
||||
const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : []
|
||||
if (!stack.length) continue
|
||||
|
||||
const persianFont = persianFontFor(stack)
|
||||
const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)]
|
||||
const fontChange = { from: stack.join(', '), to: restacked.join(', ') }
|
||||
|
||||
if (!('text-field' in layout)) {
|
||||
// لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح میشود
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, font: fontChange })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!referencesName(layout['text-field'])) {
|
||||
layout['text-font'] = restacked
|
||||
skipped.push(layer.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const from = JSON.stringify(layout['text-field']).slice(0, 48)
|
||||
const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD
|
||||
layout['text-field'] = to
|
||||
layout['text-font'] = restacked
|
||||
changed.push({ id: layer.id, field: { from, to }, font: fontChange })
|
||||
}
|
||||
|
||||
return { changed, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* بررسی فونتها: هر فونتی که در text-font لایههای برچسب استفاده شده باید در
|
||||
* پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست.
|
||||
*/
|
||||
async function verifyFonts(style: StyleDocument): Promise<void> {
|
||||
const fonts = new Set<string>()
|
||||
for (const layer of style.layers ?? []) {
|
||||
if (layer.type !== 'symbol') continue
|
||||
const stack = layer.layout?.['text-font']
|
||||
if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font)
|
||||
}
|
||||
|
||||
if (!fonts.size) {
|
||||
console.log('• فونتی در لایههای برچسب پیدا نشد')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`)
|
||||
const missing: string[] = []
|
||||
for (const font of [...fonts].sort()) {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT)
|
||||
const ok = (await fetch(url, { method: 'HEAD' })).ok
|
||||
if (!ok) missing.push(font)
|
||||
console.log(` ${ok ? '✓' : '✗'} ${font}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
missing.length
|
||||
? `✗ فونتهای ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}`
|
||||
: `✓ همهٔ فونتها در ${GLYPHS_FOLDER} باکت موجودند`,
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بررسی پوشش فونتهای محلی (کدام فونت فارسی را رندر میکند؟) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** بازههای کلیدی گلیف: لاتین پایه، عربی/فارسی، فرمهای نمایشی عربی */
|
||||
const COVERAGE_RANGES = [
|
||||
{ label: 'لاتین', range: '0-255' },
|
||||
{ label: 'فارسی', range: '1536-1791' },
|
||||
{ label: 'فرم فارسی', range: '65024-65279' },
|
||||
] as const
|
||||
|
||||
/** فهرست پوشههای فونت داخل v2.0 باکت */
|
||||
async function listFontFolders(): Promise<string[]> {
|
||||
const response = await fetch(
|
||||
new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT),
|
||||
)
|
||||
const xml = await response.text()
|
||||
const pattern = new RegExp(`<Prefix>${GLYPHS_FOLDER}/([^<]+)/</Prefix>`, 'g')
|
||||
return [...xml.matchAll(pattern)].map((match) => match[1])
|
||||
}
|
||||
|
||||
/** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */
|
||||
async function rangeCoverage(font: string, range: string): Promise<string> {
|
||||
const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT)
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) return 'نیست'
|
||||
const bytes = Number(response.headers.get('content-length') ?? 0)
|
||||
// بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت)
|
||||
return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی'
|
||||
}
|
||||
|
||||
/** گزارش پوشش خطها برای همهٔ فونتهای باکت — ورودی تصمیم انتخاب فونت برچسبها */
|
||||
async function auditFonts(): Promise<void> {
|
||||
const fonts = await listFontFolders()
|
||||
console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`)
|
||||
for (const font of fonts) {
|
||||
const cells: string[] = []
|
||||
for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`)
|
||||
console.log(` ${font.padEnd(26)} ${cells.join(' ')}`)
|
||||
}
|
||||
console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر میکند.')
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا میشود تا مطمئن شویم سند
|
||||
* سالم است و اسکیمای سبک (نسخه، لایهها، منابع) نشکسته است.
|
||||
*/
|
||||
function validateStyle(style: StyleDocument, label: string): void {
|
||||
const problems: string[] = []
|
||||
if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`)
|
||||
if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد')
|
||||
else {
|
||||
const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string')
|
||||
if (broken.length) problems.push(`${broken.length} لایه بدون id/type`)
|
||||
}
|
||||
if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) {
|
||||
problems.push('sources باید یک شیء باشد')
|
||||
}
|
||||
|
||||
console.log(
|
||||
problems.length
|
||||
? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}`
|
||||
: `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`,
|
||||
)
|
||||
if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`)
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
category: Category
|
||||
/** با این الگو روی شناسهٔ لایه تطبیق داده میشود */
|
||||
test: RegExp
|
||||
/** کدام ویژگیهای paint رنگآمیزی شوند */
|
||||
properties: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* جدول قواعد — به ترتیب بررسی میشود و نخستین تطبیق برنده است.
|
||||
* رنگهای داخل عبارتها (interpolate/match) هم بازنویسی میشوند تا پالت
|
||||
* یکدست بماند.
|
||||
*/
|
||||
const RULES: Rule[] = [
|
||||
{ category: 'background', test: /^background$/, properties: ['background-color'] },
|
||||
|
||||
// آب: خود لایههای آب و راههای آبی (برچسبها قاعدهٔ جدا دارند)
|
||||
{ category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// پارکها پیش از طبیعت بررسی میشوند تا چمن/پارک زیر «طبیعت» نرود
|
||||
{ category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// طبیعت: پوشش زمینِ جنگلی/علفزارِ بیرون از شهر
|
||||
{ category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] },
|
||||
|
||||
// کاربری شهری و ساختمانها
|
||||
{ category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] },
|
||||
{ category: 'urban', test: /^building/, properties: ['fill-color'] },
|
||||
|
||||
// سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه
|
||||
{ category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] },
|
||||
{ category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
{ category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] },
|
||||
|
||||
// برچسبهای اصلی: شهر/شهرک و نام بزرگراهها
|
||||
{ category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] },
|
||||
|
||||
// بقیهٔ برچسبها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی
|
||||
{ category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] },
|
||||
]
|
||||
|
||||
/** ویژگیهای فرعی که همیشه با رنگ پسزمینه هماهنگ میشوند تا خوانایی بماند */
|
||||
const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color']
|
||||
/** ویژگیهای آیکون برچسبها که با متن همرنگ میشوند */
|
||||
const ICON_PROPERTIES = ['icon-color']
|
||||
|
||||
const COLOR_PATTERN = /^(#|rgb|hsl)/i
|
||||
const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim())
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* بازنویسی رنگها (توابع خالص) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارتها — را با رنگ تازه عوض میکند */
|
||||
function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set<string> }): unknown {
|
||||
if (isColor(value)) {
|
||||
stats.seen.add(value.trim())
|
||||
stats.replaced += 1
|
||||
return color
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats))
|
||||
if (value && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats)
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] }
|
||||
|
||||
/** یک لایه را بر پایهٔ قواعد رنگآمیزی میکند و گزارش تغییرها را برمیگرداند */
|
||||
function recolorLayer(layer: Layer): LayerReport {
|
||||
const rule = RULES.find((candidate) => candidate.test.test(layer.id))
|
||||
const stats = { replaced: 0, seen: new Set<string>() }
|
||||
|
||||
if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] }
|
||||
|
||||
if (rule) {
|
||||
for (const property of rule.properties) {
|
||||
if (!(property in layer.paint)) continue
|
||||
layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لایههای برچسب: هاله و آیکونها با پالت هماهنگ میشوند
|
||||
if (layer.type === 'symbol') {
|
||||
const textColor = rule?.category === 'mainText' || rule?.category === 'subText'
|
||||
? PALETTE[rule.category]
|
||||
: PALETTE.subText
|
||||
if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) {
|
||||
layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats)
|
||||
}
|
||||
for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) {
|
||||
if (!(property in layer.paint)) continue
|
||||
const target = property === 'icon-color' ? textColor : PALETTE.background
|
||||
layer.paint[property] = paintColor(layer.paint[property], target, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// لبهٔ ساختمانها با رنگ مرزها همخانواده میشود
|
||||
if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) {
|
||||
layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats)
|
||||
}
|
||||
|
||||
return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] }
|
||||
}
|
||||
|
||||
/** کل سند سبک را بازنویسی میکند (بدون دستزدن به ساختار و ویژگیهای غیررنگی) */
|
||||
function recolorStyle(style: StyleDocument): LayerReport[] {
|
||||
const layers = style.layers ?? []
|
||||
return layers.map(recolorLayer)
|
||||
}
|
||||
|
||||
/** گزارش فشردهٔ تغییرهای پالت — بهتفکیک دسته و لایههای دستنخورده */
|
||||
function reportPalette(reports: LayerReport[]): void {
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const report of reports) {
|
||||
if (!report.replaced) continue
|
||||
const category = report.category ?? 'بدوندسته'
|
||||
byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced)
|
||||
}
|
||||
|
||||
console.log('\nگزارش تغییرها:')
|
||||
for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`)
|
||||
}
|
||||
|
||||
const untouched = reports.filter((report) => !report.replaced)
|
||||
if (untouched.length) {
|
||||
console.log(`\nلایههای بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Credentials = { user: string; secret: string }
|
||||
|
||||
async function readCredentials(): Promise<Credentials> {
|
||||
const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')
|
||||
const fromFile: Record<string, string> = {}
|
||||
try {
|
||||
for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Z0-9_]+)=(.*)$/)
|
||||
if (match) fromFile[match[1]] = match[2].trim()
|
||||
}
|
||||
} catch {
|
||||
/* فایل .env اختیاری است — متغیرهای محیطی جایگزین میشوند */
|
||||
}
|
||||
|
||||
const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh'
|
||||
const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD
|
||||
if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)')
|
||||
return { user, secret }
|
||||
}
|
||||
|
||||
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
|
||||
const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest()
|
||||
const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex')
|
||||
|
||||
/** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام میدهند */
|
||||
async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise<void> {
|
||||
const url = new URL(objectPath, endpoint)
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
||||
const dateStamp = amzDate.slice(0, 8)
|
||||
const payloadHash = sha256(body)
|
||||
const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
|
||||
const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
||||
const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
|
||||
const scope = `${dateStamp}/us-east-1/s3/aws4_request`
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n')
|
||||
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request')
|
||||
const authorization =
|
||||
`AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
'x-amz-date': amzDate,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* اجرا */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000'
|
||||
const STYLE_PATH = '/maps/style.json'
|
||||
const BACKUP_PATH = '/maps/style.backup.json'
|
||||
const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette')
|
||||
const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json')
|
||||
|
||||
/** بازگردانی: پشتیبان دقیقاً بهجای style.json نوشته میشود */
|
||||
async function restore(): Promise<void> {
|
||||
const backup = await readFile(LOCAL_BACKUP, 'utf8')
|
||||
validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان')
|
||||
await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials())
|
||||
|
||||
const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text()
|
||||
console.log(
|
||||
restored === backup
|
||||
? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت'
|
||||
: '✗ محتوای بازگرداندهشده با پشتیبان یکسان نیست — دستی بررسی کنید',
|
||||
)
|
||||
}
|
||||
|
||||
const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
const action = ACTIONS.find((flag) => process.argv.includes(flag))
|
||||
|
||||
// پیشفرض امن: بدون پرچم هیچ نوشتنی انجام نمیشود
|
||||
if (!action) {
|
||||
console.log(
|
||||
'کاری انجام نشد — یکی از پرچمها را بدهید:\n' +
|
||||
' --audit-fonts گزارش پوشش خطهای هر فونت در v2.0 (فقط خواندن)\n' +
|
||||
' --fix-labels برچسبهای دوزبانه + پشتهٔ فونت محلی\n' +
|
||||
' --fix-glyphs اصلاح مسیر گلیفها به v2.0\n' +
|
||||
' --palette اعمال پالت تازه\n' +
|
||||
' --restore بازگردانی از پشتیبان\n' +
|
||||
' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--audit-fonts') {
|
||||
await auditFonts()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === '--restore') {
|
||||
await restore()
|
||||
return
|
||||
}
|
||||
|
||||
// ۱) خواندن سبک فعلی
|
||||
const response = await fetch(new URL(STYLE_PATH, ENDPOINT))
|
||||
if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`)
|
||||
const original = await response.text()
|
||||
const style = JSON.parse(original) as StyleDocument
|
||||
console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`)
|
||||
validateStyle(style, 'نسخهٔ فعلی')
|
||||
|
||||
// ۲) پشتیبانگیری، پیش از هر تغییر
|
||||
await mkdir(OUTPUT_DIR, { recursive: true })
|
||||
await writeFile(LOCAL_BACKUP, original, 'utf8')
|
||||
console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`)
|
||||
if (!dryRun) {
|
||||
await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials())
|
||||
console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`)
|
||||
}
|
||||
|
||||
// ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره
|
||||
if (action === '--fix-labels') {
|
||||
const { changed, skipped } = fixLabels(style)
|
||||
console.log(`\nلایههای برچسب اصلاحشده (${changed.length}):`)
|
||||
for (const layer of changed) {
|
||||
if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`)
|
||||
console.log(` فونت: ${layer.font.from} → ${layer.font.to}`)
|
||||
}
|
||||
if (skipped.length) {
|
||||
console.log(`\nلایههای بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`)
|
||||
}
|
||||
await verifyFonts(style)
|
||||
} else if (action === '--fix-glyphs') {
|
||||
const change = fixGlyphsPath(style)
|
||||
console.log(
|
||||
change ? `\nمسیر گلیفها: ${change.from} → ${change.to}` : '\nمسیر گلیفها از قبل به v2.0 اشاره میکند',
|
||||
)
|
||||
await verifyFonts(style)
|
||||
} else {
|
||||
reportPalette(recolorStyle(style))
|
||||
}
|
||||
validateStyle(style, 'نسخهٔ تازه')
|
||||
|
||||
// ۴) نوشتن و آپلود نسخهٔ تازه
|
||||
const updated = JSON.stringify(style, null, 2)
|
||||
await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8')
|
||||
if (dryRun) {
|
||||
console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.')
|
||||
return
|
||||
}
|
||||
|
||||
await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials())
|
||||
console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`)
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('\nخطا:', error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -15,12 +15,35 @@ import { Blog } from './pages/Blog';
|
||||
import { ArticleDetail } from './pages/ArticleDetail';
|
||||
import { FaqPage } from './pages/FaqPage';
|
||||
import { ContactPage } from './pages/ContactPage';
|
||||
import { SiteProvider, useSiteData } from './api';
|
||||
import { SiteProvider, useSiteData, useSite } from './api';
|
||||
import { applySeo, seoKeyForPath } from './seo';
|
||||
import { AdminProvider, useAdmin } from './admin/AdminContext';
|
||||
import { AdminBar } from './admin/AdminBar';
|
||||
import { AdminLoginPage } from './admin/AdminLoginPage';
|
||||
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() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -45,17 +68,9 @@ function AnimatedRoutes() {
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
<Route path="/admin/write" element={<ArticleWritePage />} />
|
||||
<Route path="/admin/write/:id" element={<ArticleWritePage />} />
|
||||
<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 />} />
|
||||
{FA_ROUTES}
|
||||
<Route path="*" element={<Home />} />
|
||||
{EN_ROUTES}
|
||||
</Routes>
|
||||
</motion.main>
|
||||
</AnimatePresence>);
|
||||
@@ -65,12 +80,13 @@ function AnimatedRoutes() {
|
||||
function AppChrome() {
|
||||
const { pathname } = useLocation();
|
||||
const { isAuthenticated } = useAdmin();
|
||||
const { data } = useSiteData();
|
||||
const data = useSite();
|
||||
const { lang } = useLang();
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
|
||||
// عنوان و توضیح متای هر صفحه از دیتابیس (سئو) — جز صفحههای جزئیات که خودشان ست میکنند
|
||||
useEffect(() => {
|
||||
if (isAdminPage || !data) return;
|
||||
if (isAdminPage) return;
|
||||
const key = seoKeyForPath(pathname);
|
||||
if (!key) return;
|
||||
const meta = data.seo_meta?.find((s) => s.page_key === key);
|
||||
@@ -80,8 +96,9 @@ function AppChrome() {
|
||||
title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand),
|
||||
description: meta?.description ?? '',
|
||||
path: pathname,
|
||||
lang,
|
||||
});
|
||||
}, [pathname, data, isAdminPage]);
|
||||
}, [pathname, data, isAdminPage, lang]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -96,46 +113,46 @@ function AppChrome() {
|
||||
|
||||
function AppShell() {
|
||||
const { data, error, reload } = useSiteData();
|
||||
const { t, dir, lang } = useLang();
|
||||
|
||||
// overflow-x-clip جای overflow-x-hidden: اسکرولافزار نمیسازد، پس position: sticky
|
||||
// (فهرست مطالب مقاله) به صفحه میچسبد و با محتوا پایین نمیرود؛ برش افقی هم حفظ میشود.
|
||||
return (
|
||||
<div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-clip bg-ink font-pinar text-ivory">
|
||||
<Preloader />
|
||||
{error ? (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-2xl font-bold text-ivory">اتصال به سرور برقرار نشد</h1>
|
||||
<h1 className="text-2xl font-bold text-ivory">{t('errorTitle')}</h1>
|
||||
<p className="mt-4 max-w-md text-sm font-light leading-8 text-ivory/70">
|
||||
محتوای سایت از سرور بارگذاری میشود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.
|
||||
{t('errorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
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>
|
||||
</div>
|
||||
) : data ? (
|
||||
<BrowserRouter>
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<AppChrome />
|
||||
</BrowserRouter>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('dir', 'rtl');
|
||||
document.documentElement.setAttribute('lang', 'fa');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteProvider>
|
||||
<AdminProvider>
|
||||
<AppShell />
|
||||
<BrowserRouter>
|
||||
<LangProvider>
|
||||
<AppShell />
|
||||
</LangProvider>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</SiteProvider>
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
/** نوار شناور ادمین — فقط برای کاربر واردشده رندر میشود */
|
||||
export function AdminBar() {
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout } = useAdmin()
|
||||
const { isEditMode, toggleEditMode, saveStatus, logout, editLang, toggleEditLang } = useAdmin()
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -161,6 +161,19 @@ export function AdminBar() {
|
||||
{isEditMode ? 'حالت ویرایش فعال' : 'حالت ویرایش خاموش'}
|
||||
</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={`h-1.5 w-1.5 rounded-full ${STATUS_COLOR[saveStatus]}`} aria-hidden="true" />
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { ReactNode } from 'react'
|
||||
import Swal from 'sweetalert2'
|
||||
import type { SiteData } from '../api'
|
||||
import { useSiteData } from '../api'
|
||||
import { adminApi, getToken, HttpError, setToken } from './adminClient'
|
||||
import { adminApi, getToken, HttpError, setToken, AUTH_CHANGE_EVENT } from './adminClient'
|
||||
import { ENTITIES, isTranslatable } from './entities'
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'error'
|
||||
/** زبان ویرایش محتوا — رابط مدیریتی فارسی میماند؛ این فقط تعیین میکند کدام ستون زبان ذخیره شود */
|
||||
export type EditLang = 'fa' | 'en'
|
||||
|
||||
interface AdminContextValue {
|
||||
isAuthenticated: boolean
|
||||
@@ -16,6 +19,9 @@ interface AdminContextValue {
|
||||
saveStatus: SaveStatus
|
||||
/** اجرای یک عملیات ذخیره — وضعیت نوار ادمین را بهروز میکند و خطاها را با دکمه تلاش دوباره نشان میدهد */
|
||||
runSave: (action: () => Promise<void>) => Promise<boolean>
|
||||
/** زبان محتوایی که در حالت ویرایش ذخیره میشود (فارسی یا انگلیسی) */
|
||||
editLang: EditLang
|
||||
toggleEditLang: () => void
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminContextValue | null>(null)
|
||||
@@ -54,6 +60,9 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(
|
||||
() => 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 { reload } = useSiteData()
|
||||
|
||||
@@ -64,6 +73,14 @@ 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) => {
|
||||
await adminApi.login(username, password)
|
||||
setIsAuthenticated(true)
|
||||
@@ -112,6 +129,18 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
return () => document.removeEventListener('click', onClick, true)
|
||||
}, [isEditMode])
|
||||
|
||||
// توکن بیرون از این provider هم پاک میشود (پاسخ 401 بارگذاری سایت، خروج در زبانه
|
||||
// دیگر) — وضعیت ورود همگام میماند و نوار ادمین برای نشست مرده باقی نمیماند
|
||||
useEffect(() => {
|
||||
const syncAuth = () => setIsAuthenticated(!!getToken())
|
||||
window.addEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.addEventListener('storage', syncAuth)
|
||||
return () => {
|
||||
window.removeEventListener(AUTH_CHANGE_EVENT, syncAuth)
|
||||
window.removeEventListener('storage', syncAuth)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// در حین ذخیرهسازی، از بستهشدن تصادفی صفحه جلوگیری میشود
|
||||
useEffect(() => {
|
||||
if (saveStatus !== 'saving') return
|
||||
@@ -131,8 +160,10 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
logout,
|
||||
saveStatus,
|
||||
runSave,
|
||||
editLang,
|
||||
toggleEditLang,
|
||||
}),
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave],
|
||||
[isAuthenticated, isEditMode, toggleEditMode, login, logout, saveStatus, runSave, editLang, toggleEditLang],
|
||||
)
|
||||
|
||||
return <AdminContext.Provider value={value}>{children}</AdminContext.Provider>
|
||||
@@ -141,9 +172,14 @@ export function AdminProvider({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* پرکاربردترین کمکساز ویرایش: یک فیلد واحد را PATCH میکند و پس از موفقیت
|
||||
* همان تغییر را بهصورت خوشبینانه در درخت داده سایت اعمال میکند.
|
||||
*
|
||||
* در حالت ویرایش انگلیسی، کلیدهای قابل ترجمه به پسوند _en نگاشت میشوند؛ چون
|
||||
* برخی mutatorهای کامپوننتها نام فیلد را جداگانه مینویسند، مقدار قبلی فیلدهای
|
||||
* فارسی اسنپشات میشود و پس از اجرای mutator بازگردانده میشود تا متن انگلیسی
|
||||
* هرگز جای متن فارسی در درخت خام ننشیند.
|
||||
*/
|
||||
export function useFieldSaver() {
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { updateSite } = useSiteData()
|
||||
|
||||
return useCallback(
|
||||
@@ -154,9 +190,50 @@ export function useFieldSaver() {
|
||||
apply: (data: SiteData) => void,
|
||||
): Promise<boolean> =>
|
||||
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)
|
||||
updateSite(apply)
|
||||
updateSite((d) => {
|
||||
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],
|
||||
[runSave, updateSite, editLang],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* نام فیلد متناظر با زبان ویرایش جاری — در حالت انگلیسی فیلدهای قابل ترجمه
|
||||
* پسوند _en میگیرند. برای جاهایی که نام فیلد جدا از شیء تغییرات ذخیره میشود.
|
||||
*/
|
||||
export function useEditField() {
|
||||
const { editLang } = useAdmin()
|
||||
return useCallback(
|
||||
(resource: string, field: string) =>
|
||||
editLang === 'en' && isTranslatable(resource, field) ? `${field}_en` : field,
|
||||
[editLang],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import type { Article, ArticleBodyDoc } from '../data/site'
|
||||
|
||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as ArticleBodyDoc
|
||||
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc }
|
||||
/** پیشنویس صفحه نوشتن — همان شکل Article با بدنه ساختاریافته دو زبانه */
|
||||
type Draft = Omit<Article, 'body'> & { body: ArticleBodyDoc; body_en: ArticleBodyDoc }
|
||||
|
||||
/**
|
||||
* فیلدهای متادیتای مقاله که با React Hook Form مدیریت میشوند:
|
||||
@@ -65,7 +65,7 @@ const STATUS_TEXT: Record<string, string> = {
|
||||
* مسیرها: /admin/write (مقاله جدید) و /admin/write/:id (ویرایش).
|
||||
*/
|
||||
export function ArticleWritePage() {
|
||||
const { isAuthenticated, saveStatus, runSave } = useAdmin()
|
||||
const { isAuthenticated, saveStatus, runSave, editLang, toggleEditLang } = useAdmin()
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { data, updateSite } = useSiteData()
|
||||
@@ -78,8 +78,11 @@ export function ArticleWritePage() {
|
||||
id: 0,
|
||||
slug: `article-${Date.now()}`,
|
||||
title: '',
|
||||
title_en: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
date_en: '',
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
category_en: '',
|
||||
tags: [],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
@@ -87,8 +90,11 @@ export function ArticleWritePage() {
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
read_en: '',
|
||||
excerpt: '',
|
||||
excerpt_en: '',
|
||||
body: EMPTY_DOC,
|
||||
body_en: EMPTY_DOC,
|
||||
status: 'draft',
|
||||
seo_keyphrase: '',
|
||||
})
|
||||
@@ -147,11 +153,16 @@ export function ArticleWritePage() {
|
||||
// وقتی مقاله موجود شناخته شد، فرم و شناسه ساختهشده یکبار مقداردهی میشود
|
||||
useEffect(() => {
|
||||
if (!article || createdRef.current) return
|
||||
const rawEnBody = (article as unknown as { body_en?: unknown }).body_en
|
||||
setDraft({
|
||||
...article,
|
||||
tags: normalizeTags(article.tags),
|
||||
...(article as unknown as Draft),
|
||||
tags: normalizeTags((article as unknown as { tags?: unknown }).tags),
|
||||
// بدنه ذخیرهشده (سند جدید یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
body: toArticleBodyDoc(article.body),
|
||||
body_en:
|
||||
rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody)
|
||||
? (rawEnBody as ArticleBodyDoc)
|
||||
: EMPTY_DOC,
|
||||
})
|
||||
setCreatedId(article.id)
|
||||
// بدنه ویرایشگر با محتوای بارگذاریشده همگام میشود
|
||||
@@ -259,6 +270,8 @@ export function ArticleWritePage() {
|
||||
})
|
||||
|
||||
const metaLength = (draft.meta_description ?? '').trim().length
|
||||
// در زبان انگلیسی، فیلدهای متنی به ستونهای _en میروند؛ اسلاگ/تصویر/وضعیت مشترکاند
|
||||
const suf = <N extends string>(name: N) => `${name}${editLang === 'en' ? '_en' : ''}` as keyof Draft
|
||||
|
||||
// متن جایگزین تصویر شاخص — با watch('title') بهصورت زنده ساخته میشود
|
||||
const imageAltFallback = [watchedTitle.trim(), FEATURED_IMAGE_ALT_SUFFIX]
|
||||
@@ -280,6 +293,18 @@ export function ArticleWritePage() {
|
||||
</Link>
|
||||
|
||||
<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">
|
||||
{saveStatus === 'idle' && <CheckIcon className="h-3.5 w-3.5 text-emerald-400" />}
|
||||
{STATUS_TEXT[saveStatus]}
|
||||
@@ -314,7 +339,7 @@ export function ArticleWritePage() {
|
||||
{draft.status === 'draft' ? 'انتشار در سایت' : 'تبدیل به پیشنویس'}
|
||||
</button>
|
||||
<Link
|
||||
to={`/blog/${draft.slug}`}
|
||||
to={`${editLang === 'en' ? '/en' : ''}/blog/${draft.slug}`}
|
||||
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"
|
||||
>
|
||||
@@ -345,7 +370,8 @@ export function ArticleWritePage() {
|
||||
: 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال میشود'}
|
||||
</p>
|
||||
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند */}
|
||||
{/* عنوان — با Controller به فرم وصل است تا watch('title') زنده بماند؛
|
||||
در ویرایش انگلیسی همان مقدار در ستون title_en ذخیره میشود */}
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
@@ -355,9 +381,9 @@ export function ArticleWritePage() {
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
queueSave({ title: event.target.value })
|
||||
queueSave({ [suf('title')]: event.target.value })
|
||||
}}
|
||||
placeholder="عنوان مقاله…"
|
||||
placeholder={editLang === 'en' ? draft.title || 'English article title…' : 'عنوان مقاله…'}
|
||||
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"
|
||||
/>
|
||||
@@ -390,14 +416,19 @@ export function ArticleWritePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* متن */}
|
||||
{/* متن — ویرایشگر روی سند زبان جاری کار میکند */}
|
||||
<div className="mt-8">
|
||||
<ArticleEditor
|
||||
contentKey={loadedContentKey}
|
||||
doc={draft.body as unknown as Record<string, unknown>}
|
||||
doc={(editLang === 'en' ? draft.body_en : draft.body) as unknown as Record<string, unknown>}
|
||||
onChange={(json) => {
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود (۲۰۰ واژه در دقیقه)
|
||||
queueSave({ body: json, read: formatReadingTime(json) })
|
||||
// زمان مطالعه بهصورت پویا از متن سند محاسبه میشود — به زبان جاری
|
||||
const minutes = formatReadingTime(json)
|
||||
if (editLang === 'en') {
|
||||
queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` })
|
||||
} else {
|
||||
queueSave({ body: json, read: `${minutes} دقیقه` })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -457,8 +488,8 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">چکیده (روی کارت مقاله نمایش داده میشود)</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
{...textField('excerpt')}
|
||||
placeholder="خلاصه کوتاه مقاله برای کارتهای وبلاگ…"
|
||||
{...textField(suf('excerpt'))}
|
||||
placeholder={editLang === 'en' ? draft.excerpt || 'English excerpt…' : 'خلاصه کوتاه مقاله برای کارتهای وبلاگ…'}
|
||||
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>
|
||||
@@ -521,9 +552,13 @@ export function ArticleWritePage() {
|
||||
<label className="text-xs font-light text-ivory/50">زمان مطالعه (محاسبه خودکار)</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
{...textField('read')}
|
||||
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"
|
||||
disabled={editLang !== 'en'}
|
||||
{...(editLang === 'en' ? textField('read_en') : { value: String(draft.read ?? '') })}
|
||||
className={`mt-2 w-full text-sm font-light ${
|
||||
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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Swal from 'sweetalert2'
|
||||
import { useSiteData } from '../api'
|
||||
import { useAdmin, useFieldSaver } from './AdminContext'
|
||||
import { adminApi } from './adminClient'
|
||||
import { editableFields } from './entities'
|
||||
import { toArticleBodyDoc } from '../utils/tiptapDoc'
|
||||
import type { EntityConfig, FieldConfig } from './entities'
|
||||
import { ImageField } from '../components/admin/ImageField'
|
||||
@@ -23,11 +24,16 @@ type EntityDrawerProps = {
|
||||
|
||||
export function EntityDrawer({ config, item, onClose, deletable = false }: EntityDrawerProps) {
|
||||
const save = useFieldSaver()
|
||||
const { runSave } = useAdmin()
|
||||
const { runSave, editLang } = useAdmin()
|
||||
const { reload } = useSiteData()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const flushRef = useRef<() => void>(() => {})
|
||||
|
||||
// در ویرایش انگلیسی فقط فیلدهای قابل ترجمه با نام انگلیسی رندر میشوند؛
|
||||
// متن فارسی همان فیلد بهعنوان جاینگهدار نشان داده میشود تا مترجم زمینه را ببیند
|
||||
const fields = editableFields(config, editLang)
|
||||
const faBase = (name: string) => name.replace(/_en$/, '')
|
||||
|
||||
const closeWithFlush = () => {
|
||||
flushRef.current()
|
||||
onClose()
|
||||
@@ -114,7 +120,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="grid gap-6">
|
||||
{config.fields.map((field) => {
|
||||
{fields.map((field) => {
|
||||
const isImage = field.type === 'image'
|
||||
// فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt
|
||||
const altKey = isImage
|
||||
@@ -135,6 +141,7 @@ export function EntityDrawer({ config, item, onClose, deletable = false }: Entit
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={item[field.name]}
|
||||
placeholder={editLang === 'en' ? item[faBase(field.name)] : undefined}
|
||||
altValue={altKey ? String(item[altKey] ?? '') : undefined}
|
||||
altFallback={imageAltFallback}
|
||||
imageTitleValue={imageTitleKey ? String(item[imageTitleKey] ?? '') : undefined}
|
||||
@@ -179,6 +186,7 @@ function FieldControl({
|
||||
value,
|
||||
altValue,
|
||||
altFallback,
|
||||
placeholder,
|
||||
imageTitleValue,
|
||||
onSave,
|
||||
onSaveAlt,
|
||||
@@ -190,6 +198,8 @@ function FieldControl({
|
||||
/** مقدار Alt تصویر — فقط برای فیلدهای image */
|
||||
altValue?: string
|
||||
altFallback?: string
|
||||
/** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی بهعنوان جاینگهدار نمایش داده میشود */
|
||||
placeholder?: unknown
|
||||
/** مقدار Title تصویر — فقط برای فیلدهای image */
|
||||
imageTitleValue?: string
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
@@ -269,14 +279,22 @@ function FieldControl({
|
||||
}
|
||||
|
||||
if (field.type === 'wysiwyg') {
|
||||
return <WysiwygField field={field} value={value} onSave={onSave} registerFlush={registerFlush} />
|
||||
return (
|
||||
<WysiwygField
|
||||
field={field}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
registerFlush={registerFlush}
|
||||
hint={placeholder ? 'خالی = بازدیدکننده متن فارسی را میبیند' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'list') {
|
||||
return field.ui === 'tags' ? (
|
||||
<TagsField field={field} value={value} onSave={onSave} />
|
||||
) : (
|
||||
<ListField field={field} value={value} onSave={onSave} />
|
||||
<ListField field={field} value={value} onSave={onSave} hint={placeholder ? 'خالی = بازدیدکننده فهرست فارسی را میبیند' : undefined} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,6 +305,7 @@ function FieldControl({
|
||||
<textarea
|
||||
dir="auto"
|
||||
rows={4}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -303,6 +322,7 @@ function FieldControl({
|
||||
<input
|
||||
dir={field.type === 'number' ? 'ltr' : 'auto'}
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
placeholder={placeholder ? String(placeholder) : undefined}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commitText}
|
||||
@@ -345,10 +365,12 @@ function ListField({
|
||||
field,
|
||||
value,
|
||||
onSave,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
hint?: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<string[]>(Array.isArray(value) ? (value as string[]) : [])
|
||||
|
||||
@@ -361,6 +383,7 @@ function ListField({
|
||||
<div>
|
||||
<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>}
|
||||
{hint && <p className="mt-1 text-[10px] font-light text-copper/70">{hint}</p>}
|
||||
<div className="mt-2 grid gap-2">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
@@ -408,11 +431,13 @@ function WysiwygField({
|
||||
value,
|
||||
onSave,
|
||||
registerFlush,
|
||||
hint,
|
||||
}: {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
onSave: (value: unknown) => Promise<boolean>
|
||||
registerFlush?: (fn: () => void) => void
|
||||
hint?: string
|
||||
}) {
|
||||
const [doc, setDoc] = useState<Record<string, unknown>>(
|
||||
// بدنه ذخیرهشده (سند جدید، رشته JSON یا پاراگرافهای قدیمی) به سند قابل ویرایش تبدیل میشود
|
||||
@@ -457,6 +482,7 @@ function WysiwygField({
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -3,10 +3,14 @@ import { API_URL } from '../config'
|
||||
|
||||
const TOKEN_KEY = 'aramesh_admin_token'
|
||||
|
||||
/** رویداد تغییر وضعیت ورود — لایههای دیگر (مثل نوار ادمین) با آن همگام میمانند */
|
||||
export const AUTH_CHANGE_EVENT = 'aramesh:auth-change'
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string | null): void => {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
window.dispatchEvent(new CustomEvent(AUTH_CHANGE_EVENT))
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
|
||||
@@ -14,6 +14,8 @@ export type FieldConfig = {
|
||||
creatable?: boolean
|
||||
/** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */
|
||||
ui?: 'lines' | 'tags'
|
||||
/** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمیشوند */
|
||||
shared?: boolean
|
||||
/** پسوند پیشنهاد Alt تصویر — مثل «تصویر شاخص» در کنار عنوان رکورد */
|
||||
altFallbackSuffix?: string
|
||||
}
|
||||
@@ -33,13 +35,15 @@ const F = (
|
||||
label: string,
|
||||
type: FieldType = 'text',
|
||||
hint?: string,
|
||||
extra?: Partial<FieldConfig>,
|
||||
sharedOrExtra?: boolean | Partial<FieldConfig>,
|
||||
): FieldConfig => ({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
hint,
|
||||
...extra,
|
||||
...(typeof sharedOrExtra === 'object' && sharedOrExtra !== null
|
||||
? sharedOrExtra
|
||||
: { shared: !!sharedOrExtra }),
|
||||
})
|
||||
|
||||
/** دستهبندیهای استاندارد مقاله — با react-select در کشو و صفحه نوشتن نمایش داده میشوند */
|
||||
@@ -55,16 +59,16 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
services: {
|
||||
resource: 'services', dataKey: 'services', title: 'خدمات', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape')],
|
||||
fields: [F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('icon', 'آیکون', 'text', 'draft / tower / mall / villa / renew / manage / invest / landscape', true)],
|
||||
defaults: { title: 'خدمت جدید', desc: '', icon: 'draft' },
|
||||
},
|
||||
projects: {
|
||||
resource: 'projects', dataKey: 'projects', title: 'پروژهها', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)'), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress'),
|
||||
F('image', 'تصویر شاخص', 'image'), F('gallery', 'گالری (هر خط یک URL)', 'list'),
|
||||
F('title', 'عنوان'), F('slug', 'نشانی (slug)', 'text', undefined, true), F('location', 'موقعیت'),
|
||||
F('year', 'سال'), F('status', 'وضعیت', 'text', 'done یا progress', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, true), F('gallery', 'گالری (هر خط یک URL)', 'list', undefined, true),
|
||||
F('area', 'زیربنا'), F('floors', 'طبقات'), F('units', 'واحدها'), F('client', 'کارفرما'),
|
||||
F('summary', 'خلاصه', 'textarea'), F('body', 'متن پروژه (هر پاراگراف یک خط)', 'list'),
|
||||
],
|
||||
@@ -76,31 +80,31 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
fields: [
|
||||
F('title', 'عنوان'),
|
||||
F('body', 'متن مقاله', 'wysiwyg'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود'),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags' }),
|
||||
F('author', 'نویسنده'),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date'),
|
||||
F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'),
|
||||
F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده میشود', true),
|
||||
F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }),
|
||||
F('category', 'دستهبندی (Category)', 'select', 'میتوانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }),
|
||||
F('tags', 'هشتگها / برچسبها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }),
|
||||
F('author', 'نویسنده', 'text', undefined, true),
|
||||
F('date', 'تاریخ انتشار (شمسی)', 'date', undefined, true),
|
||||
F('read', 'زمان مطالعه (دقیقه)'),
|
||||
F('slug', 'نشانی (slug)'),
|
||||
F('slug', 'نشانی (slug)', 'text', undefined, true),
|
||||
],
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
defaults: {
|
||||
title: 'مقاله جدید',
|
||||
slug: `article-${Date.now()}`,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
category: ARTICLE_CATEGORIES[0],
|
||||
tags: ['مدیریت پروژه', 'ساختوساز'],
|
||||
image: '',
|
||||
image_alt: '',
|
||||
image_title: '',
|
||||
author: '',
|
||||
meta_description: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
read: '۵ دقیقه',
|
||||
excerpt: '',
|
||||
seo_keyphrase: '',
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
body: { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
@@ -124,13 +128,13 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
timeline: {
|
||||
resource: 'timeline', dataKey: 'timeline', title: 'تایملاین', kind: 'collection',
|
||||
titleKey: 'title',
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image')],
|
||||
fields: [F('year', 'سال'), F('title', 'عنوان'), F('desc', 'توضیح', 'textarea'), F('image', 'تصویر', 'image', undefined, true)],
|
||||
defaults: { year: '۱۴۰۰', title: 'رویداد جدید', desc: '', image: '' },
|
||||
},
|
||||
stats: {
|
||||
resource: 'stats', dataKey: 'stats', title: 'آمار', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number'), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
fields: [F('label', 'برچسب'), F('value', 'عدد', 'number', undefined, true), F('suffix', 'پسوند (مثل + یا هزار)')],
|
||||
defaults: { label: 'شاخص جدید', value: 0, suffix: '' },
|
||||
},
|
||||
why_us: {
|
||||
@@ -160,20 +164,20 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
gallery: {
|
||||
resource: 'gallery', dataKey: 'gallery', title: 'گالری تصاویر', kind: 'collection',
|
||||
titleKey: 'alt',
|
||||
fields: [F('src', 'تصویر', 'image'), F('alt', 'متن جایگزین')],
|
||||
fields: [F('src', 'تصویر', 'image', undefined, true), F('alt', 'متن جایگزین')],
|
||||
defaults: { src: '', alt: 'تصویر گالری' },
|
||||
},
|
||||
nav: {
|
||||
resource: 'nav', dataKey: 'nav', title: 'منوی ناوبری', kind: 'collection',
|
||||
titleKey: 'label',
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)')],
|
||||
fields: [F('label', 'عنوان'), F('href', 'مسیر (مثل /about)', 'text', undefined, true)],
|
||||
defaults: { label: 'آیتم جدید', href: '/' },
|
||||
},
|
||||
seo_meta: {
|
||||
resource: 'seo-meta', dataKey: 'seo_meta', title: 'سئوی صفحه', kind: 'collection',
|
||||
titleKey: 'page_key',
|
||||
fields: [
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact'),
|
||||
F('page_key', 'کلید صفحه', 'text', 'home / about / services / projects / investment / blog / faq / contact', true),
|
||||
F('title', 'عنوان سئو (عنوان تب مرورگر و گوگل)'),
|
||||
F('description', 'توضیح سئو (متا دیسکریپشن)', 'textarea'),
|
||||
],
|
||||
@@ -182,25 +186,25 @@ export const ENTITIES: Record<string, EntityConfig> = {
|
||||
brand: {
|
||||
resource: 'brand', dataKey: 'brand', title: 'برند و تماس', kind: 'singleton',
|
||||
titleKey: 'name',
|
||||
fields: [F('name', 'نام برند'), F('logo', 'لوگو', 'image'), F('hero', 'تصویر هیرو', 'image'), F('contact_bg', 'پسزمینه بخش تماس', 'image'), F('phone', 'تلفن نمایشی'), F('phone_href', 'لینک تلفن'), F('email', 'ایمیل'), F('address', 'نشانی', 'textarea')],
|
||||
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')],
|
||||
defaults: {},
|
||||
},
|
||||
hero: {
|
||||
resource: 'hero', dataKey: 'hero', title: 'تنظیمات هیرو', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
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')],
|
||||
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')],
|
||||
defaults: {},
|
||||
},
|
||||
story: {
|
||||
resource: 'story', dataKey: 'story', title: 'تصاویر بخش درباره ما', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
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', 'لینک دکمه')],
|
||||
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)],
|
||||
defaults: {},
|
||||
},
|
||||
before_after: {
|
||||
resource: 'before-after', dataKey: 'before_after', title: 'بخش قبل/بعد', kind: 'singleton',
|
||||
titleKey: 'title',
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image'), F('after', 'تصویر بعد', 'image'), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
fields: [F('title', 'عنوان'), F('lead', 'توضیح', 'textarea'), F('before', 'تصویر قبل', 'image', undefined, true), F('after', 'تصویر بعد', 'image', undefined, true), F('before_label', 'برچسب قبل'), F('after_label', 'برچسب بعد')],
|
||||
defaults: {},
|
||||
},
|
||||
contact_section: {
|
||||
@@ -231,6 +235,22 @@ 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 }) {
|
||||
return data[config.dataKey as keyof SiteData] as never
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Article, Project, Service } from './data/site'
|
||||
import { API_URL } from './config'
|
||||
import { getToken } from './admin/adminClient'
|
||||
import { getToken, setToken } from './admin/adminClient'
|
||||
import { localizeSite } from './i18n/localize'
|
||||
import { useLang } from './i18n/lang'
|
||||
|
||||
// انواع دادههایی که بکاند در GET /api/site برمیگرداند
|
||||
export type BrandInfo = {
|
||||
id: number
|
||||
name: string
|
||||
name_en?: string
|
||||
logo: string
|
||||
hero: string
|
||||
contact_bg: string
|
||||
@@ -15,105 +18,157 @@ export type BrandInfo = {
|
||||
phone_href: string
|
||||
email: string
|
||||
address: string
|
||||
address_en?: string
|
||||
}
|
||||
|
||||
export type TestimonialItem = { id: number; name: string; role: string; quote: string }
|
||||
export type FaqItem = { id: number; question: string; answer: string }
|
||||
export type TeamMemberItem = { id: number; name: string; role: string; bio: string }
|
||||
export type TimelineItem = { id: number; year: string; title: string; desc: string; image: string }
|
||||
export type StatItem = { id: number; value: number; suffix: string; label: string }
|
||||
export type WhyUsItem = { id: number; title: string; desc: string }
|
||||
export type TestimonialItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; quote: string; quote_en?: string }
|
||||
export type FaqItem = { id: number; question: string; question_en?: string; answer: string; answer_en?: string }
|
||||
export type TeamMemberItem = { id: number; name: string; name_en?: string; role: string; role_en?: string; bio: string; bio_en?: 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 StatItem = { id: number; value: number; suffix: string; suffix_en?: string; label: string; label_en?: string }
|
||||
export type WhyUsItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type BeforeAfterInfo = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
before: string
|
||||
after: string
|
||||
before_label: string
|
||||
before_label_en?: string
|
||||
after_label: string
|
||||
after_label_en?: string
|
||||
}
|
||||
|
||||
export type NavItem = { id: number; label: string; href: string }
|
||||
export type NavItem = { id: number; label: string; label_en?: string; href: string }
|
||||
|
||||
export type HeroContent = {
|
||||
id: number
|
||||
kicker: string
|
||||
kicker_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
title_highlight: string
|
||||
title_highlight_en?: string
|
||||
subtitle: string
|
||||
subtitle_en?: string
|
||||
badge_value: string
|
||||
badge_value_en?: string
|
||||
badge_label: string
|
||||
badge_label_en?: string
|
||||
cta_primary_label: string
|
||||
cta_primary_label_en?: string
|
||||
cta_primary_href: string
|
||||
cta_secondary_label: string
|
||||
cta_secondary_label_en?: string
|
||||
cta_secondary_href: string
|
||||
scroll_label: string
|
||||
scroll_label_en?: string
|
||||
}
|
||||
|
||||
export type StoryContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
paragraphs: string[]
|
||||
paragraphs_en?: string[] | null
|
||||
quote: string
|
||||
quote_en?: string
|
||||
quote_author: string
|
||||
quote_author_en?: string
|
||||
stat1_value: string
|
||||
stat1_value_en?: string
|
||||
stat1_label: string
|
||||
stat1_label_en?: string
|
||||
stat2_value: string
|
||||
stat2_value_en?: string
|
||||
stat2_label: string
|
||||
stat2_label_en?: string
|
||||
link_label: string
|
||||
link_label_en?: string
|
||||
link_href: string
|
||||
bento: { src: string; alt: string }[]
|
||||
bento_en?: { src: string; alt: string }[] | null
|
||||
}
|
||||
|
||||
export type SectionHeadingContent = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
image: string
|
||||
cta_label: string
|
||||
cta_label_en?: string
|
||||
cta_href: string
|
||||
}
|
||||
|
||||
export type PageHeroContent = { id: number; key: string; title: string; lead: string; image: string }
|
||||
export type PageHeroContent = { id: number; key: string; title: string; title_en?: string; lead: string; lead_en?: string; image: string }
|
||||
|
||||
export type InvestmentModelItem = { id: number; title: string; desc: string; points: string[] }
|
||||
export type ProcessStepItem = { id: number; step: string; title: string; desc: string }
|
||||
export type OfficeItem = { id: number; city: string; address: string; hours: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string }
|
||||
export type InvestmentModelItem = { id: number; title: string; title_en?: string; desc: string; desc_en?: string; points: string[]; points_en?: string[] | null }
|
||||
export type ProcessStepItem = { id: number; step: string; step_en?: string; title: string; title_en?: string; desc: string; desc_en?: string }
|
||||
export type OfficeItem = { id: number; city: string; city_en?: string; address: string; address_en?: string; hours: string; hours_en?: string }
|
||||
export type GalleryImageItem = { id: number; src: string; alt: string; alt_en?: string }
|
||||
export type StatusLabelItem = { id: number; key: string; label: string; label_en?: string }
|
||||
|
||||
export type ContactSectionContent = {
|
||||
id: number
|
||||
label: string
|
||||
label_en?: string
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
address_label: string
|
||||
address_label_en?: string
|
||||
phone_label: string
|
||||
phone_label_en?: string
|
||||
email_label: string
|
||||
email_label_en?: string
|
||||
name_label: string
|
||||
name_label_en?: string
|
||||
name_placeholder: string
|
||||
name_placeholder_en?: string
|
||||
phone_field_label: string
|
||||
phone_field_label_en?: string
|
||||
phone_placeholder: string
|
||||
phone_placeholder_en?: string
|
||||
note_label: string
|
||||
note_label_en?: string
|
||||
note_optional_label: string
|
||||
note_optional_label_en?: string
|
||||
note_placeholder: string
|
||||
note_placeholder_en?: string
|
||||
submit_label: string
|
||||
submit_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_title: string
|
||||
success_title_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
success_button_label: string
|
||||
success_button_label_en?: string
|
||||
}
|
||||
|
||||
export type NewsletterContent = {
|
||||
id: number
|
||||
title: string
|
||||
title_en?: string
|
||||
lead: string
|
||||
lead_en?: string
|
||||
placeholder: string
|
||||
placeholder_en?: string
|
||||
button_label: string
|
||||
button_label_en?: string
|
||||
sending_label: string
|
||||
sending_label_en?: string
|
||||
success_text: string
|
||||
success_text_en?: string
|
||||
}
|
||||
|
||||
export type SocialItem = { label: string; href: string; icon: string }
|
||||
@@ -121,12 +176,19 @@ export type SocialItem = { label: string; href: string; icon: string }
|
||||
export type FooterContent = {
|
||||
id: number
|
||||
about_text: string
|
||||
about_text_en?: string
|
||||
pages_heading: string
|
||||
pages_heading_en?: string
|
||||
services_heading: string
|
||||
services_heading_en?: string
|
||||
contact_heading: string
|
||||
contact_heading_en?: string
|
||||
copyright: string
|
||||
copyright_en?: string
|
||||
tagline: string
|
||||
tagline_en?: string
|
||||
socials: SocialItem[]
|
||||
socials_en?: SocialItem[] | null
|
||||
}
|
||||
|
||||
export interface SiteData {
|
||||
@@ -154,7 +216,7 @@ export interface SiteData {
|
||||
contact_section: ContactSectionContent
|
||||
newsletter: NewsletterContent
|
||||
footer: FooterContent
|
||||
seo_meta: { id: number; page_key: string; title: string; description: string }[]
|
||||
seo_meta: { id: number; page_key: string; title: string; title_en?: string; description: string; description_en?: string }[]
|
||||
}
|
||||
|
||||
interface SiteContextValue {
|
||||
@@ -168,10 +230,16 @@ interface SiteContextValue {
|
||||
const SiteContext = createContext<SiteContextValue>({
|
||||
data: null,
|
||||
error: false,
|
||||
// پیشفرضهای بیاثر — مصرفکننده بیرون از Provider هم بدون خطا رندر میشود
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
reload: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
updateSite: () => {},
|
||||
})
|
||||
|
||||
/** سقف انتظار داده سایت — سرور بیپاسخ یعنی صفحه خطا با دکمه تلاش دوباره، نه انتظار بیپایان */
|
||||
const SITE_TIMEOUT_MS = 15_000
|
||||
|
||||
export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const [data, setData] = useState<SiteData | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
@@ -179,7 +247,9 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const load = useCallback(() => {
|
||||
let cancelled = false
|
||||
setError(false)
|
||||
fetch(`${API_URL}/api/site`)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), SITE_TIMEOUT_MS)
|
||||
fetch(`${API_URL}/api/site`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<SiteData>
|
||||
@@ -193,14 +263,17 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
const res = await fetch(`${API_URL}/api/admin/articles`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
if (res.status === 401) {
|
||||
// توکن منقضی/نامعتبر — پاک میشود تا بارگذاریهای بعدی درخواست بیفایده نفرستند
|
||||
setToken(null)
|
||||
} else if (res.ok) {
|
||||
const all = (await res.json()) as Article[]
|
||||
const published = new Set(json.articles.map((a) => a.id))
|
||||
const drafts = all.filter((a) => a.status === 'draft' && !published.has(a.id))
|
||||
json.articles = [...json.articles, ...drafts]
|
||||
}
|
||||
} catch {
|
||||
/* بدون پیشنویسها ادامه بده */
|
||||
/* بدون پیشنویسها ادامه بده — بارگذاری سایت هرگز وابسته به پنل مدیریتی نیست */
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
@@ -213,6 +286,7 @@ export function SiteProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -244,13 +318,16 @@ export function useSiteData(): SiteContextValue {
|
||||
return useContext(SiteContext)
|
||||
}
|
||||
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند
|
||||
// فقط در کامپوننتهایی استفاده شود که پس از بارگذاری کامل دادهها رندر میشوند.
|
||||
// خروجی بومیسازیشده است — فیلدهای انگلیسی در زبان انگلیسی جایگزین فارسی میشوند (با برگشت به فارسی).
|
||||
// داده خام برای ویرایش مدیریتی از useSiteData() خوانده میشود.
|
||||
export function useSite(): SiteData {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
if (!data) {
|
||||
throw new Error('useSite باید پس از بارگذاری دادههای سایت استفاده شود')
|
||||
}
|
||||
return data
|
||||
return localizeSite(data, lang)
|
||||
}
|
||||
|
||||
const EMPTY_HEADING: SectionHeadingContent = {
|
||||
@@ -267,19 +344,25 @@ const EMPTY_HEADING: SectionHeadingContent = {
|
||||
/** تیتر و توضیح یک بخش بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function useSectionHeading(key: string): SectionHeadingContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.section_headings.find((s) => s.key === key) ?? { ...EMPTY_HEADING, key }
|
||||
}
|
||||
|
||||
/** هیرو بالای صفحه بر اساس key تعریفشده در پنل مدیریت */
|
||||
export function usePageHero(key: string): PageHeroContent {
|
||||
const { data } = useContext(SiteContext)
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return (
|
||||
data?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
localized?.page_heroes.find((p) => p.key === key) ?? { id: 0, key, title: '', lead: '', image: '' }
|
||||
)
|
||||
}
|
||||
|
||||
/** برچسب فارسی وضعیت پروژه (done | progress) از پنل مدیریت */
|
||||
/** برچسب وضعیت پروژه (done | progress) به زبان جاری — از پنل مدیریت */
|
||||
export function useStatusLabel(key: string): string {
|
||||
const { data } = useContext(SiteContext)
|
||||
return data?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
const { lang } = useLang()
|
||||
const localized = data ? localizeSite(data, lang) : null
|
||||
return localized?.status_labels.find((s) => s.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
@@ -3,49 +3,51 @@ import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon, ClockIcon } from 'lucide-react'
|
||||
import type { Article } from '../data/site'
|
||||
import { formatJalali } from '../utils/date'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
export function ArticleCard({ article }: { article: Article }) {
|
||||
const { t, href } = useLang()
|
||||
return (
|
||||
<Link
|
||||
to={`/blog/${article.slug}`}
|
||||
to={href(`/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"
|
||||
aria-label={`خواندن مقاله ${article.title}`}
|
||||
aria-label={`${t('readArticlePrefix')} ${article.title}`}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={article.image}
|
||||
alt={`تصویر مقاله ${article.title}`}
|
||||
alt={`${t('articleImagePrefix')} ${article.title}`}
|
||||
className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
{/* برچسب دستهبندی کپسولی شیشهای روی تصویر */}
|
||||
<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)]">
|
||||
<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)]">
|
||||
{article.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<h3 className="text-lg font-bold leading-8 text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
<h3 className="mb-4 md:mb-6 text-xl font-bold leading-relaxed text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{article.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-3 mb-8 text-sm font-light leading-7 text-ivory/60 line-clamp-3">
|
||||
<p className="mb-6 md:mb-10 text-[15px] font-light leading-loose text-ivory/60 line-clamp-3">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-5 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-3">
|
||||
<div className="mt-auto flex items-center justify-between border-t border-white/10 pt-6 md:pt-8 text-xs font-light text-ivory/50">
|
||||
<span className="flex items-center gap-4">
|
||||
<span>{formatJalali(article.date)}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<ClockIcon className="h-3.5 w-3.5 text-copper/80" />
|
||||
{toPersianDigits(article.read)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* دکمه کپسولی ادامه مطلب با افکت شیشهای */}
|
||||
<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)]">
|
||||
ادامه مطلب
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<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 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_15px_rgba(197,143,92,0.5)] rtl:hover:[&>svg]:-translate-x-1 ltr:hover:[&>svg]:translate-x-1">
|
||||
{t('readMore')}
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Variant = 'copper' | 'outline' | 'dark' | 'dark-ivory'
|
||||
|
||||
type BaseProps = {
|
||||
children: React.ReactNode
|
||||
variant?: Variant
|
||||
className?: string
|
||||
}
|
||||
|
||||
// استایلهای فوقلوکس شیشهای (Glassmorphic) با رنگهای اختصاصی پروژه
|
||||
const STYLES: Record<Variant, { base: string; fill: string; text: string; glow: string }> = {
|
||||
copper: {
|
||||
// دکمه اصلی: شیشه دودی تیره با خط نوری مسی، هاله بازتاب و حاشیه کریستالی
|
||||
base: 'bg-char/75 backdrop-blur-xl text-ivory border border-copper/50 shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_8px_20px_rgba(0,0,0,0.4)]',
|
||||
fill: 'bg-gradient-to-r from-copper via-[#d89f6e] to-copper',
|
||||
text: 'group-hover:text-char font-semibold',
|
||||
glow: 'hover:border-copper hover:shadow-[0_0_30px_rgba(197,143,92,0.5)]',
|
||||
},
|
||||
outline: {
|
||||
// دکمه ثانویه: شیشه دودی کاملاً مات (Frosted Smoked Glass) با بازتاب لبهها
|
||||
base: 'bg-white/[0.04] backdrop-blur-xl border border-white/20 text-ivory shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_8px_20px_rgba(0,0,0,0.3)]',
|
||||
fill: 'bg-white/10',
|
||||
text: 'group-hover:text-copper',
|
||||
glow: 'hover:border-copper/70 hover:bg-white/[0.08] hover:shadow-[0_0_25px_rgba(197,143,92,0.25)]',
|
||||
},
|
||||
dark: {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-copper',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:border-copper/50 hover:shadow-[0_0_20px_rgba(197,143,92,0.3)]',
|
||||
},
|
||||
'dark-ivory': {
|
||||
base: 'bg-ink/85 backdrop-blur-xl text-ivory border border-white/10 shadow-[inset_0_1px_1px_rgba(255,255,255,0.1)]',
|
||||
fill: 'bg-ivory',
|
||||
text: 'group-hover:text-char',
|
||||
glow: 'hover:shadow-[0_0_20px_rgba(255,255,255,0.2)]',
|
||||
},
|
||||
}
|
||||
|
||||
function Inner({ children, variant = 'copper' }: { children: React.ReactNode; variant?: Variant }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<>
|
||||
{/* خط انعکاس نوری شیشه در بالای دکمه */}
|
||||
<span className="pointer-events-none absolute -top-1/2 left-0 right-0 h-full rounded-full bg-gradient-to-b from-white/20 to-transparent" />
|
||||
|
||||
{/* انیمیشن پر شدن موجی و نرم */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 translate-y-full rounded-full transition-transform duration-500 ease-out group-hover:translate-y-0 ${style.fill}`}
|
||||
/>
|
||||
|
||||
{/* متن و آیکون دکمه */}
|
||||
<span
|
||||
className={`relative z-10 flex items-center justify-center gap-3 transition-colors duration-400 ease-out ${style.text}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// بدنه اصلی دکمه: کپسول کامل (rounded-full)، کلیک نرم (active:scale-95) و انتقال شیشهای
|
||||
const SHELL =
|
||||
'group relative inline-flex items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-sm font-medium tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-copper focus-visible:ring-offset-2 focus-visible:ring-offset-ink active:scale-[0.98] transition-all duration-400 ease-out cursor-pointer select-none'
|
||||
|
||||
export function ButtonLink({
|
||||
to,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
}: BaseProps & { to: string }) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<Link to={to} className={`${SHELL} ${style.base} ${style.glow} ${className}`}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ButtonAnchor({
|
||||
href,
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & { href: string } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<a href={href} className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'copper',
|
||||
className = '',
|
||||
...rest
|
||||
}: BaseProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const style = STYLES[variant]
|
||||
return (
|
||||
<button className={`${SHELL} ${style.base} ${style.glow} ${className}`} {...rest}>
|
||||
<Inner variant={variant}>{children}</Inner>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react'
|
||||
import { CheckCircle2Icon, MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from './Reveal'
|
||||
import { Button } from './Button'
|
||||
import { PremiumButton } from './PremiumButton'
|
||||
import { useSite } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
@@ -11,6 +12,7 @@ import { API_URL } from '../config'
|
||||
|
||||
export function ContactSection() {
|
||||
const { brand, contact_section: content } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [form, setForm] = useState({ name: '', phone: '', note: '' })
|
||||
const [sent, setSent] = useState(false)
|
||||
@@ -34,7 +36,7 @@ export function ContactSection() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -62,13 +64,13 @@ export function ContactSection() {
|
||||
const phoneValue = form.phone.trim()
|
||||
|
||||
if (nameValue.length < 3) {
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).')
|
||||
showErrorAlert(t('incompleteInfo'), t('nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const phoneRegex = /^[\d۰-۹\+\-\s]{8,15}$/
|
||||
if (!phoneValue || !phoneRegex.test(phoneValue)) {
|
||||
showErrorAlert('شماره تلفن نامعتبر است', 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.')
|
||||
showErrorAlert(t('phoneInvalidTitle'), t('phoneInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,14 +85,14 @@ export function ContactSection() {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در ثبت درخواست', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('formCheck')
|
||||
showErrorAlert(t('submitErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
setSent(true)
|
||||
} catch {
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -192,17 +194,15 @@ export function ContactSection() {
|
||||
<p className="mt-3 max-w-sm text-sm font-light leading-8 text-ivory/70">
|
||||
<InlineEdit multiline value={content.success_text} onSave={(v) => saveContent('success_text', v)} />
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-8 font-light"
|
||||
<PremiumButton
|
||||
onClick={() => {
|
||||
setSent(false)
|
||||
setForm({ name: '', phone: '', note: '' })
|
||||
}}
|
||||
className="mt-8 font-light"
|
||||
>
|
||||
<InlineEdit value={content.success_button_label} onSave={(v) => saveContent('success_button_label', v)} />
|
||||
</Button>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
|
||||
@@ -232,7 +232,7 @@ export function ContactSection() {
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
placeholder={content.phone_placeholder}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -251,21 +251,17 @@ export function ContactSection() {
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start">
|
||||
<Button
|
||||
<PremiumButton
|
||||
type="submit"
|
||||
variant="copper"
|
||||
className="w-full md:w-auto md:px-12 flex items-center justify-center gap-3"
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? (
|
||||
content.sending_label
|
||||
) : (
|
||||
<>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
</>
|
||||
<InlineEdit value={content.submit_label} onSave={(v) => saveContent('submit_label', v)} />
|
||||
)}
|
||||
</Button>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { PhoneIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
|
||||
export function FloatingContact() {
|
||||
const { brand } = useSite();
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={brand.phone_href}
|
||||
aria-label="تماس تلفنی با گروه ساختمانی آرامش"
|
||||
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"
|
||||
aria-label={t('callBrand')}
|
||||
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"
|
||||
>
|
||||
{/* پالس هاله نوری در پسزمینه مانند رادار */}
|
||||
<span
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpIcon, InstagramIcon, LinkedinIcon, MailIcon, MapPinIcon, PhoneIcon, SendIcon } from 'lucide-react';
|
||||
import { useSite } from '../api';
|
||||
import { useLang } from '../i18n/lang';
|
||||
import { useFieldSaver } from '../admin/AdminContext';
|
||||
import { InlineEdit } from './admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../admin/Editable';
|
||||
@@ -14,6 +15,7 @@ const SOCIAL_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
||||
|
||||
export function Footer() {
|
||||
const { brand, services, nav, footer } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveFooter = (field: string, value: string) =>
|
||||
@@ -38,7 +40,7 @@ export function Footer() {
|
||||
<div className="lg:col-span-4">
|
||||
<div className="relative w-fit">
|
||||
<img src={brand.logo} alt={brand.name} className="h-20 w-auto object-contain" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند، لوگو و تماس" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
<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)} />
|
||||
@@ -70,7 +72,7 @@ export function Footer() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<Link
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className="inline-block text-sm font-light text-ivory/60 transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<InlineEdit
|
||||
@@ -136,7 +138,7 @@ export function Footer() {
|
||||
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"
|
||||
>
|
||||
<span>بازگشت به بالا</span>
|
||||
<span>{t('backToTop')}</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">
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
@@ -2,12 +2,15 @@ import React, { useEffect, useState } from "react";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
import { MenuIcon, PhoneIcon, XIcon } from "lucide-react";
|
||||
import { useSite } from "../api";
|
||||
import { useLang } from "../i18n/lang";
|
||||
import { useFieldSaver } from "../admin/AdminContext";
|
||||
import { InlineEdit } from "./admin/InlineEdit";
|
||||
import { EditableItem, SingletonPill } from "../admin/Editable";
|
||||
import { LangToggle } from "./LangToggle";
|
||||
|
||||
export function Header() {
|
||||
const { brand, nav } = useSite();
|
||||
const { t, href } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
const saveNav = (id: number, changes: Record<string, unknown>) =>
|
||||
@@ -40,7 +43,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="relative flex items-center">
|
||||
<Link
|
||||
to="/"
|
||||
to={href("/")}
|
||||
className="flex items-center gap-3"
|
||||
aria-label={brand.name}
|
||||
>
|
||||
@@ -52,16 +55,16 @@ export function Header() {
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute -left-10 top-1/2 -translate-y-1/2" />
|
||||
<SingletonPill resource="brand" label="ویرایش برند و لوگو" className="absolute top-1/2 -translate-y-1/2 ltr:-right-10 rtl:-left-10" />
|
||||
</div>
|
||||
|
||||
<nav aria-label="ناوبری اصلی" className="hidden xl:block">
|
||||
<nav aria-label={t("mainNav")} className="hidden xl:block">
|
||||
<ul className="flex items-center gap-7">
|
||||
{nav.map((item) => (
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
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)] ${
|
||||
isActive ? "text-copper font-normal" : "text-ivory/80"
|
||||
@@ -102,12 +105,15 @@ export function Header() {
|
||||
<span dir="ltr" className="tracking-wide">{brand.phone}</span>
|
||||
</a>
|
||||
|
||||
{/* تغییر زبان — فارسی/انگلیسی */}
|
||||
<LangToggle />
|
||||
|
||||
{/* دکمه منوی موبایل دایرهای شیشهای */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
aria-label={open ? "بستن منو" : "باز کردن منو"}
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
@@ -121,7 +127,7 @@ export function Header() {
|
||||
|
||||
{open && (
|
||||
<nav
|
||||
aria-label="ناوبری موبایل"
|
||||
aria-label={t("mobileNav")}
|
||||
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">
|
||||
@@ -129,7 +135,7 @@ export function Header() {
|
||||
<EditableItem key={item.id} resource="nav" item={item}>
|
||||
<li>
|
||||
<NavLink
|
||||
to={item.href}
|
||||
to={href(item.href)}
|
||||
className={({ isActive }) =>
|
||||
`block border-b border-ivory/5 py-3 text-base font-light transition-colors duration-200 ease-lux ${
|
||||
isActive ? "text-copper" : "text-ivory/80"
|
||||
@@ -149,4 +155,4 @@ export function Header() {
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
|
||||
|
||||
/**
|
||||
* تنظیمات ثابت هر زبان.
|
||||
* ترتیب اینجا = ترتیب نمایش در کپسول (فارسی سمت چپ، English سمت راست).
|
||||
* بجای مقادیر پیکسلی، از translate-x-full استفاده شده تا دقیقاً به اندازه عرض خودش (50%) جابجا شود.
|
||||
*/
|
||||
const LANGS = {
|
||||
fa: { label: 'فارسی', sliderTranslate: 'translate-x-0' },
|
||||
en: { label: 'English', sliderTranslate: 'translate-x-full' },
|
||||
} as const
|
||||
|
||||
type LangKey = keyof typeof LANGS
|
||||
|
||||
/** دکمه تغییر زبان — بین نسخه فارسی و /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')}
|
||||
dir="ltr"
|
||||
className={[
|
||||
// ── Capsule shell ──────────────────────────────────────────────
|
||||
'relative flex items-center',
|
||||
'w-[152px] h-[42px] rounded-full p-1', // استفاده از p-1 (4px) به جای [4px]
|
||||
'bg-[#1a1715]',
|
||||
// deep inset shadow → recessed track feel
|
||||
'shadow-[inset_0_3px_7px_rgba(0,0,0,0.75),inset_0_-1px_2px_rgba(255,255,255,0.04)]',
|
||||
// interaction
|
||||
'cursor-pointer select-none',
|
||||
'active:scale-[0.975] transition-transform duration-150 ease-lux',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
{/* ── Inner Track ───────────────────────────────────────────────
|
||||
این دیو داخلی باعث میشود پدینگ دکمه اصلی روی محاسبات 50 درصدی تاثیر نگذارد
|
||||
*/}
|
||||
<div className="relative w-full h-full flex items-center">
|
||||
|
||||
{/* ── Slider pill ─────────────────────────────────────────────── */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'absolute top-0 left-0', // چسبیده به لبههای Track داخلی
|
||||
'w-1/2 h-full rounded-full', // دقیقاً 50 درصد عرض و کل ارتفاع
|
||||
'bg-[#27221c] border border-[#d5874c]',
|
||||
// subtle raised shadow — tactile without being flashy
|
||||
'shadow-[0_2px_6px_rgba(0,0,0,0.55),0_0_0_1px_rgba(213,135,76,0.18),inset_0_1px_1px_rgba(255,255,255,0.06)]',
|
||||
'transition-transform duration-[380ms] ease-lux',
|
||||
LANGS[lang as LangKey].sliderTranslate,
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
{/* ── Labels ──────────────────────────────────────────────────── */}
|
||||
<span className="relative z-10 flex w-full h-full">
|
||||
{(Object.keys(LANGS) as LangKey[]).map((key) => {
|
||||
const isActive = lang === key
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={[
|
||||
// each label occupies exactly half the capsule
|
||||
'w-1/2 h-full flex justify-center items-center',
|
||||
'text-[11.5px] font-semibold tracking-wide leading-none',
|
||||
'transition-colors duration-[380ms] ease-lux',
|
||||
isActive ? 'text-[#d5874c]' : 'text-[#6a6662]',
|
||||
].join(' ')}
|
||||
>
|
||||
{LANGS[key].label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { MdClose, MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||
|
||||
type Props = {
|
||||
images: string[]
|
||||
@@ -26,8 +26,8 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
if (!isOpen) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight') prev()
|
||||
if (e.key === 'ArrowLeft') next()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -43,55 +43,67 @@ export function Lightbox({ images, index, onClose, onChange }: Props) {
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-5 md:p-10"
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center p-4 md:p-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{/* Solid Brand Background */}
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/90 backdrop-blur-md"
|
||||
className="absolute inset-0 bg-[#27221c]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Close Button at Top Right */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute left-6 top-6 z-10 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 md:top-10"
|
||||
className="absolute right-6 top-6 z-10 flex h-10 w-10 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
<MdClose size={24} />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="relative max-h-[80vh] w-auto max-w-full object-contain"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center gap-4 md:bottom-10">
|
||||
{/* Main Content Area */}
|
||||
<div className="relative z-10 flex w-full max-w-7xl items-center justify-center gap-4 md:gap-8">
|
||||
|
||||
{/* Prev Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
className="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="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper focus:outline-none focus:ring-2 focus:ring-copper"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
<MdChevronRight size={28} />
|
||||
</button>
|
||||
<span className="text-sm font-light text-ivory/70" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
|
||||
{/* Image */}
|
||||
<motion.img
|
||||
key={index}
|
||||
src={images[index as number]}
|
||||
alt=""
|
||||
className="max-h-[85vh] w-auto max-w-[calc(100%-7rem)] rounded-md object-contain shadow-2xl"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
/>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="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="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-all duration-300 ease-lux hover:scale-105 hover:bg-[#c57941] focus:outline-none focus:ring-2 focus:ring-copper "
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
<MdChevronLeft size={28} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 z-10 flex items-center justify-center">
|
||||
<span className="text-sm font-light text-copper" dir="ltr">
|
||||
{(index as number) + 1} / {images.length}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ChevronLeftIcon } from 'lucide-react'
|
||||
import { usePageHero } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { useFieldSaver } from '../admin/AdminContext'
|
||||
import { SingletonPill } from '../admin/Editable'
|
||||
import { InlineEdit } from './admin/InlineEdit'
|
||||
@@ -15,6 +16,7 @@ type PageHeroProps = {
|
||||
|
||||
export function PageHero({ pageKey }: PageHeroProps) {
|
||||
const hero = usePageHero(pageKey)
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveField = (field: 'title' | 'lead' | 'image', value: string) =>
|
||||
@@ -36,11 +38,11 @@ export function PageHero({ pageKey }: PageHeroProps) {
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-site px-6 pb-16 lg:px-10">
|
||||
<nav aria-label="مسیر صفحه" className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to="/" className="transition-colors hover:text-copper">
|
||||
صفحه اصلی
|
||||
<nav aria-label={t('breadcrumbAria')} className="flex items-center gap-2 text-xs font-light text-ivory/50">
|
||||
<Link to={href('/')} className="transition-colors hover:text-copper">
|
||||
{t('home')}
|
||||
</Link>
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5" />
|
||||
<ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" />
|
||||
<span className="text-copper">{hero.title}</span>
|
||||
</nav>
|
||||
<motion.h1
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useSiteData } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
// برندینگ پیش از رسیدن پاسخ سرور — پس از بارگذاری دادهها از دیتابیس استفاده میشود
|
||||
const FALLBACK_LOGO = '/images/logo.png'
|
||||
const FALLBACK_NAME = 'گروه ساختمانی آرامش'
|
||||
|
||||
export function Preloader() {
|
||||
const { data } = useSiteData()
|
||||
const { t } = useLang()
|
||||
const [done, setDone] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const logo = data?.brand.logo || FALLBACK_LOGO
|
||||
const name = data?.brand.name || FALLBACK_NAME
|
||||
const name = data?.brand.name || t('brandFallback')
|
||||
|
||||
useEffect(() => {
|
||||
const start = Date.now()
|
||||
@@ -43,7 +44,7 @@ export function Preloader() {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
aria-live="polite"
|
||||
aria-label="در حال بارگذاری"
|
||||
aria-label={t('loading')}
|
||||
>
|
||||
<div className="relative h-36 w-36">
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
type Props = {
|
||||
to?: string
|
||||
href?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium Button replicating the cinematic, high-end documentary aesthetic.
|
||||
* Features an exact cubic-bezier background fill from the bottom, transforming
|
||||
* text color smoothly to maintain contrast.
|
||||
*/
|
||||
export function PremiumButton({ to, href, children, className = '', onClick, disabled, type = 'button' }: Props) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Primary: ink, Secondary: copper
|
||||
const baseClasses = `
|
||||
group/btn relative inline-flex items-center justify-center overflow-hidden
|
||||
rounded-full px-10 py-4 text-sm font-bold tracking-widest text-copper
|
||||
border border-copper bg-ink
|
||||
transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg
|
||||
cursor-pointer select-none
|
||||
${className}
|
||||
`.trim()
|
||||
|
||||
const innerEffect = (
|
||||
<>
|
||||
<span className="absolute inset-0 z-0 bg-copper scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||
{children}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) onClick();
|
||||
if (to) {
|
||||
e.preventDefault();
|
||||
navigate(to);
|
||||
}
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} className={baseClasses} onClick={handleClick}>
|
||||
{innerEffect}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} disabled={disabled} className={baseClasses} onClick={handleClick}>
|
||||
{innerEffect}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +1,89 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { useStatusLabel } from '../api'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { PremiumButton } from './PremiumButton'
|
||||
import type { StaticProject } from '../data/projectsData'
|
||||
import type { ProjectCardItem } from '../data/site'
|
||||
|
||||
export function ProjectCard({ project }: any) {
|
||||
type ProjectCardProps = {
|
||||
project: StaticProject | ProjectCardItem;
|
||||
imageUrl?: string;
|
||||
detailHref?: string;
|
||||
}
|
||||
|
||||
export function ProjectCard({ project, imageUrl, detailHref }: ProjectCardProps) {
|
||||
const isOngoing = project.status === 'progress'
|
||||
const statusText = useStatusLabel(project.status)
|
||||
const { t, lang } = useLang()
|
||||
|
||||
// Type guards for backward compatibility
|
||||
const isStatic = 'name' in project;
|
||||
|
||||
// Resolve localized texts
|
||||
const title = isStatic
|
||||
? (project.name[lang as 'en' | 'fa'] || project.name.en)
|
||||
: (project as ProjectCardItem).title;
|
||||
|
||||
const locRaw = isStatic ? project.location : (project as ProjectCardItem).location;
|
||||
const location = typeof locRaw === 'string' ? locRaw : (locRaw?.[lang as 'en' | 'fa'] || locRaw?.en);
|
||||
|
||||
const areaRaw = isStatic ? project.area : (project as ProjectCardItem).area;
|
||||
const area = typeof areaRaw === 'string' ? areaRaw : (areaRaw?.[lang as 'en' | 'fa'] || areaRaw?.en);
|
||||
|
||||
const finalImageUrl = imageUrl || (project as ProjectCardItem).image;
|
||||
const finalDetailHref = detailHref || (project as ProjectCardItem).detailHref || '#';
|
||||
|
||||
const dotColor = isOngoing ? 'bg-copper animate-pulse' : 'bg-ivory/60'
|
||||
const textColor = isOngoing ? 'text-copper' : 'text-ivory/90'
|
||||
|
||||
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">
|
||||
<Link to={`/projects/${project.slug || '#'}`} className="flex h-full flex-col focus:outline-none">
|
||||
|
||||
{/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشهای */}
|
||||
<div className="relative aspect-[4/3] w-full overflow-hidden">
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105"
|
||||
/>
|
||||
<Link to={finalDetailHref} className="flex flex-1 flex-col focus:outline-none">
|
||||
{/* Fixed height image wrapper */}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{finalImageUrl && (
|
||||
<img
|
||||
src={finalImageUrl}
|
||||
alt={title}
|
||||
className="w-full h-[250px] object-cover transition-transform duration-700 ease-lux group-hover:scale-110"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* نشانگر وضعیت کپسولی شیشهای */}
|
||||
<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}`}>
|
||||
<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}`}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${dotColor} ${isOngoing ? 'shadow-[0_0_10px_rgba(197,143,92,0.9)]' : ''}`} />
|
||||
{statusText}
|
||||
</div>
|
||||
|
||||
{/* گوی دکمه شیشهای شناور برای ورود به پروژه */}
|
||||
<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 transition-transform duration-300 group-hover:-translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
<div className="flex flex-1 flex-col p-7 pb-4">
|
||||
<div className="text-center">
|
||||
<h3 className="font-pinar text-xl font-bold text-ivory transition-colors duration-300 group-hover:text-copper">
|
||||
{project.title}
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-4 text-sm font-light text-ivory/60">
|
||||
{project.location}
|
||||
</p>
|
||||
{location && <p className="mt-4 text-sm font-light text-ivory/60">{location}</p>}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span>سال: {project.year}</span>
|
||||
<span>{project.area} متر مربع</span>
|
||||
</div>
|
||||
{area && (
|
||||
<>
|
||||
<div className="my-6 h-px w-full bg-white/10" />
|
||||
<div className="mt-auto flex items-center justify-end text-sm font-light text-ivory/60">
|
||||
{/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه میشود */}
|
||||
<span>{lang === 'fa' ? `${area} ${t('sqm')}` : area}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* پایهٔ کارت: یک دکمه واحد */}
|
||||
<div className="flex flex-col gap-2 px-7 pb-7">
|
||||
<PremiumButton to={finalDetailHref} className="w-full">
|
||||
{t('viewProject')}
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MdApartment } from 'react-icons/md'
|
||||
import { useLang } from '../i18n/lang'
|
||||
|
||||
/**
|
||||
* جاینگهدار تصویر پروژه — همان زبان بصری کارت پاپآپ، برای هر پروژهای که
|
||||
* تصویری در داده ندارد (مثل ردیفهای شیت). تکمنبع این ظاهر همینجاست.
|
||||
*/
|
||||
export function ProjectImagePlaceholder({ className = '' }: { className?: string }) {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-char to-ink text-ivory/25 ${className}`}
|
||||
>
|
||||
<MdApartment className="h-8 w-8" aria-hidden="true" />
|
||||
<span className="text-[10px] font-light">{t('mapImagePlaceholder')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function SectionHeading({
|
||||
})
|
||||
|
||||
return (
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl text-right'}>
|
||||
<Reveal className={isCenter ? 'mx-auto max-w-2xl text-center' : 'max-w-2xl ltr:text-left rtl:text-right'}>
|
||||
<InlineEdit
|
||||
as="h2"
|
||||
value={currentTitle}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Reveal } from '../Reveal';
|
||||
import { ButtonLink } from '../Button';
|
||||
import { PremiumButton } from '../PremiumButton';
|
||||
import { ArticleCard } from '../ArticleCard';
|
||||
import { useSectionHeading, useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Articles() {
|
||||
const { articles } = useSite();
|
||||
const { href } = useLang();
|
||||
const heading = useSectionHeading('articles');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -25,7 +27,7 @@ export function Articles() {
|
||||
return (
|
||||
<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">
|
||||
<Reveal className="max-w-2xl text-right">
|
||||
<Reveal className="max-w-2xl ltr:text-left rtl:text-right">
|
||||
<p className="text-sm font-light text-copper">
|
||||
<InlineEdit value={heading.label} onSave={(v) => saveHeading('label', v)} />
|
||||
</p>
|
||||
@@ -51,10 +53,9 @@ export function Articles() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink to={heading.cta_href} variant="outline" className="font-light">
|
||||
<PremiumButton to={href(heading.cta_href)} className="font-light">
|
||||
<InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { MoveHorizontalIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function BeforeAfter() {
|
||||
const { before_after } = useSite()
|
||||
const { t } = useLang()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState(52)
|
||||
const dragging = useRef(false)
|
||||
@@ -102,7 +104,7 @@ export function BeforeAfter() {
|
||||
<button
|
||||
type="button"
|
||||
role="slider"
|
||||
aria-label="مقایسه کانسپت و پروژه ساختهشده"
|
||||
aria-label={t('compareAria')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(position)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
type FaqProps = {
|
||||
@@ -12,24 +13,27 @@ type FaqProps = {
|
||||
|
||||
export function Faq({ hideHeading = false }: FaqProps) {
|
||||
const { faqs } = useSite()
|
||||
const { t } = useLang()
|
||||
const [open, setOpen] = useState<number | null>(0)
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="faq-title">
|
||||
<div className="mx-auto max-w-site px-4 md:px-8 lg:px-10">
|
||||
|
||||
<div className={hideHeading ? "mx-auto max-w-4xl px-6 lg:px-10" : "mx-auto grid max-w-site gap-14 px-6 lg:grid-cols-12 lg:px-10"}>
|
||||
|
||||
{/* Header — centered, full width */}
|
||||
{!hideHeading && (
|
||||
<div className="lg:col-span-4">
|
||||
<div className="mb-8 md:mb-14 flex flex-col items-center w-full text-center">
|
||||
<SectionHeading
|
||||
title="سوالات متداول"
|
||||
lead="پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."
|
||||
title={t('fallbackFaqTitle')}
|
||||
lead={t('fallbackFaqLead')}
|
||||
headingKey="faq"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={hideHeading ? "" : "lg:col-span-8"}>
|
||||
{/* Accordion list — constrained width, direction-aware */}
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<ul>
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = open === i
|
||||
@@ -41,9 +45,9 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper"
|
||||
className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
|
||||
>
|
||||
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
<span className={`text-base font-bold md:text-lg text-start ${isOpen ? 'text-copper' : 'text-ivory'}`}>
|
||||
{faq.question}
|
||||
</span>
|
||||
<PlusIcon
|
||||
@@ -61,7 +65,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pb-7 pl-10 text-sm font-light leading-9 text-ivory/60">{faq.answer}</p>
|
||||
<p className="pb-7 ps-10 text-sm font-light leading-9 text-ivory/60 text-start">{faq.answer}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { ProjectCard } from '../ProjectCard'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSectionHeading, useSite, useStatusLabel } from '../../api'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
import type { ProjectStatus } from '../../data/site'
|
||||
import { projectsData } from '../../data/projectsData'
|
||||
|
||||
export function FeaturedProjects() {
|
||||
const { projects, status_labels } = useSite()
|
||||
const { status_labels } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const heading = useSectionHeading('featured_projects')
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStatusLabel = (key: string, value: string) => {
|
||||
const label = status_labels.find((s) => s.key === key)
|
||||
if (!label) return
|
||||
@@ -28,8 +31,12 @@ export function FeaturedProjects() {
|
||||
{ key: 'done', label: useStatusLabel('done') },
|
||||
{ key: 'progress', label: useStatusLabel('progress') },
|
||||
]
|
||||
|
||||
const [status, setStatus] = useState<ProjectStatus>('done')
|
||||
const list = projects.filter((p) => p.status === status).slice(0, 3)
|
||||
|
||||
// Filter and slice static project data
|
||||
const list = projectsData.filter((p) => p.status === status).slice(0, 3)
|
||||
const BASE_IMG_URL = "/seed-media/2026/09/";
|
||||
|
||||
return (
|
||||
<section className="bg-ink py-24 lg:py-32" aria-labelledby="projects-title">
|
||||
@@ -37,12 +44,11 @@ export function FeaturedProjects() {
|
||||
<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="فیلتر وضعیت پروژه">
|
||||
<div className="mt-10 flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label={t('projectFilterAria')}>
|
||||
{FILTERS.map((filter, index) => {
|
||||
const isSelected = status === filter.key
|
||||
return (
|
||||
<React.Fragment key={filter.key}>
|
||||
{/* خط جداکننده بین دکمهها */}
|
||||
{index > 0 && (
|
||||
<span className="h-6 w-[2px] rounded-full bg-ivory/10" aria-hidden="true" />
|
||||
)}
|
||||
@@ -80,19 +86,26 @@ export function FeaturedProjects() {
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="grid gap-6 lg:grid-cols-3"
|
||||
>
|
||||
{list.map((project, i) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<EditableItem resource="projects" item={project} className="h-full">
|
||||
<ProjectCard project={project} />
|
||||
</EditableItem>
|
||||
</motion.div>
|
||||
))}
|
||||
{list.map((project, i) => {
|
||||
const dummyImageName = `${(i % 6) + 1}.png`;
|
||||
const imageUrl = `${BASE_IMG_URL}${dummyImageName}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: i * 0.1, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full"
|
||||
>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
imageUrl={imageUrl}
|
||||
detailHref={`/projects/${project.slug}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -101,24 +114,17 @@ export function FeaturedProjects() {
|
||||
{heading.cta_label && heading.cta_href && (
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<ButtonLink
|
||||
to={heading.cta_href}
|
||||
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)]"
|
||||
>
|
||||
<PremiumButton to={href(heading.cta_href)}>
|
||||
<InlineEdit
|
||||
value={heading.cta_label}
|
||||
onSave={(v) =>
|
||||
save('section-headings', heading.id, { cta_label: v }, (d) => {
|
||||
const h = d.section_headings.find((s) => s.id === heading.id)
|
||||
if (h) h.cta_label = v
|
||||
if (h) (h as unknown as Record<string, unknown>)["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 group-hover:-translate-x-1 group-hover:bg-copper group-hover:text-char">
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ChevronLeftIcon, ChevronRightIcon, XIcon } from 'lucide-react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang, formatNumber } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Gallery() {
|
||||
const { gallery } = useSite()
|
||||
const { t, lang } = useLang()
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
|
||||
const close = useCallback(() => setActive(null), [])
|
||||
@@ -30,8 +32,8 @@ export function Gallery() {
|
||||
<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">
|
||||
<SectionHeading
|
||||
title="گالری تصاویر"
|
||||
lead="قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."
|
||||
title={t('fallbackGalleryTitle')}
|
||||
lead={t('fallbackGalleryLead')}
|
||||
headingKey="gallery"
|
||||
align="center"
|
||||
/>
|
||||
@@ -44,7 +46,7 @@ export function Gallery() {
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className="group block w-full overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
aria-label={`بزرگنمایی تصویر شماره ${(i + 1).toLocaleString('fa-IR')}`}
|
||||
aria-label={`${t('zoomImagePrefix')} ${formatNumber(i + 1, lang)}`}
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
@@ -62,59 +64,81 @@ export function Gallery() {
|
||||
<AnimatePresence>
|
||||
{active !== null && gallery[active] && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-char/80 backdrop-blur-lg p-6"
|
||||
className="fixed inset-0 z-[80] flex flex-col items-center justify-center bg-ink p-4 md:p-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="نمایش تصویر"
|
||||
aria-label={t('viewImage')}
|
||||
onClick={close}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
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="بستن"
|
||||
className="absolute top-6 right-6 md:top-8 md:right-8 z-10 flex h-10 w-10 md:h-11 md:w-11 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
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="تصویر قبلی"
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
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="تصویر بعدی"
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[85vh] w-auto max-w-[85vw] object-contain shadow-[0_30px_80px_-20px_rgba(0,0,0,0.9)]"
|
||||
{/* Flex Container for Prev, Image, Next */}
|
||||
<div
|
||||
className="relative z-10 flex w-full max-w-7xl flex-1 items-center justify-center gap-4 md:gap-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('prevImage')}
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
|
||||
<motion.img
|
||||
key={active}
|
||||
src={gallery[active].src}
|
||||
alt={gallery[active].alt}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="max-h-[75vh] w-auto max-w-[calc(100%-7rem)] rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
className="flex h-10 w-10 md:h-11 md:w-11 shrink-0 items-center justify-center rounded-xl bg-copper text-ink transition-transform duration-300 ease-lux hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory"
|
||||
aria-label={t('nextImage')}
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modern Dot Pagination */}
|
||||
<div
|
||||
className="absolute bottom-6 md:bottom-10 flex items-center justify-center gap-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{gallery.map((_, idx) => {
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActive(idx)}
|
||||
aria-label={`${t('viewImage')} ${formatNumber(idx + 1, lang)}`}
|
||||
className={`h-2.5 rounded-full transition-all duration-300 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ivory ${
|
||||
isActive ? 'w-6 bg-copper' : 'w-2.5 bg-white/50 hover:bg-white/80'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
|
||||
export function Hero() {
|
||||
const { brand, hero } = useSite()
|
||||
const { t, href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const { scrollY } = useScroll()
|
||||
const imageY = useTransform(scrollY, [0, 700], [0, 110])
|
||||
@@ -21,11 +23,11 @@ export function Hero() {
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label="معرفی گروه ساختمانی آرامش">
|
||||
<section className="relative h-screen min-h-[640px] w-full overflow-hidden" aria-label={t('heroAria')}>
|
||||
{/* تصویر پسزمینه */}
|
||||
<motion.img
|
||||
src={brand.hero}
|
||||
alt="نمای شبانه برج مسکونی ساختهشده توسط گروه ساختمانی آرامش"
|
||||
alt={t('heroImageAlt')}
|
||||
style={{ y: imageY }}
|
||||
className="absolute inset-0 h-[114%] w-full object-cover"
|
||||
/>
|
||||
@@ -62,7 +64,7 @@ export function Hero() {
|
||||
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]"
|
||||
>
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label="تیتر اصلی صفحه" />
|
||||
<InlineEdit value={hero.title} onSave={(v) => saveHero('title', v)} aria-label={t('heroTitleAria')} />
|
||||
</motion.h1>
|
||||
|
||||
{/* زیرتیتر */}
|
||||
@@ -82,51 +84,26 @@ export function Hero() {
|
||||
transition={{ duration: 0.85, delay: 2.44, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="mt-10 flex flex-wrap items-center gap-4"
|
||||
>
|
||||
{/* دکمه اصلی: کپسولی شیشهای دودی با دایره کهربایی اختصاصی برای آیکون */}
|
||||
<ButtonLink
|
||||
to={hero.cta_primary_href}
|
||||
variant="copper"
|
||||
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">
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</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 group-hover:-translate-x-1">
|
||||
<ArrowLeftIcon className="h-4 w-4 stroke-[2.5]" />
|
||||
</span>
|
||||
</ButtonLink>
|
||||
{/* دکمه اصلی */}
|
||||
<PremiumButton to={href(hero.cta_primary_href)}>
|
||||
<InlineEdit value={hero.cta_primary_label} onSave={(v) => saveHero('cta_primary_label', v)} />
|
||||
</PremiumButton>
|
||||
|
||||
{/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */}
|
||||
<ButtonLink
|
||||
to={hero.cta_secondary_href}
|
||||
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"
|
||||
>
|
||||
{/* دکمه ثانویه */}
|
||||
<PremiumButton to={href(hero.cta_secondary_href)} className="bg-char/40 backdrop-blur-md">
|
||||
<InlineEdit value={hero.cta_secondary_label} onSave={(v) => saveHero('cta_secondary_label', v)} />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* کادر آمار شناور (گوشه پایین چپ): شیشهای با لبههای گرد مدرن */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 2.6, ease: [0.23, 1, 0.32, 1] }}
|
||||
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">
|
||||
<InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-light leading-7 text-ivory/70">
|
||||
<InlineEdit multiline value={hero.badge_label} onSave={(v) => saveHero('badge_label', v)} />
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* دکمه اسکرول پایین: تبدیلشده به یک گوی شیشهای دایرهای */}
|
||||
<a
|
||||
href="#story"
|
||||
aria-label="رفتن به بخش بعدی"
|
||||
aria-label={t('scrollDown')}
|
||||
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">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckIcon, SendIcon } from 'lucide-react'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import Swal from 'sweetalert2'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { Button } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
@@ -11,6 +12,7 @@ import { API_URL } from '../../config'
|
||||
|
||||
export function Newsletter() {
|
||||
const { newsletter } = useSite()
|
||||
const { t } = useLang()
|
||||
const save = useFieldSaver()
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
|
||||
@@ -29,7 +31,7 @@ export function Newsletter() {
|
||||
background: '#1e1a16',
|
||||
color: '#F5F3EF',
|
||||
iconColor: '#d5874c',
|
||||
confirmButtonText: 'متوجه شدم',
|
||||
confirmButtonText: t('understood'),
|
||||
buttonsStyling: false,
|
||||
customClass: {
|
||||
container: 'backdrop-blur-md bg-ink/70',
|
||||
@@ -58,13 +60,13 @@ export function Newsletter() {
|
||||
|
||||
if (!emailValue) {
|
||||
setStatus('error')
|
||||
showErrorAlert('اطلاعات ناقص است', 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.')
|
||||
showErrorAlert(t('incompleteInfo'), t('emailRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!emailRegex.test(emailValue)) {
|
||||
setStatus('error')
|
||||
showErrorAlert('فرمت ایمیل نامعتبر است', 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).')
|
||||
showErrorAlert(t('emailInvalidTitle'), t('emailInvalidText'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,8 +82,8 @@ export function Newsletter() {
|
||||
setStatus('error')
|
||||
const data = await res.json().catch(() => null)
|
||||
const detail =
|
||||
typeof data?.detail === 'string' ? data.detail : 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.'
|
||||
showErrorAlert('خطا در عضویت', detail)
|
||||
typeof data?.detail === 'string' ? data.detail : t('emailCheck')
|
||||
showErrorAlert(t('subscribeErrorTitle'), detail)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,14 +91,14 @@ export function Newsletter() {
|
||||
setEmail('')
|
||||
} catch {
|
||||
setStatus('error')
|
||||
showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.')
|
||||
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-r from-copper via-[#d8965e] to-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<section className="relative overflow-hidden bg-copper py-20 shadow-[inset_0_1px_1px_rgba(255,255,255,0.3)]" aria-labelledby="newsletter-title">
|
||||
<SingletonPill resource="newsletter" label="ویرایش متنهای خبرنامه" className="absolute left-4 top-4" />
|
||||
|
||||
{/* هاله نور پسزمینه برای عمق بصری */}
|
||||
@@ -140,19 +142,18 @@ export function Newsletter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<button
|
||||
type="submit"
|
||||
variant="dark"
|
||||
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/btn relative inline-flex items-center justify-center overflow-hidden rounded-full px-10 py-4 text-sm font-bold tracking-widest text-white border border-ink bg-ink transition-all duration-300 ease-in-out transform hover:scale-105 hover:shadow-lg cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
||||
<span>
|
||||
<span className="absolute inset-0 z-0 bg-white scale-y-0 origin-bottom transition-transform duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:scale-y-100" />
|
||||
<span className="relative z-10 transition-colors duration-500 ease-[cubic-bezier(0.19,1,0.22,1)] group-hover/btn:text-ink">
|
||||
{sending ? newsletter.sending_label : (
|
||||
<InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</button>
|
||||
</form>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,77 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
|
||||
function toFa(n: number) {
|
||||
return n.toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
function Counter({ value }: {value: number;}) {
|
||||
function useCounter(targetNumber: number, duration: number = 2000) {
|
||||
const [count, setCount] = useState(0);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: '-60px' });
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return;
|
||||
let frame = 0;
|
||||
const total = 60;
|
||||
const id = window.setInterval(() => {
|
||||
frame += 1;
|
||||
const progress = 1 - Math.pow(1 - frame / total, 3);
|
||||
setDisplay(Math.round(value * progress));
|
||||
if (frame >= total) window.clearInterval(id);
|
||||
}, 20);
|
||||
return () => window.clearInterval(id);
|
||||
}, [inView, value]);
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
return <span ref={ref}>{toFa(display)}</span>;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
observer.disconnect();
|
||||
|
||||
let startTime: number | null = null;
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = timestamp - startTime;
|
||||
|
||||
// ease-out cubic
|
||||
const easeOut = progress >= duration ? 1 : 1 - Math.pow(1 - progress / duration, 3);
|
||||
|
||||
setCount(Math.floor(easeOut * targetNumber));
|
||||
|
||||
if (progress < duration) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
setCount(targetNumber);
|
||||
}
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [targetNumber, duration]);
|
||||
|
||||
return { count, ref };
|
||||
}
|
||||
|
||||
function Counter({ value }: { value: string | number }) {
|
||||
const { lang } = useLang();
|
||||
|
||||
const strValue = String(value);
|
||||
// Match the first sequence of digits
|
||||
const numMatch = strValue.match(/(\d+)/);
|
||||
const targetNumber = numMatch ? parseInt(numMatch[0], 10) : 0;
|
||||
|
||||
const { count, ref } = useCounter(targetNumber, 1500);
|
||||
|
||||
// If there's a number, replace it with the animated (and localized) count, otherwise render string as is.
|
||||
const formattedDisplay = formatNumber(count, lang);
|
||||
const renderValue = numMatch ? strValue.replace(numMatch[0], formattedDisplay) : strValue;
|
||||
|
||||
return <span ref={ref}>{renderValue}</span>;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const { stats } = useSite();
|
||||
const { t } = useLang();
|
||||
const save = useFieldSaver();
|
||||
|
||||
return (
|
||||
<section className="bg-char py-16" aria-label="آمار و ارقام">
|
||||
<section className="bg-char py-16" aria-label={t('statsAria')}>
|
||||
<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) =>
|
||||
<EditableItem key={stat.id} resource="stats" item={stat}>
|
||||
@@ -48,7 +84,7 @@ export function Stats() {
|
||||
const s = d.stats.find((x) => x.id === stat.id);
|
||||
if (s) s.suffix = v;
|
||||
})}
|
||||
className="mr-1 text-2xl"
|
||||
className="ltr:ml-1 rtl:mr-1 text-2xl"
|
||||
/>
|
||||
</p>
|
||||
<p className="mt-3 text-sm font-light text-ivory/55">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
|
||||
import { Reveal } from '../Reveal'
|
||||
import { ButtonLink } from '../Button'
|
||||
import { PremiumButton } from '../PremiumButton'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { useFieldSaver } from '../../admin/AdminContext'
|
||||
import { InlineEdit } from '../admin/InlineEdit'
|
||||
import { SingletonPill } from '../../admin/Editable'
|
||||
@@ -17,6 +18,7 @@ type StoryProps = {
|
||||
|
||||
export function Story({ showLink = true, showLabel = true, showQuote = false, alignTop = false, children }: StoryProps) {
|
||||
const { story } = useSite()
|
||||
const { href } = useLang()
|
||||
const save = useFieldSaver()
|
||||
|
||||
const saveStory = (field: string, value: unknown) =>
|
||||
@@ -87,10 +89,9 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
|
||||
{showLink && (
|
||||
<Reveal delay={0.22}>
|
||||
<ButtonLink to={story.link_href} variant="outline" className="mt-10 font-light">
|
||||
<PremiumButton to={href(story.link_href)} className="mt-10 font-light">
|
||||
<InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} />
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</ButtonLink>
|
||||
</PremiumButton>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -102,7 +103,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al
|
||||
</div>
|
||||
|
||||
<div className="relative grid grid-cols-2 gap-4">
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 right-0" />
|
||||
<SingletonPill resource="story" label="تصاویر و نقلقول بخش درباره ما" className="absolute -top-10 ltr:left-0 rtl:right-0" />
|
||||
{story.bento.map((item, i) => (
|
||||
<Reveal key={item.src + i} delay={0.06 * i}>
|
||||
<div className="overflow-hidden">
|
||||
|
||||
@@ -2,16 +2,18 @@ import React from 'react'
|
||||
import { Reveal } from '../Reveal'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { useSite } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
export function Team() {
|
||||
const { team } = useSite()
|
||||
const { t } = useLang()
|
||||
return (
|
||||
<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">
|
||||
<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 ">
|
||||
<SectionHeading
|
||||
title="تیم ما"
|
||||
lead="کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."
|
||||
title={t('fallbackTeamTitle')}
|
||||
lead={t('fallbackTeamLead')}
|
||||
headingKey="team"
|
||||
align="center"
|
||||
/>
|
||||
@@ -20,7 +22,8 @@ export function Team() {
|
||||
{team.map((member, i) => (
|
||||
<Reveal key={member.id} delay={0.06 * i} className="h-full">
|
||||
<EditableItem resource="team" item={member} className="h-full">
|
||||
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
{/* اضافه شدن rounded-2xl برای گرد شدن گوشهها */}
|
||||
<article className="group flex h-full flex-col items-center rounded-2xl border border-ivory/10 bg-char p-10 text-center transition-[transform,box-shadow,border-color] duration-500 ease-out hover:-translate-y-3 hover:border-copper/50 hover:shadow-lift">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full border border-copper/30 text-3xl font-bold text-copper transition-colors duration-500 group-hover:border-copper">
|
||||
{member.name.trim().charAt(0)}
|
||||
</div>
|
||||
@@ -38,4 +41,4 @@ export function Team() {
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, QuoteIcon } from 'lucide-react';
|
||||
import { SectionHeading } from '../SectionHeading';
|
||||
import { useSite } from '../../api';
|
||||
import { useLang } from '../../i18n/lang';
|
||||
import { EditableItem } from '../../admin/Editable';
|
||||
|
||||
export function Testimonials() {
|
||||
const { testimonials } = useSite();
|
||||
const { t } = useLang();
|
||||
const [index, setIndex] = useState(0);
|
||||
const current = testimonials[Math.min(index, Math.max(testimonials.length - 1, 0))];
|
||||
if (!current) return null;
|
||||
@@ -44,20 +46,20 @@ export function Testimonials() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
aria-label="نظر قبلی"
|
||||
aria-label={t('prevTestimonial')}
|
||||
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 transition-transform duration-300 ease-lux group-hover:translate-x-0.5" />
|
||||
<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" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label="انتخاب نظر">
|
||||
{testimonials.map((t, i) => (
|
||||
<div className="flex items-center gap-2" role="tablist" aria-label={t('selectTestimonialAria')}>
|
||||
{testimonials.map((item, i) => (
|
||||
<button
|
||||
key={t.id}
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === index}
|
||||
aria-label={`نظر ${t.name}`}
|
||||
aria-label={`${t("testimonialByPrefix")} ${item.name}`}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-lux ${
|
||||
i === index
|
||||
@@ -71,10 +73,10 @@ export function Testimonials() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
aria-label="نظر بعدی"
|
||||
aria-label={t('nextTestimonial')}
|
||||
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 transition-transform duration-300 ease-lux group-hover:-translate-x-0.5" />
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform, useInView } from 'framer-motion'
|
||||
import { useSite, type TimelineItem } from '../../api'
|
||||
import { useLang } from '../../i18n/lang'
|
||||
import { SectionHeading } from '../SectionHeading'
|
||||
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
||||
|
||||
@@ -15,6 +16,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
|
||||
return (
|
||||
<li ref={ref} className="relative md:grid md:grid-cols-2 md:gap-16">
|
||||
{/* دایرهها: مرکز دایره در موبایل و دسکتاپ دقیقاً روی مرکز خط 2px است */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute right-[-5px] top-8 z-10 h-3 w-3 rounded-full transition-all duration-500 ease-lux md:right-1/2 md:translate-x-1/2 ${
|
||||
@@ -34,7 +36,6 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
>
|
||||
<EditableItem resource="timeline" item={item}>
|
||||
<div className="group relative overflow-hidden rounded-2xl border border-ivory/10 bg-ink/40 p-6 backdrop-blur-md transition-all duration-500 ease-lux hover:-translate-y-1.5 hover:border-copper/50 hover:bg-ink/60 hover:shadow-[0_12px_40px_-10px_rgba(197,143,92,0.35)] md:p-8">
|
||||
{/* لایه نوری ظریف که در هاور روشن میشود */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
||||
|
||||
<span className="relative z-10 text-sm font-medium tracking-widest text-copper">{item.year}</span>
|
||||
@@ -49,6 +50,7 @@ function Item({ item, index }: { item: Milestone; index: number }) {
|
||||
|
||||
export function Timeline() {
|
||||
const { timeline } = useSite()
|
||||
const { t } = useLang()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
@@ -62,21 +64,23 @@ export function Timeline() {
|
||||
|
||||
<div className="mb-16">
|
||||
<SectionHeading
|
||||
title="مسیر ما"
|
||||
lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."
|
||||
title={t('fallbackTimelineTitle')}
|
||||
lead={t('fallbackTimelineLead')}
|
||||
headingKey="timeline"
|
||||
align="center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={ref} className="relative mx-auto mt-20 max-w-4xl">
|
||||
{/* خط پسزمینه ثابت - عرض 2px و همراستایی دقیق با مرکز */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px bg-ivory/10 md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-[2px] bg-ivory/10 md:right-1/2 md:translate-x-1/2"
|
||||
/>
|
||||
{/* خط متحرک - عرض 2px و همراستایی دقیق با مرکز */}
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-0 right-0 w-px origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2"
|
||||
className="absolute inset-y-0 right-0 w-[2px] origin-top bg-gradient-to-b from-copper/80 via-copper to-copper/20 shadow-[0_0_10px_rgba(197,143,92,0.5)] md:right-1/2 md:translate-x-1/2"
|
||||
style={{ scaleY }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useSectionHeading, useSite } from '../../api';
|
||||
import { AddItemButton, EditableItem, SingletonPill } from '../../admin/Editable';
|
||||
import { InlineEdit } from '../admin/InlineEdit';
|
||||
import { useFieldSaver } from '../../admin/AdminContext';
|
||||
import { useLang, formatNumber } from '../../i18n/lang';
|
||||
|
||||
export function WhyUs() {
|
||||
const { why_us } = useSite();
|
||||
const { lang } = useLang();
|
||||
const heading = useSectionHeading('why_us');
|
||||
const save = useFieldSaver();
|
||||
|
||||
@@ -43,7 +45,7 @@ export function WhyUs() {
|
||||
<Reveal key={item.id} delay={0.05 * i}>
|
||||
<EditableItem resource="why_us" item={item}>
|
||||
<div className="flex h-full flex-col border-t border-ivory/10 py-8">
|
||||
<span className="text-xs font-light text-copper">۰{(i + 1).toLocaleString('fa-IR')}</span>
|
||||
<span className="text-xs font-light text-copper">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</span>
|
||||
<h3 className="mt-4 text-lg font-bold text-ivory">
|
||||
<InlineEdit
|
||||
value={item.title}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// آدرس بکاند — پیشفرض خالی یعنی درخواستهای هممبدأ (/api از طریق Traefik).
|
||||
// فقط اگر بکاند روی مبدأ دیگری سرو میشود VITE_API_URL را تنظیم کنید.
|
||||
export const API_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? ''
|
||||
|
||||
// آدرس پایه کاشیهای نقشه (MinIO: style.json، iran.pmtiles، فونتها و اسپرایت).
|
||||
// در صورت تغییر مبدا سرو کاشیها VITE_MAP_URL را تنظیم کنید.
|
||||
export const MAP_BASE_URL: string =
|
||||
(import.meta.env.VITE_MAP_URL as string | undefined) ?? 'http://localhost:19000/maps'
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* مختصات پروژههای گروه ساختمانی آرامش — برگرفته از شیت «Sheet1.csv».
|
||||
*
|
||||
* دو نکتهی دادهای که در همانجا حل میشوند:
|
||||
* ۱) ستونهای neshan_url و google_maps_url در شیت فقط برچسب بودند
|
||||
* («Open in Neshan») و نشانی واقعی نداشتند؛ پس پیوندها از روی مختصات
|
||||
* ساخته میشوند که الگوی تثبیتشدهی هر دو سرویس است.
|
||||
* ۲) ستون توضیحات خالی است؛ متن پیشفرض در لایهی رابط کاربری جایگزین میشود.
|
||||
*/
|
||||
|
||||
import type { Project, ProjectCardItem } from './site'
|
||||
|
||||
export type MapProject = {
|
||||
id: number
|
||||
name: string
|
||||
/** سال شمسی — ممکن است خالی باشد */
|
||||
year: string
|
||||
/** پروژهی در جریان (ستون new شیت) */
|
||||
isNew: boolean
|
||||
lat: number
|
||||
lng: number
|
||||
description: string
|
||||
}
|
||||
|
||||
/** مرکز پیشفرض نقشه — اصفهان */
|
||||
export const ISFAHAN_CENTER: [number, number] = [51.6675, 32.6539]
|
||||
|
||||
type CsvRow = { name: string; year: string; isNew: boolean; lat: number; lng: number; description: string }
|
||||
|
||||
/**
|
||||
* ستونهای برچسبی شیت حذف شدهاند (نشانی واقعی نداشتند) و ستون توضیحات به
|
||||
* description نگاشت شده است. ترتیب: name,year,new,lat,lng,description
|
||||
*/
|
||||
const RAW_CSV = `name,year,new,lat,lng,description
|
||||
پروژه سروش,1381,no,32.6662842,51.701735,
|
||||
چهرازی-زیتون,1383,no,32.6700428,51.69985,
|
||||
جعفریان,1384,no,32.6669259,51.701085,
|
||||
بهار,1385,yes,32.6468963,51.6899119,
|
||||
حسن مرادی,1385,no,32.6704278,51.6985567,
|
||||
کوثر,1386,no,32.6522044,51.6856474,
|
||||
صبا,1387,no,32.6541363,51.6847828,
|
||||
پورمعالی - بهشت,1387,no,32.6500402,51.6919823,
|
||||
2 پروژه- فردوس,1388,no,32.6614576,51.706429,
|
||||
ارم,1388,no,32.6563712,51.710694,
|
||||
سینا,1388,no,32.6469299,51.6855403,
|
||||
مسجد سید,1389,yes,32.6669953,51.6557085,
|
||||
هفده شهریور,1389,yes,32.7249998,51.6543819,
|
||||
آرامش رفیع زاده,1390,no,32.6467107,51.6873398,
|
||||
بن بست شریعت,1390,yes,32.6459939,51.6897556,
|
||||
قصر - یاسین,1390,no,32.6663549,51.7065512,
|
||||
پردیس,1390,no,32.6518961,51.6973621,
|
||||
آرامش حکیم,1392,no,32.6426783,51.6813477,
|
||||
آرامش دخانیات,1393,no,32.6411924,51.6797555,
|
||||
بهشتی فرشادی,1393,no,32.6531989,51.6790984,
|
||||
کاکتوس,1394,no,32.6486747,51.6859003,
|
||||
آرامش - صیاد,1395,no,32.6486798,51.712659,
|
||||
کوثر,1395,yes,32.6506204,51.6344053,
|
||||
آرامش - بوعلی 1,1396,no,32.6455156,51.6918424,
|
||||
میرزا نصیر,1396,no,32.668195,51.7173079,
|
||||
نیکان,1396,yes,32.6504663,51.6355982,
|
||||
آرامش-رسا,1397,no,32.6522126,51.6857297,
|
||||
نگین,1397,yes,32.6507084,51.6313395,
|
||||
آرامش - بوعلی 2,1398,no,32.645391,51.6917828,
|
||||
آرامش استاد همایی,1398,no,32.6563505,51.691988,
|
||||
آرامش صدر,1398,no,32.6469234,51.6826176,
|
||||
آرامش - سپهسالار,1399,yes,32.6259658,51.7080268,
|
||||
آرامش- صدر,1399,no,32.6469205,51.6829887,
|
||||
آرامش - سنایی,1400,yes,32.6973339,51.6194128,
|
||||
مغازه لباس فروشی,1400,no,32.6516965,51.7020497,
|
||||
آرامش - آتشگاه,1401,yes,32.6547584,51.6319244,
|
||||
آرامش - بزرگمهر,1401,no,32.651381,51.6993575,
|
||||
آرامش - سعدی,1401,yes,32.6970115,51.6196909,
|
||||
آرامش - صمدیه لباف,1401,yes,32.6716585,51.6359248,
|
||||
آرامش 2,1401,yes,32.6505704,51.6337863,
|
||||
آرامش 5 رمضان,1401,yes,32.6674407,51.6568827,
|
||||
آرامش احمدآباد,1401,no,32.6626101,51.6969828,
|
||||
تجاری آتشگاه,1401,yes,32.6515087,51.6033278,
|
||||
آرامش - شهیدان,1402,yes,32.6854968,51.6171964,
|
||||
آرامش خواجو,1402,no,32.6419187,51.6831873,
|
||||
آرامش- اداری ابوالحسن,1402,no,32.6453749,51.6915519,
|
||||
آرامش - انقلاب,1403,yes,32.719769,51.645921,
|
||||
آرامش - شریف,1404,no,32.6473319,51.6955996,
|
||||
آرامش - کهندژ,1404,yes,32.6649497,51.630077,
|
||||
آرامش جی,1404,no,32.6610717,51.7110184,
|
||||
آرامش - بابک,1405,yes,32.674076,51.6368204,
|
||||
آرامش - شمس آبادی,1405,yes,32.6560844,51.664771,
|
||||
آرامش - میرزاطاهر 3,1405,yes,32.6495532,51.6178643,
|
||||
آرامش - وحدت,1405,yes,32.6537564,51.6197007,
|
||||
آرامش پروین,1405,no,32.6618223,51.7098152,
|
||||
خلیفه سلطانی,1405,yes,32.7035398,51.6223296,
|
||||
آرامش - میرزا طاهر 5,1406,yes,32.6517244,51.6202975,
|
||||
آرامش - میرزاطاهر 6,1408,yes,32.6505366,51.6267342,
|
||||
آرامش - میرزا طاهر 4,,yes,32.6510328,51.631436,
|
||||
آرامش- میرزا طاهر 1,,yes,32.6506236,51.6298238,`
|
||||
|
||||
/**
|
||||
* تجزیهی یک خط CSV — پشتیبانی از فیلدهای گیومهدار و گیومهی دوگانهی درونی.
|
||||
* جداکننده کاما و نویسههای مخفی ابتدای فایل (BOM) هم حذف میشوند.
|
||||
*/
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"'
|
||||
i += 1
|
||||
} else inQuotes = false
|
||||
} else current += char
|
||||
} else if (char === '"') inQuotes = true
|
||||
else if (char === ',') {
|
||||
fields.push(current.trim())
|
||||
current = ''
|
||||
} else current += char
|
||||
}
|
||||
fields.push(current.trim())
|
||||
return fields
|
||||
}
|
||||
|
||||
/** CSV خام → آرایهی تایپشدهی JSON */
|
||||
export function parseProjectsCsv(raw: string): MapProject[] {
|
||||
const rows: CsvRow[] = []
|
||||
const lines = raw.replace(/^\ufeff/, '').split(/\r?\n/)
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
if (!line.trim()) continue
|
||||
const [name, year = '', isNew = '', lat = '', lng = '', description = ''] = parseCsvLine(line)
|
||||
const parsedLat = Number(lat)
|
||||
const parsedLng = Number(lng)
|
||||
if (!name || !Number.isFinite(parsedLat) || !Number.isFinite(parsedLng)) continue
|
||||
rows.push({ name, year, isNew: isNew.toLowerCase() === 'yes', lat: parsedLat, lng: parsedLng, description })
|
||||
}
|
||||
|
||||
return rows.map((row, index) => ({ id: index + 1, ...row }))
|
||||
}
|
||||
|
||||
export const MAP_PROJECTS: MapProject[] = parseProjectsCsv(RAW_CSV)
|
||||
|
||||
/** پیوند نشان روی نقشه برای سرویس نشان */
|
||||
export function buildNeshanUrl(project: Pick<MapProject, 'lat' | 'lng'>, zoom = 17): string {
|
||||
return `https://neshan.org/maps/@${project.lat},${project.lng},${zoom}z`
|
||||
}
|
||||
|
||||
/** پیوند نقشه گوگل */
|
||||
export function buildGoogleMapsUrl(project: Pick<MapProject, 'lat' | 'lng'>): string {
|
||||
return `https://www.google.com/maps?q=${project.lat},${project.lng}`
|
||||
}
|
||||
|
||||
const NAME_NOISE = ['آرامش', 'پروژه', 'مجتمع', 'گروه', 'ساختمانی', 'برج', 'مجموعه']
|
||||
|
||||
/**
|
||||
* نرمالسازی نام برای تطبیق شیت با دادهی بکاند: نویسههای نامرئی،
|
||||
* ی/ک عربی، نشانهها و واژههای عمومی حذف میشوند تا «آرامش - سنایی» و
|
||||
* «پروژه سنایی» یک کلید بگیرند.
|
||||
*/
|
||||
export function normalizeProjectName(name: string): string {
|
||||
let result = (name ?? '')
|
||||
.normalize('NFC')
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\ufeff]/g, ' ')
|
||||
.replace(/[يى]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[ۀة]/g, 'ه')
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
for (const noise of NAME_NOISE) result = result.replace(new RegExp(`(^|\\s)${noise}(?=\\s|$)`, 'g'), ' ')
|
||||
return result.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** پروژهی شیت متناظر با یک نام (تطبیق دقیق نرمالشده، سپس دربرگیری) */
|
||||
export function findMapProjectByName(name: string): MapProject | undefined {
|
||||
const key = normalizeProjectName(name)
|
||||
if (!key) return undefined
|
||||
|
||||
const exact = MAP_PROJECTS.find((project) => normalizeProjectName(project.name) === key)
|
||||
if (exact) return exact
|
||||
|
||||
return MAP_PROJECTS.find((project) => {
|
||||
const candidate = normalizeProjectName(project.name)
|
||||
return candidate.length >= 3 && key.length >= 3 && (candidate.includes(key) || key.includes(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* محل جایگزین وقتی پروژهای در شیت پیدا نشد — مرکز اصفهان با نام پروژهی
|
||||
* بکاند، تا نمای تکپروژه هرگز بدون نقشه نماند.
|
||||
*/
|
||||
export function createPlaceholderProject(name: string, description = ''): MapProject {
|
||||
return {
|
||||
id: 0,
|
||||
name,
|
||||
year: '',
|
||||
isNew: false,
|
||||
lat: ISFAHAN_CENTER[1],
|
||||
lng: ISFAHAN_CENTER[0],
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* کادر محدودهٔ نقشه از مختصات پروژهها — با حاشیه تا لبهٔ نقشه خالی نماند و
|
||||
* کاربر به بیرون از محدودهٔ پروژهها جابهجا نشود.
|
||||
*/
|
||||
export function boundsForProjects(
|
||||
projects: Pick<MapProject, 'lat' | 'lng'>[],
|
||||
padding = 0.02,
|
||||
): [[number, number], [number, number]] | undefined {
|
||||
if (!projects.length) return undefined
|
||||
const lats = projects.map((project) => project.lat)
|
||||
const lngs = projects.map((project) => project.lng)
|
||||
return [
|
||||
[Math.min(...lngs) - padding, Math.min(...lats) - padding],
|
||||
[Math.max(...lngs) + padding, Math.max(...lats) + padding],
|
||||
]
|
||||
}
|
||||
|
||||
/** ردیف شیت → کارت فهرست: عنوان از شیت، وضعیت از ستون new، نشانی از مختصات */
|
||||
export function csvProjectToCard(project: MapProject, detailHref: string): ProjectCardItem {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.name,
|
||||
status: project.isNew ? 'progress' : 'done',
|
||||
detailHref,
|
||||
addressUrl: buildNeshanUrl(project),
|
||||
}
|
||||
}
|
||||
|
||||
/** پروژهٔ بکاند → کارت فهرست؛ نشانی نشان از تطبیق نامی با شیت میآید */
|
||||
export function apiProjectToCard(project: Project, detailHref: string): ProjectCardItem {
|
||||
const matched = findMapProjectByName(project.title)
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
status: project.status,
|
||||
detailHref,
|
||||
location: project.location || undefined,
|
||||
image: project.image || undefined,
|
||||
area: project.area || undefined,
|
||||
addressUrl: matched ? buildNeshanUrl(matched) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export type BilingualText = { en: string; fa: string };
|
||||
|
||||
export type StaticProject = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: BilingualText;
|
||||
location: BilingualText;
|
||||
area: BilingualText;
|
||||
status: 'done' | 'progress';
|
||||
};
|
||||
|
||||
|
||||
export const projectsData: StaticProject[] = [
|
||||
{ id: '1', slug: 'project-1', name: { en: 'Project 1', fa: 'پروژه سروش' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '2', slug: 'project-2', name: { en: 'Project 2', fa: 'چهرازی-زیتون' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '3', slug: 'project-3', name: { en: 'Project 3', fa: 'جعفریان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '4', slug: 'project-4', name: { en: 'Project 4', fa: 'بهار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '5', slug: 'project-5', name: { en: 'Project 5', fa: 'حسن مرادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '6', slug: 'project-6', name: { en: 'Project 6', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '7', slug: 'project-7', name: { en: 'Project 7', fa: 'صبا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '8', slug: 'project-8', name: { en: 'Project 8', fa: 'پورمعالی - بهشت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '9', slug: 'project-9', name: { en: 'Project 9', fa: '2 پروژه- فردوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '10', slug: 'project-10', name: { en: 'Project 10', fa: 'ارم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '11', slug: 'project-11', name: { en: 'Project 11', fa: 'سینا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '12', slug: 'project-12', name: { en: 'Project 12', fa: 'مسجد سید' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '13', slug: 'project-13', name: { en: 'Project 13', fa: 'هفده شهریور' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '14', slug: 'project-14', name: { en: 'Project 14', fa: 'آرامش رفیع زاده' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '15', slug: 'project-15', name: { en: 'Project 15', fa: 'بن بست شریعت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '16', slug: 'project-16', name: { en: 'Project 16', fa: 'قصر - یاسین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '17', slug: 'project-17', name: { en: 'Project 17', fa: 'پردیس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '18', slug: 'project-18', name: { en: 'Project 18', fa: 'آرامش حکیم' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '19', slug: 'project-19', name: { en: 'Project 19', fa: 'آرامش دخانیات' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '20', slug: 'project-20', name: { en: 'Project 20', fa: 'بهشتی فرشادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '21', slug: 'project-21', name: { en: 'Project 21', fa: 'کاکتوس' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '22', slug: 'project-22', name: { en: 'Project 22', fa: 'آرامش - صیاد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '23', slug: 'project-23', name: { en: 'Project 23', fa: 'کوثر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '24', slug: 'project-24', name: { en: 'Project 24', fa: 'آرامش - بوعلی 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '25', slug: 'project-25', name: { en: 'Project 25', fa: 'میرزا نصیر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '26', slug: 'project-26', name: { en: 'Project 26', fa: 'نیکان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '27', slug: 'project-27', name: { en: 'Project 27', fa: 'آرامش-رسا' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '28', slug: 'project-28', name: { en: 'Project 28', fa: 'نگین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '29', slug: 'project-29', name: { en: 'Project 29', fa: 'آرامش - بوعلی 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '30', slug: 'project-30', name: { en: 'Project 30', fa: 'آرامش استاد همایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '31', slug: 'project-31', name: { en: 'Project 31', fa: 'آرامش صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '32', slug: 'project-32', name: { en: 'Project 32', fa: 'آرامش - سپهسالار' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '33', slug: 'project-33', name: { en: 'Project 33', fa: 'آرامش- صدر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '34', slug: 'project-34', name: { en: 'Project 34', fa: 'آرامش - سنایی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '35', slug: 'project-35', name: { en: 'Project 35', fa: 'مغازه لباس فروشی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '36', slug: 'project-36', name: { en: 'Project 36', fa: 'آرامش - آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '37', slug: 'project-37', name: { en: 'Project 37', fa: 'آرامش - بزرگمهر' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '38', slug: 'project-38', name: { en: 'Project 38', fa: 'آرامش - سعدی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '39', slug: 'project-39', name: { en: 'Project 39', fa: 'آرامش - صمدیه لباف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '40', slug: 'project-40', name: { en: 'Project 40', fa: 'آرامش 2' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '41', slug: 'project-41', name: { en: 'Project 41', fa: 'آرامش 5 رمضان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '42', slug: 'project-42', name: { en: 'Project 42', fa: 'آرامش احمدآباد' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '43', slug: 'project-43', name: { en: 'Project 43', fa: 'تجاری آتشگاه' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '44', slug: 'project-44', name: { en: 'Project 44', fa: 'آرامش - شهیدان' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '45', slug: 'project-45', name: { en: 'Project 45', fa: 'آرامش خواجو' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '46', slug: 'project-46', name: { en: 'Project 46', fa: 'آرامش- اداری ابوالحسن' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '47', slug: 'project-47', name: { en: 'Project 47', fa: 'آرامش - انقلاب' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '48', slug: 'project-48', name: { en: 'Project 48', fa: 'آرامش - شریف' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '49', slug: 'project-49', name: { en: 'Project 49', fa: 'آرامش - کهندژ' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '50', slug: 'project-50', name: { en: 'Project 50', fa: 'آرامش جی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '51', slug: 'project-51', name: { en: 'Project 51', fa: 'آرامش - بابک' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '52', slug: 'project-52', name: { en: 'Project 52', fa: 'آرامش - شمس آبادی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '53', slug: 'project-53', name: { en: 'Project 53', fa: 'آرامش - میرزاطاهر 3' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '54', slug: 'project-54', name: { en: 'Project 54', fa: 'آرامش - وحدت' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '55', slug: 'project-55', name: { en: 'Project 55', fa: 'آرامش پروین' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'done' },
|
||||
{ id: '56', slug: 'project-56', name: { en: 'Project 56', fa: 'خلیفه سلطانی' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '57', slug: 'project-57', name: { en: 'Project 57', fa: 'آرامش - میرزا طاهر 5' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '58', slug: 'project-58', name: { en: 'Project 58', fa: 'آرامش - میرزاطاهر 6' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '59', slug: 'project-59', name: { en: 'Project 59', fa: 'آرامش - میرزا طاهر 4' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
{ id: '60', slug: 'project-60', name: { en: 'Project 60', fa: 'آرامش- میرزا طاهر 1' }, location: { en: 'Isfahan', fa: 'اصفهان' }, area: { en: '1000', fa: '۱۰۰۰' }, status: 'progress' },
|
||||
];
|
||||
@@ -1,9 +1,12 @@
|
||||
// انواع مشترک دادهها — همه محتوای متنی سایت از بکاند (GET /api/site) بارگذاری میشود.
|
||||
// فیلدهای *_en معادل انگلیسیاند (اختیاری — خالی یعنی بدون ترجمه و برگشت به فارسی).
|
||||
|
||||
export type Service = {
|
||||
id: number;
|
||||
title: string;
|
||||
title_en?: string;
|
||||
desc: string;
|
||||
desc_en?: string;
|
||||
icon: 'draft' | 'tower' | 'mall' | 'villa' | 'renew' | 'manage' | 'invest' | 'landscape';
|
||||
};
|
||||
|
||||
@@ -13,17 +16,30 @@ export type Project = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
title_en?: string;
|
||||
location: string;
|
||||
location_en?: string;
|
||||
year: string;
|
||||
year_en?: string;
|
||||
status: ProjectStatus;
|
||||
image: string;
|
||||
gallery: string[];
|
||||
area: string;
|
||||
area_en?: string;
|
||||
floors: string;
|
||||
floors_en?: string;
|
||||
units: string;
|
||||
units_en?: string;
|
||||
client: string;
|
||||
client_en?: string;
|
||||
/** مختصات جغرافیایی پروژه — عرض جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||
lat?: number;
|
||||
/** مختصات جغرافیایی پروژه — طول جغرافیایی؛ ستونهای آینده بکاند، اختیاری */
|
||||
lng?: number;
|
||||
summary: string;
|
||||
summary_en?: string;
|
||||
body: string[];
|
||||
body_en?: string[] | null;
|
||||
};
|
||||
|
||||
/** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */
|
||||
@@ -33,9 +49,12 @@ export type ArticleStatus = 'draft' | 'published';
|
||||
|
||||
export type Article = {
|
||||
id: number;
|
||||
slug: string; title: string; date: string; category: string; image: string; read: string; excerpt: string;
|
||||
slug: string; title: string; title_en?: string; date: string; date_en?: string; category: string; category_en?: string;
|
||||
image: string; read: string; read_en?: string; excerpt: string; excerpt_en?: string;
|
||||
/** سند TipTap — ردیفهای قدیمی ممکن است هنوز لیست پاراگراف باشند */
|
||||
body: ArticleBodyDoc | string[];
|
||||
/** بدنه انگلیسی — سند TipTap؛ null یعنی بدون ترجمه */
|
||||
body_en?: ArticleBodyDoc | null;
|
||||
/** پیشنویس فقط برای ادمین (از انتهای مدیریتی) میآید؛ بازدیدکننده نمیبیند */
|
||||
status: ArticleStatus;
|
||||
/** کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود */
|
||||
@@ -51,3 +70,20 @@ export type Article = {
|
||||
/** هشتگها/برچسبهای مقاله */
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* شکل پروژه در کارتهای فهرست — از پروژهٔ بکاند یا ردیف شیت پر میشود؛
|
||||
* فیلدهای اختیاری فقط وقتی رندر میشوند که دادهشان موجود باشد.
|
||||
*/
|
||||
export type ProjectCardItem = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
status: ProjectStatus;
|
||||
/** مسیر صفحهٔ جزئیات پروژه */
|
||||
detailHref: string;
|
||||
/** نشانی بیرونی پروژه (نشان) — در نبودش دکمهٔ آن رندر نمیشود */
|
||||
addressUrl?: string;
|
||||
location?: string;
|
||||
image?: string;
|
||||
area?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// دیکشنری دوزبانه رشتههای هاردکد رابط کاربری (بدون محتوای دیتابیس).
|
||||
// کلید فارسی مرجع است؛ متنهای دیتابیس (سئو، پروژهها، مقالهها و...) از 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: 'پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران.',
|
||||
|
||||
// کارت پروژه
|
||||
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: 'نظر',
|
||||
|
||||
// نقشه پروژهها (مپلیبر)
|
||||
mapAria: 'نقشه موقعیت پروژهها',
|
||||
mapLoading: 'در حال بارگذاری نقشه…',
|
||||
mapErrorText: 'بارگذاری نقشه ناموفق بود — اتصال به سرور کاشیها را بررسی کنید.',
|
||||
viewProject: 'مشاهده پروژه',
|
||||
mapFullscreen: 'نمایش تمامصفحه',
|
||||
mapExitFullscreen: 'خروج از تمامصفحه',
|
||||
viewAddress: 'مشاهده آدرس پروژه',
|
||||
mapNewBadge: 'جدید',
|
||||
mapProjectDescription: 'پروژه اجراشده در اصفهان',
|
||||
mapImagePlaceholder: 'تصویر پروژه',
|
||||
}
|
||||
|
||||
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.',
|
||||
|
||||
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',
|
||||
|
||||
mapAria: 'Projects location map',
|
||||
mapLoading: 'Loading map…',
|
||||
mapErrorText: 'The map failed to load — check the tile server connection.',
|
||||
viewProject: 'View project',
|
||||
mapFullscreen: 'Fullscreen',
|
||||
mapExitFullscreen: 'Exit fullscreen',
|
||||
viewAddress: 'View project location',
|
||||
mapNewBadge: 'New',
|
||||
mapProjectDescription: 'Project delivered in Isfahan',
|
||||
mapImagePlaceholder: 'Project image',
|
||||
}
|
||||
|
||||
export const dict = { fa, en }
|
||||
export type DictKey = keyof Dict
|
||||
@@ -0,0 +1,100 @@
|
||||
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')
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// بومیسازی درخت داده سایت — انتخاب فیلدهای انگلیسی با برگشت به فارسی.
|
||||
// فرانتاند هر دو زبان را در یک درخواست از 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),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
/* END TAILWIND IMPORTS — ALL OTHER CSS MUST GO BELOW THIS LINE */
|
||||
|
||||
/* فونت سایت سلفهاست است (public/fonts) — هیچ وابستگی خارجی وجود ندارد.
|
||||
Pinar فونت اصلی است (۵ وزن) و Vazirmatn بهعنوان جایگزین پشتیبان میماند.
|
||||
نسخه انگلیسی از پشته فونت سیستمی لاتین استفاده میکند (Inter در صورت نصب بودن). */
|
||||
@font-face {
|
||||
font-family: 'Pinar';
|
||||
src: url('/fonts/pinar/Pinar-Light.woff2') format('woff2'),
|
||||
@@ -69,6 +72,21 @@ body {
|
||||
font-family: 'Pinar', 'Vazirmatn', Tahoma, sans-serif;
|
||||
-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 {
|
||||
background-color: #d5874c;
|
||||
color: #1e1a16;
|
||||
@@ -112,6 +130,8 @@ body {
|
||||
}
|
||||
.article-body h2 {
|
||||
font-size: 16px;
|
||||
padding-inline-start: 0.85rem;
|
||||
border-inline-start: 3px solid #d5874c;
|
||||
}
|
||||
.article-body h3 {
|
||||
font-size: 14px;
|
||||
@@ -177,7 +197,7 @@ body {
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
margin-top: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
.article-body ul {
|
||||
list-style: disc;
|
||||
@@ -195,7 +215,7 @@ body {
|
||||
}
|
||||
.article-body blockquote {
|
||||
margin-top: 2rem;
|
||||
border-right: 3px solid #d5874c;
|
||||
border-inline-start: 3px solid #d5874c;
|
||||
background: rgba(30, 26, 22, 0.5);
|
||||
padding: 1rem 1.25rem;
|
||||
font-style: normal;
|
||||
@@ -346,4 +366,78 @@ body {
|
||||
}
|
||||
.aramesh-datepicker .rmdp-action-button {
|
||||
color: #d5874c;
|
||||
}
|
||||
|
||||
/* ---------- نقشه مپلیبر — کنترلها و پاپآپ همرنگ تم تیره ---------- */
|
||||
.lux-map-popup .maplibregl-popup-content {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* دم پاپآپ — همرنگ کارت شیشهای */
|
||||
.lux-map-popup .maplibregl-popup-tip {
|
||||
border-top-color: #1e1a16;
|
||||
}
|
||||
.maplibregl-ctrl-group {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(245, 243, 239, 0.14);
|
||||
border-radius: 0.75rem !important;
|
||||
background: rgba(30, 26, 22, 0.85) !important;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.maplibregl-ctrl-group button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.maplibregl-ctrl-group button:hover {
|
||||
background-color: rgba(213, 135, 76, 0.16) !important;
|
||||
}
|
||||
.maplibregl-ctrl-group button + button {
|
||||
border-top: 1px solid rgba(245, 243, 239, 0.12) !important;
|
||||
}
|
||||
/* آیکونهای پیشفرض کنترلها تیرهاند — با اینورتر روشن میشوند */
|
||||
.maplibregl-ctrl-group button .maplibregl-ctrl-icon {
|
||||
filter: invert(0.92);
|
||||
}
|
||||
.maplibregl-ctrl-attrib.maplibregl-compact {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(30, 26, 22, 0.85);
|
||||
color: rgba(245, 243, 239, 0.55);
|
||||
font-size: 10px;
|
||||
}
|
||||
.maplibregl-ctrl-attrib a {
|
||||
color: rgba(213, 135, 76, 0.9);
|
||||
}
|
||||
.maplibregl-ctrl-attrib-button {
|
||||
filter: invert(0.92);
|
||||
}
|
||||
.maplibregl-ctrl-scale {
|
||||
border-color: rgba(245, 243, 239, 0.35);
|
||||
border-radius: 2px;
|
||||
background: rgba(30, 26, 22, 0.7);
|
||||
color: rgba(245, 243, 239, 0.6);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---------- ورود نرم کارت پاپآپ و حالت تمامصفحه ---------- */
|
||||
@keyframes luxPopupIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes luxFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ProjectsMapAllProps } from './ProjectsMapAll'
|
||||
import type { ProjectsMapSingleProps } from './ProjectsMapSingle'
|
||||
|
||||
/**
|
||||
* بارگیری تنبل نقشهها — بستهی سنگین مپلیبر فقط وقتی دانلود میشود که
|
||||
* واقعاً نقشهای روی صفحه باشد؛ صفحههای بدون نقشه (مثل خانه) جریمهی حجم
|
||||
* نمیپردازند. Import زیر فقط نوع است و باندل را سنگین نمیکند.
|
||||
*/
|
||||
const ProjectsMapAll = lazy(() => import('./ProjectsMapAll').then((module) => ({ default: module.ProjectsMapAll })))
|
||||
const ProjectsMapSingle = lazy(() =>
|
||||
import('./ProjectsMapSingle').then((module) => ({ default: module.ProjectsMapSingle })),
|
||||
)
|
||||
|
||||
/** جاینگهدار هماندازهی نقشه تا چیدمان هنگام بارگیری نلرزد */
|
||||
function MapSkeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-3xl border border-ivory/10 bg-char/50 ${className}`} />
|
||||
}
|
||||
|
||||
export function LazyProjectsMapAll(props: ProjectsMapAllProps) {
|
||||
return (
|
||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||
<ProjectsMapAll {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function LazyProjectsMapSingle(props: ProjectsMapSingleProps) {
|
||||
return (
|
||||
<Suspense fallback={<MapSkeleton className={props.className} />}>
|
||||
<ProjectsMapSingle {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useEffect, useState, type MutableRefObject, type ReactNode } from 'react'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import { MdClose, MdFullscreen } from 'react-icons/md'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import type { MapStatus } from './types'
|
||||
|
||||
type MapViewportProps = {
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>
|
||||
/** نمونهی نقشه — فقط برای همگامسازی اندازه؛ مالکیتش با useMapLibre است */
|
||||
map: maplibregl.Map | null
|
||||
status: MapStatus
|
||||
onRetry: () => void
|
||||
/** برچسب دسترسپذیری ناحیهی نقشه */
|
||||
label: string
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||
className?: string
|
||||
/** محتوای شناور روی نقشه (پورتال پاپآپ) */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* پوستهی نقشه — قاب شیشهای، حالت تمامصفحه، کنترل بزرگنمایی و لایههای
|
||||
* بارگذاری/خطا.
|
||||
*
|
||||
* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد مینشیند (نه absolute)؛
|
||||
* استایل خود مپلیبر بعد از تیلویند بار میشود و position: relative میدهد،
|
||||
* پس کلاس absolute بیاثر میشد و ظرف صفر میماند.
|
||||
*
|
||||
* در تمامصفحه فقط شفافیت و گوشهها انیمیت میشوند تا بافر بوم هرگز کشیده
|
||||
* و تار نشود؛ تغییر اندازه در همان فریم اعمال میشود.
|
||||
*/
|
||||
export function MapViewport({
|
||||
containerRef,
|
||||
map,
|
||||
status,
|
||||
onRetry,
|
||||
label,
|
||||
className = '',
|
||||
children,
|
||||
}: MapViewportProps) {
|
||||
const { t } = useLang()
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
/** تمامصفحه: Esc برای خروج و قفل اسکرول پسزمینه */
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setIsFullscreen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = previousOverflow
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
/** پس از پایان انتقال، بوم را همگام کن (مپلیبر خودش هم ResizeObserver دارد) */
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return
|
||||
const timer = window.setTimeout(() => map?.resize(), 340)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isFullscreen, map])
|
||||
|
||||
const shellClass = isFullscreen
|
||||
? 'fixed inset-0 z-[75] h-[100dvh] w-screen animate-[luxFadeIn_0.25s_ease-out] rounded-none border-0'
|
||||
: `relative h-full w-full rounded-3xl border border-ivory/10 shadow-lift ${className}`
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
aria-label={label}
|
||||
className={`overflow-hidden bg-char font-pinar transition-all duration-300 ease-lux ${shellClass}`}
|
||||
>
|
||||
{/* ظرف نقشه در جریان عادی و با ارتفاع ۱۰۰٪ والد است؛ کلاس relative صریح
|
||||
است تا هم بستر موقعیتیابی فرزندان مپلیبر باشد و هم هشدار
|
||||
«container has a non-static position» و محاسبهی اشتباه آفست اسکرول
|
||||
رخ ندهد — ترتیب بار شدن CSS هیچوقت تعیینکننده نباشد */}
|
||||
<div ref={containerRef} className="relative h-full w-full" />
|
||||
|
||||
{isFullscreen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
aria-label={t('mapExitFullscreen')}
|
||||
className="fixed left-1/2 top-5 z-[80] flex h-11 -translate-x-1/2 items-center gap-2 rounded-full border border-copper/60 bg-ink/90 px-5 text-xs font-bold text-copper shadow-lift backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" aria-hidden="true" />
|
||||
{t('mapExitFullscreen')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
aria-label={t('mapFullscreen')}
|
||||
title={t('mapFullscreen')}
|
||||
className="absolute left-4 top-4 z-20 flex h-10 w-10 items-center justify-center rounded-xl border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-colors duration-200 ease-lux hover:border-copper hover:text-copper"
|
||||
>
|
||||
<MdFullscreen className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status !== 'ready' && (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-4 bg-char/70 backdrop-blur-sm">
|
||||
{status === 'loading' ? (
|
||||
<>
|
||||
<Loader2Icon className="h-7 w-7 animate-spin text-copper" aria-hidden="true" />
|
||||
<p className="text-xs font-light tracking-wide text-ivory/60">{t('mapLoading')}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="max-w-xs text-center text-xs font-light leading-6 text-ivory/60">
|
||||
{t('mapErrorText')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="border border-copper/50 px-6 py-2 text-xs font-bold text-copper transition-colors duration-200 hover:bg-copper hover:text-char"
|
||||
>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, type MouseEvent, type PointerEvent } from 'react'
|
||||
import { MdChevronLeft, MdChevronRight } from 'react-icons/md'
|
||||
import { ProjectImagePlaceholder } from '../components/ProjectImagePlaceholder'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { toPersianDigits } from '../utils/digits'
|
||||
|
||||
type ProjectImageSliderProps = {
|
||||
/** همان آرایهٔ تصاویر صفحهٔ جزئیات پروژه — در نبودش جاینگهدار میآید */
|
||||
images: string[]
|
||||
alt: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* اسلایدر تصویر لبهتالبهٔ بالای کارت پاپآپ (سبک Zillow): فلشهای چپ/راست،
|
||||
* شمارندهٔ اسلاید و گوشههای گرد هماندازهٔ کارت. همهٔ رویدادهای اشاره روی
|
||||
* اسلایدر متوقف میشوند تا کلیک/کشیدن آن، نقشه را جابهجا یا پاپآپ را نبندد.
|
||||
*/
|
||||
export function ProjectImageSlider({ images, alt, className = '' }: ProjectImageSliderProps) {
|
||||
const { t } = useLang()
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
/** هیچ رویدادی از اسلایدر به نقشهٔ زیرین نمیرسد */
|
||||
const stop = (event: MouseEvent | PointerEvent) => event.stopPropagation()
|
||||
|
||||
const frame = `relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-char ${className}`
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<ProjectImagePlaceholder />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const current = Math.min(index, images.length - 1)
|
||||
const step = (delta: number) => setIndex((value) => (value + delta + images.length) % images.length)
|
||||
|
||||
const arrow =
|
||||
'absolute top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/85 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char'
|
||||
|
||||
return (
|
||||
<div className={frame} onClick={stop} onPointerDown={stop} onMouseDown={stop}>
|
||||
<img src={images[current]} alt={alt} draggable={false} className="h-full w-full object-cover" />
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('prevImage')}
|
||||
title={t('prevImage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
step(-1)
|
||||
}}
|
||||
className={`${arrow} left-2`}
|
||||
>
|
||||
<MdChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nextImage')}
|
||||
title={t('nextImage')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
step(1)
|
||||
}}
|
||||
className={`${arrow} right-2`}
|
||||
>
|
||||
<MdChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<span className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full bg-ink/75 px-2.5 py-0.5 text-[10px] font-light text-ivory/85 backdrop-blur-md">
|
||||
{toPersianDigits(current + 1)} / {toPersianDigits(images.length)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MdLocationOn } from 'react-icons/md'
|
||||
|
||||
type ProjectPinProps = {
|
||||
label: string
|
||||
/** پروژهی انتخابشده — هالهی مسی پایدار */
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* سنجاق پروژه روی نقشه — آیکون MdLocationOn در رنگ مسی برند با هالهی
|
||||
* نورانی در هاور، کلیک و حالت انتخابشده. این کامپوننت داخل المان نشانگر
|
||||
* مپلیبر رندر میشود، پس خودش هیچ چیدمان بیرونی ندارد.
|
||||
*/
|
||||
export function ProjectPin({ label, active = false, onClick }: ProjectPinProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className="group flex cursor-pointer items-end justify-center bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-copper"
|
||||
>
|
||||
<MdLocationOn
|
||||
className={`h-9 w-9 text-[#d5874c] transition-all duration-300 ease-lux group-hover:scale-125 group-hover:drop-shadow-[0_0_14px_rgba(213,135,76,0.95)] group-active:scale-110 group-active:drop-shadow-[0_0_18px_rgba(213,135,76,1)] ${
|
||||
active
|
||||
? 'scale-110 drop-shadow-[0_0_16px_rgba(213,135,76,0.95)]'
|
||||
: 'drop-shadow-[0_3px_6px_rgba(0,0,0,0.65)]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { MdClose } from 'react-icons/md'
|
||||
import type { MapProject } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { ProjectImageSlider } from './ProjectImageSlider'
|
||||
|
||||
/** دکمههای کارت — تمامعرض، همخانوادهٔ دکمههای مسی سایت (در ستون هم درست مینشینند) */
|
||||
export const POPUP_PRIMARY_CLASS =
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-xl bg-copper px-3 py-2.5 text-[12px] font-bold text-char transition-all duration-300 ease-lux hover:scale-[1.03] hover:shadow-[0_8px_22px_rgba(213,135,76,0.35)] active:scale-95'
|
||||
|
||||
export const POPUP_SECONDARY_CLASS =
|
||||
'flex w-full items-center justify-center gap-1.5 rounded-xl border border-ivory/20 px-3 py-2.5 text-[12px] font-light text-ivory/80 transition-colors duration-300 ease-lux hover:border-copper hover:text-copper'
|
||||
|
||||
type ProjectPopupCardProps = {
|
||||
project: MapProject
|
||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات؛ در نبودش جاینگهدار میآید */
|
||||
images?: string[]
|
||||
onClose: () => void
|
||||
/** دکمههای پایهٔ کارت — هر نما اکشنهای خودش را میدهد (عمودی روی هم) */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* کارت پاپآپ پروژه به سبک Zillow — اسلایدر لبهتالبه در بالاترین بخش، سپس
|
||||
* عنوان و توضیح، و در پایه دکمههای عمودی. راستبهچپ، شیشهای تیره و
|
||||
* هماندازهٔ کارتهای سایت. سال پروژه عمداً نمایش داده نمیشود.
|
||||
*/
|
||||
export function ProjectPopupCard({ project, images = [], onClose, children }: ProjectPopupCardProps) {
|
||||
const { t } = useLang()
|
||||
const description = project.description.trim() || t('mapProjectDescription')
|
||||
|
||||
return (
|
||||
<article
|
||||
dir="rtl"
|
||||
className="w-[17rem] max-w-[calc(100vw-2.5rem)] animate-[luxPopupIn_0.22s_ease-out] overflow-hidden rounded-2xl border border-ivory/10 bg-[#27221c]/95 font-pinar text-ivory shadow-[0_24px_60px_-15px_rgba(0,0,0,0.9)] backdrop-blur-xl sm:w-72"
|
||||
>
|
||||
{/* اسلایدر تصویر: لبهتالبه، بدون حاشیه، با فلشهای چپ/راست */}
|
||||
<div className="relative">
|
||||
<ProjectImageSlider images={images} alt={project.name} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
className="absolute left-2.5 top-2.5 z-20 flex h-8 w-8 items-center justify-center rounded-full border border-ivory/15 bg-ink/80 text-ivory/80 backdrop-blur-md transition-all duration-300 ease-lux hover:scale-105 hover:border-copper hover:bg-copper hover:text-char"
|
||||
>
|
||||
<MdClose className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{project.isNew && (
|
||||
<span className="absolute right-2.5 top-2.5 z-20 rounded-full bg-copper px-2.5 py-1 text-[10px] font-bold text-char shadow-[0_4px_14px_rgba(213,135,76,0.4)]">
|
||||
{t('mapNewBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1 pt-3.5">
|
||||
<h3 className="min-w-0 truncate text-base font-bold text-ivory">{project.name}</h3>
|
||||
<p className="mt-1.5 line-clamp-2 text-[11px] font-light leading-6 text-ivory/60">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 pt-2.5">{children}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { MdArrowBack, MdOpenInNew } from 'react-icons/md'
|
||||
import {
|
||||
ISFAHAN_CENTER,
|
||||
MAP_PROJECTS,
|
||||
boundsForProjects,
|
||||
buildNeshanUrl,
|
||||
type MapProject,
|
||||
} from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { POPUP_PRIMARY_CLASS, POPUP_SECONDARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
import { useProjectMarkers } from './useProjectMarkers'
|
||||
|
||||
export type ProjectsMapAllProps = {
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-[520px]» */
|
||||
className?: string
|
||||
/** فهرست پروژهها — پیشفرض همهی ردیفهای شیت */
|
||||
projects?: MapProject[]
|
||||
/**
|
||||
* نشانی صفحهی جزئیات هر پروژه. اگر تطبیقی پیدا نشود، دکمهی «مشاهده پروژه»
|
||||
* به فهرست پروژهها میرود تا هیچ لینکی بیمقصد نماند.
|
||||
*/
|
||||
resolveProjectHref?: (project: MapProject) => string | undefined
|
||||
/** آرایهٔ تصاویر هر پروژه (همان آرایهٔ صفحهٔ جزئیات) — در نبودش جاینگهدار */
|
||||
imagesFor?: (project: MapProject) => string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای «همهی پروژهها» — همهی مختصات شیت روی یک نقشهی تمامعرض با
|
||||
* سنجاقهای مسی، پاپآپ کارتی و دو اکشن عمودی: صفحهی پروژه و آدرس نشان.
|
||||
* پاپآپ فقط با کلیک کاربر باز میشود و جابهجایی نقشه به محدودهٔ پروژهها
|
||||
* بسته است.
|
||||
*/
|
||||
export function ProjectsMapAll({
|
||||
className = '',
|
||||
projects = MAP_PROJECTS,
|
||||
resolveProjectHref,
|
||||
imagesFor,
|
||||
}: ProjectsMapAllProps) {
|
||||
const { t, href } = useLang()
|
||||
const bounds = useMemo(() => boundsForProjects(projects), [projects])
|
||||
const { containerRef, map, status, retry } = useMapLibre({ center: ISFAHAN_CENTER, zoom: 12, maxBounds: bounds })
|
||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||
|
||||
const projectHref = href((activeProject && resolveProjectHref?.(activeProject)) || '/projects')
|
||||
const images = activeProject ? (imagesFor?.(activeProject) ?? []) : []
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapAria')}
|
||||
className={className}
|
||||
>
|
||||
{popupHost &&
|
||||
activeProject &&
|
||||
createPortal(
|
||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||
<Link to={projectHref} className={POPUP_PRIMARY_CLASS}>
|
||||
{t('viewProject')}
|
||||
<MdArrowBack className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
{/* منطق دکمهٔ آدرس دستنخورده — فقط در ستون عمودی نشسته است */}
|
||||
<a
|
||||
href={buildNeshanUrl(activeProject)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={POPUP_SECONDARY_CLASS}
|
||||
>
|
||||
{t('viewAddress')}
|
||||
<MdOpenInNew className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</a>
|
||||
</ProjectPopupCard>,
|
||||
popupHost,
|
||||
)}
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MdOpenInNew } from 'react-icons/md'
|
||||
import { boundsForProjects, buildNeshanUrl, type MapProject } from '../data/mapProjects'
|
||||
import { useLang } from '../i18n/lang'
|
||||
import { MapViewport } from './MapViewport'
|
||||
import { POPUP_PRIMARY_CLASS, ProjectPopupCard } from './ProjectPopupCard'
|
||||
import { useMapLibre } from './useMapLibre'
|
||||
import { useProjectMarkers } from './useProjectMarkers'
|
||||
|
||||
export type ProjectsMapSingleProps = {
|
||||
/** همان پروژهی شیت — یا محل جایگزین ساختهشده با createPlaceholderProject */
|
||||
location: MapProject
|
||||
/** تصاویر پروژه — همان آرایهٔ صفحهٔ جزئیات (تصویر شاخص + گالری) */
|
||||
images?: string[]
|
||||
/** ارتفاع/فاصلهها از بیرون — مثل «h-80» */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* نمای «تکپروژه» — یک سنجاق دقیق روی مختصات همان پروژه و پاپآپی که فقط با
|
||||
* کلیک کاربر باز میشود. تنها اکشن کارت، مشاهدهٔ آدرس پروژه در نشان است.
|
||||
* جابهجایی نقشه به محدودهٔ همین پروژه بسته شده تا به بیرون نرود.
|
||||
*/
|
||||
export function ProjectsMapSingle({ location, images = [], className = '' }: ProjectsMapSingleProps) {
|
||||
const { t } = useLang()
|
||||
const projects = useMemo(() => [location], [location])
|
||||
const bounds = useMemo(() => boundsForProjects(projects, 0.008), [projects])
|
||||
const { containerRef, map, status, retry } = useMapLibre({
|
||||
center: [location.lng, location.lat],
|
||||
zoom: 15,
|
||||
maxBounds: bounds,
|
||||
})
|
||||
const { popupHost, activeProject, closePopup } = useProjectMarkers(map, projects)
|
||||
|
||||
return (
|
||||
<MapViewport
|
||||
containerRef={containerRef}
|
||||
map={map}
|
||||
status={status}
|
||||
onRetry={retry}
|
||||
label={t('mapAria')}
|
||||
className={className}
|
||||
>
|
||||
{popupHost &&
|
||||
activeProject &&
|
||||
createPortal(
|
||||
<ProjectPopupCard project={activeProject} images={images} onClose={closePopup}>
|
||||
{/* منطق دکمهٔ آدرس دستنخورده است — تنها اکشن این کارت */}
|
||||
<a
|
||||
href={buildNeshanUrl(activeProject)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={POPUP_PRIMARY_CLASS}
|
||||
>
|
||||
{t('viewAddress')}
|
||||
<MdOpenInNew className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</ProjectPopupCard>,
|
||||
popupHost,
|
||||
)}
|
||||
</MapViewport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { Protocol } from 'pmtiles'
|
||||
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
|
||||
let setup: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* آمادهسازی وبورکر نقشه — یکبار برای همه نقشهها.
|
||||
*
|
||||
* ۱) نشانی وبورکر مپلیبر صریح تعیین میشود: مپلیبر بهصورت پیشفرض
|
||||
* «./maplibre-gl-worker.mjs» را کنار باندل جستوجو میکند که در خروجی ویت
|
||||
* وجود ندارد و ورکر ۴۰۴ میشود؛ نسخهی ساختهشدهی ویت را با setWorkerUrl
|
||||
* میدهیم (نشانی مطلق، چون پایهی ورکر میتواند Blob باشد).
|
||||
* ۲) پروتکل pmtiles در نخ اصلی ثبت میشود.
|
||||
* ۳) همان پروتکل با importScriptInWorkers داخل وبورکر هم ثبت میشود —
|
||||
* کاشیهای برداری آنجا بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||
* جداست. اسکریپت وبورکر، خروجی خودبسندهی public/map/pmtiles-worker.mjs
|
||||
* است (ساختهشده از src/map/pmtiles.worker.ts با npm run build:map-worker).
|
||||
*
|
||||
* تا پایان بارگیری اسکریپت صبر میکنیم تا نخستین درخواستهای کاشی در وبورکر
|
||||
* بیپاسخ نمانند.
|
||||
*/
|
||||
export function ensureMapWorker(): Promise<void> {
|
||||
if (!setup) {
|
||||
maplibregl.setWorkerUrl(new URL(maplibreWorkerUrl, window.location.href).href)
|
||||
maplibregl.addProtocol('pmtiles', new Protocol().tile)
|
||||
|
||||
const protocolScript = new URL(
|
||||
`${import.meta.env.BASE_URL}map/pmtiles-worker.mjs`,
|
||||
window.location.href,
|
||||
).href
|
||||
setup = maplibregl.importScriptInWorkers(protocolScript)
|
||||
}
|
||||
return setup
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Protocol } from 'pmtiles'
|
||||
|
||||
/**
|
||||
* اسکریپت سمت وبورکر مپلیبر — با importScriptInWorkers داخل وبورکر اجرا
|
||||
* میشود و مپلیبر تابع addProtocol را روی self وبورکر گذاشته است.
|
||||
*
|
||||
* کاشیهای برداری در وبورکر بارگیری میشوند و رجیستری پروتکل در هر کانتکست
|
||||
* جداست؛ بدون این ثبت، درخواستهای pmtiles در وبورکر بیپاسخ میمانند و
|
||||
* نقشه فقط پسزمینه را نشان میدهد.
|
||||
*/
|
||||
const workerScope = self as unknown as {
|
||||
addProtocol: (name: string, loadFn: unknown) => void
|
||||
}
|
||||
|
||||
workerScope.addProtocol('pmtiles', new Protocol().tile)
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SourceSpecification, StyleSpecification } from 'maplibre-gl'
|
||||
import { MAP_BASE_URL } from '../config'
|
||||
|
||||
const PMTILES_SCHEME = 'pmtiles://'
|
||||
/** نشانیهای مطلق — دستنخورده میمانند */
|
||||
const EXTERNAL = /^(https?:|data:|blob:)/i
|
||||
/** تنها آرشیو برداری باکت — منبع برداری سبک همیشه به همین جا میرسد */
|
||||
const PMTILES_ARCHIVE = 'iran.pmtiles'
|
||||
/** نام منبع برداری در سبکهای استاندارد OpenMapTiles */
|
||||
const OPENMAPTILES_KEY = 'openmaptiles'
|
||||
/** پوشه فونتهای گلیف در باکت MinIO — سبک خام فقط «{fontstack}/{range}.pbf» دارد */
|
||||
const GLYPHS_FOLDER = 'v2.0'
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
/** نشانی آرشیو برداری روی MinIO — تنها منبع کاشی این پروژه */
|
||||
function archiveUrl(base: string): string {
|
||||
return `${PMTILES_SCHEME}${joinUrl(base, PMTILES_ARCHIVE)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگذاری جانگهدارهای سبک — «{styleJsonFolder}» به آدرس پایه MinIO تبدیل
|
||||
* میشود. جانگهدارهای {fontstack} و {range} قانونی خود مپلیبرند و دستنخورده
|
||||
* میمانند. جانگهدار تازهای در سبکهای بعدی دیده شد، به همین نگاشت اضافه شود.
|
||||
*/
|
||||
function resolvePlaceholders(url: string, base: string): string {
|
||||
return url.replace(/\{styleJsonFolder\}/g, base)
|
||||
}
|
||||
|
||||
/** مطلقسازی نشانیهای عمومی (گلیف، اسپرایت، منابع غیربرداری) */
|
||||
function absolutize(resource: string, base: string): string {
|
||||
const url = resolvePlaceholders(resource.trim(), base)
|
||||
if (EXTERNAL.test(url)) return url
|
||||
|
||||
if (url.startsWith(PMTILES_SCHEME)) {
|
||||
const target = url.slice(PMTILES_SCHEME.length).trim()
|
||||
return EXTERNAL.test(target) ? url : PMTILES_SCHEME + joinUrl(base, target)
|
||||
}
|
||||
|
||||
return joinUrl(base, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* نشانی گلیفها — سبک خام فقط الگوی «{fontstack}/{range}.pbf» را دارد و پوشه
|
||||
* فونتها را نمیآورد، اما فونتهای باکت زیر پوشه v2.0 هستند؛ پس الگو همیشه
|
||||
* به همان پوشه میرسد و جانگهدارهای {fontstack} و {range} دستنخورده میمانند.
|
||||
*/
|
||||
function resolveGlyphsUrl(url: string, base: string): string {
|
||||
const resolved = resolvePlaceholders(url.trim(), base)
|
||||
if (EXTERNAL.test(resolved)) return resolved
|
||||
|
||||
if (resolved.includes('{fontstack}')) {
|
||||
const template = resolved.slice(resolved.indexOf('{fontstack}'))
|
||||
return joinUrl(base, joinUrl(GLYPHS_FOLDER, template))
|
||||
}
|
||||
|
||||
return joinUrl(base, resolved)
|
||||
}
|
||||
|
||||
/** منبع برداری سبک — با نوع vector یا نام شناختهشده openmaptiles */
|
||||
function isVectorSource(key: string, source: SourceSpecification): boolean {
|
||||
return source.type === 'vector' || key === OPENMAPTILES_KEY
|
||||
}
|
||||
|
||||
/**
|
||||
* بازنویسی نشانی منبع برداری. سبکهای ساختهشده برای سرور MBTiles (مثل
|
||||
* «mbtiles://{v3}» در tileserver-gl) کاشی برداری را با طرح mbtiles آدرس
|
||||
* میدهند که مپلیبر نمیشناسد؛ هر شکلی از نشانی اینجا به آرشیو iran.pmtiles
|
||||
* روی MinIO هدایت میشود تا با پروتکل pmtiles رندر شود.
|
||||
*/
|
||||
function resolveVectorUrl(url: string, base: string): string {
|
||||
const resolved = resolvePlaceholders(url.trim(), base)
|
||||
|
||||
if (resolved.startsWith(PMTILES_SCHEME)) {
|
||||
const target = resolved.slice(PMTILES_SCHEME.length).trim()
|
||||
return EXTERNAL.test(target) ? resolved : PMTILES_SCHEME + joinUrl(base, target)
|
||||
}
|
||||
|
||||
return archiveUrl(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* پالایش سبک نقشه پیش از تحویل به مپلیبر — style.json خام MinIO با
|
||||
* جانگهدار ({styleJsonFolder}) و نشانی MBTiles نوشته شده است؛ این تابع با یک
|
||||
* پاس، گلیف/اسپرایت را به آدرس مطلق MinIO و منبع برداری را به آرشیو
|
||||
* iran.pmtiles تبدیل میکند تا مستقیم به «new Map({ style })» داده شود.
|
||||
*/
|
||||
export function rewriteStyleUrls(
|
||||
style: StyleSpecification,
|
||||
base: string = MAP_BASE_URL,
|
||||
): StyleSpecification {
|
||||
const next: StyleSpecification = { ...style }
|
||||
|
||||
if (typeof next.glyphs === 'string') {
|
||||
next.glyphs = resolveGlyphsUrl(next.glyphs, base)
|
||||
}
|
||||
|
||||
// اسپرایت در نسخه ۸ مشخصات سبک، رشته یا فهرست {id, url} است
|
||||
if (typeof next.sprite === 'string') {
|
||||
next.sprite = absolutize(next.sprite, base)
|
||||
} else if (Array.isArray(next.sprite)) {
|
||||
next.sprite = next.sprite.map((sprite) => ({ ...sprite, url: absolutize(sprite.url, base) }))
|
||||
}
|
||||
|
||||
const sources: StyleSpecification['sources'] = {}
|
||||
for (const [key, source] of Object.entries(next.sources ?? {})) {
|
||||
const rewritten = { ...source } as SourceSpecification & { url?: unknown; tiles?: unknown }
|
||||
|
||||
if (typeof rewritten.url === 'string') {
|
||||
const raw = rewritten.url.trim()
|
||||
if (isVectorSource(key, source)) {
|
||||
rewritten.url = resolveVectorUrl(raw, base)
|
||||
} else {
|
||||
// رندر سختگیرانهی برداری: منابع غیربرداری (مثل raster) هرگز به آرشیو
|
||||
// برداری هدایت نمیشوند — کاشی MVT زیر منبع raster یعنی خروجی خراب.
|
||||
if (import.meta.env.DEV && source.type === 'raster') {
|
||||
console.warn(`[map] منبع غیربرداری «${key}» در سبک هست؛ افزایش رزولوشن برداری برای آن اعمال نمیشود`)
|
||||
}
|
||||
rewritten.url = absolutize(raw, base)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rewritten.tiles)) {
|
||||
rewritten.tiles = rewritten.tiles.map((tile) =>
|
||||
typeof tile === 'string' ? absolutize(tile, base) : tile,
|
||||
)
|
||||
}
|
||||
sources[key] = rewritten
|
||||
}
|
||||
next.sources = sources
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** بارگیری style.json از MinIO و بازگرداندن سبک پالایششده برای «new Map({ style })» */
|
||||
export async function loadMapStyle(signal?: AbortSignal): Promise<StyleSpecification> {
|
||||
const res = await fetch(joinUrl(MAP_BASE_URL, 'style.json'), { signal })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return rewriteStyleUrls((await res.json()) as StyleSpecification)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RequestTransformFunction } from 'maplibre-gl'
|
||||
|
||||
/** دو بخش آخر مسیر گلیف: پوشهی پشتهی فونت و فایل بازه */
|
||||
const GLYPH_PATH = /\/([^/?#]+)\/([^/?#]+\.pbf)$/
|
||||
|
||||
/**
|
||||
* نشانی گلیف با تنها فونت نخستِ پشته.
|
||||
*
|
||||
* مپلیبر برای هر لایه، تمام `text-font` را با کاما در {fontstack} میگذارد
|
||||
* (مثل «Metropolis Regular,Noto Sans Regular»)؛ سرورهای عمومی فونت، پوشهی
|
||||
* همین پشتهی ترکیبی را دارند اما MinIO این پروژه فونتها را در پوشههای
|
||||
* تکفونتی نگه میدارد. نتیجهی آن درخواستها ۴۰۴ بود، متنها رندر نمیشدند و
|
||||
* سیل خطا اپ را قفل میکرد. با این بازنویسی فقط فونت نخست — همان ترجیح اول
|
||||
* سبک — درخواست میشود و بقیهی مسیر (پوشهی v2.0 و فایل بازه) دستنخورده
|
||||
* میماند. نشانی بدون پشتهی ترکیبی یا ناشناس، بدون تغییر برمیگردد.
|
||||
*/
|
||||
export function singleFontGlyphUrl(url: string): string {
|
||||
const queryIndex = url.search(/[?#]/)
|
||||
const path = queryIndex === -1 ? url : url.slice(0, queryIndex)
|
||||
const suffix = queryIndex === -1 ? '' : url.slice(queryIndex)
|
||||
|
||||
const match = path.match(GLYPH_PATH)
|
||||
if (!match) return url
|
||||
|
||||
let fonts: string[]
|
||||
try {
|
||||
fonts = match[1].split(',').map((font) => decodeURIComponent(font).trim())
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
if (fonts.length < 2 || !fonts[0]) return url
|
||||
|
||||
return `${path.slice(0, match.index)}/${encodeURIComponent(fonts[0])}/${match[2]}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* قلاب تبدیل درخواستهای نقشه — فقط گلیفها بازنویسی میشوند و هر درخواست
|
||||
* دیگری دستنخورده میماند تا رفتار پیشفرض مپلیبر حفظ شود.
|
||||
*/
|
||||
export const mapTransformRequest: RequestTransformFunction = (url, resourceType) =>
|
||||
resourceType === 'Glyphs' ? { url: singleFontGlyphUrl(url) } : { url }
|
||||
@@ -0,0 +1,2 @@
|
||||
/** وضعیت چرخهی حیات نقشه — بارگذاری سبک، آماده یا خطای قابل تلاش دوباره */
|
||||
export type MapStatus = 'loading' | 'ready' | 'error'
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ensureMapWorker } from './mapWorker'
|
||||
import { loadMapStyle } from './styleLoader'
|
||||
import { mapTransformRequest } from './transformRequest'
|
||||
import type { MapStatus } from './types'
|
||||
|
||||
type UseMapLibreOptions = {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
/** بزرگنمایی با چرخ ماوس و پینچ لمسی — پیشفرض فعال */
|
||||
scrollZoom?: boolean
|
||||
/** محدودهٔ مجاز جابهجایی نقشه — جلوگیری از رفتن به بیرون محدودهٔ پروژهها */
|
||||
maxBounds?: maplibregl.LngLatBoundsLike
|
||||
}
|
||||
|
||||
/** سقف انتظار سبک نقشه — سرور کاشی بیپاسخ یعنی خطای قابل تلاش دوباره، نه اسپینر بیپایان */
|
||||
const STYLE_TIMEOUT_MS = 10_000
|
||||
/** اگر رویداد style.load دیر برسد، وضعیت آماده با این مهلت نمایش داده میشود */
|
||||
const READY_FALLBACK_MS = 2_000
|
||||
|
||||
/**
|
||||
* سقف بزرگنمایی = maxzoom آرشیو + همین مقدار سطح overzoom.
|
||||
*
|
||||
* سنجش روی تایلهای واقعی z14 اصفهان (۲۶٫۸۷۴ پارهخط جاده): میانهی وتر ۲۰
|
||||
* متر و صدک ۷۵ برابر ۳۶ متر است. با سه سطح overzoom (زوم ۱۷، ۰٫۵ متر بر
|
||||
* پیکسل) میانه ۴۰ و صدک ۷۵ حدود ۷۲ پیکسل میشود که خمها را هنوز صاف نشان
|
||||
* میدهد؛ از ۱۷٫۵ به بعد وترها به ۱۰۰ پیکسل و بیشتر میرسند و چندضلعیشدن
|
||||
* خمها دیده میشود. متن و آیکون هرگز تار نمیشوند (متن SDF و آیکون اسپرایت
|
||||
* است)؛ افت کیفیت فقط از هندسهی سادهشده میآید. با بازتولید آرشیو در z16
|
||||
* این سقف خودکار به ۱۹ میرسد.
|
||||
*/
|
||||
const OVERZOOM_LEVELS = 3
|
||||
/** سقف مطلق زوم — بالاتر از آن حتی با دادهی دقیقتر هم نمایش مرورگر بیمعناست */
|
||||
const ABSOLUTE_MAX_ZOOM = 22
|
||||
/** سقف جایگزین وقتی maxzoom منبع در دسترس نیست — برابر آرشیو فعلی (z14 + ۳) */
|
||||
const FALLBACK_MAX_ZOOM = 17
|
||||
|
||||
export function useMapLibre({ center, zoom, scrollZoom = true, maxBounds }: UseMapLibreOptions) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [map, setMap] = useState<maplibregl.Map | null>(null)
|
||||
const [status, setStatus] = useState<MapStatus>('loading')
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let disposed = false
|
||||
let instance: maplibregl.Map | null = null
|
||||
let readyFallback: number | undefined
|
||||
let dprQuery: MediaQueryList | null = null
|
||||
let handleDprChange: (() => void) | null = null
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS)
|
||||
|
||||
setStatus('loading')
|
||||
setMap(null)
|
||||
|
||||
// وبورکر نقشه و پروتکل pmtiles باید پیش از ساخت نقشه آماده باشند؛
|
||||
// وگرنه نخستین درخواستهای کاشی در وبورکر بیپاسخ میمانند
|
||||
Promise.all([loadMapStyle(controller.signal), ensureMapWorker()])
|
||||
.then(([style]) => {
|
||||
if (disposed) return
|
||||
instance = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center,
|
||||
zoom,
|
||||
maxBounds,
|
||||
// سقف اولیه تا رسیدن maxzoom واقعی منبع؛ سپس applyZoomLimit دقیق میکند
|
||||
maxZoom: FALLBACK_MAX_ZOOM,
|
||||
renderWorldCopies: false,
|
||||
// لبههای نرمتر خطوط و چندضلعیها — حس لوکس رندر برداری
|
||||
canvasContextAttributes: { antialias: true },
|
||||
// کیفیت حداکثری در بزرگنماییهای عمیق: هیچ سطحی با مقیاسکردن سطح
|
||||
// بالاتر رندر نمیشود و همهی سطحها تا maxZoom از تقسیم هندسه
|
||||
// میآیند (پیشفرض ۴ سطح آخر را overscale میکند). مبادله: کار
|
||||
// بیشتر پردازنده در زومهای عمیق.
|
||||
zoomLevelsToOverscale: 0,
|
||||
// چرخ ماوس، پینچ لمسی و دکمههای کنترل — مطابق درخواست کاربر
|
||||
scrollZoom,
|
||||
attributionControl: { compact: true },
|
||||
// MinIO فونتها را در پوشههای تکفونتی دارد، پس پشتهی ترکیبی
|
||||
// text-font («Metropolis Regular,Noto Sans Regular») به فونت نخست
|
||||
// بازنویسی میشود — توضیح کامل در transformRequest.ts
|
||||
transformRequest: mapTransformRequest,
|
||||
})
|
||||
|
||||
/**
|
||||
* سقف بزرگنمایی را از maxzoom واقعی آرشیو میسازد؛ با دادهی دقیقتر
|
||||
* (بازتولید آرشیو در z بالاتر) خودکار بالاتر میرود و کیفیت روی صفحه
|
||||
* ثابت میماند. زوم جاری هم اگر از سقف بیشتر باشد به آن محدود میشود.
|
||||
*/
|
||||
const applyZoomLimit = () => {
|
||||
if (!instance) return
|
||||
const source = instance.getSource('openmaptiles') as { maxzoom?: number } | undefined
|
||||
const sourceMaxZoom = typeof source?.maxzoom === 'number' ? source.maxzoom : null
|
||||
instance.setMaxZoom(
|
||||
sourceMaxZoom === null
|
||||
? FALLBACK_MAX_ZOOM
|
||||
: Math.min(sourceMaxZoom + OVERZOOM_LEVELS, ABSOLUTE_MAX_ZOOM),
|
||||
)
|
||||
}
|
||||
|
||||
instance.on('style.load', () => {
|
||||
if (disposed) return
|
||||
setStatus('ready')
|
||||
window.setTimeout(() => instance?.resize(), 150)
|
||||
})
|
||||
|
||||
/**
|
||||
* کنترل بزرگنمایی همراه خود نقشه ساخته میشود و با map.remove() پایین
|
||||
* میآید — map.remove بهتنهایی onRemove همهی کنترلها را صدا میزند،
|
||||
* پس removeControl دستی پس از آن، onRemove را دوباره اجرا میکند و با
|
||||
* «reading 'off' of undefined» کرش میکند. مالکیت کنترل همینجا، کنار
|
||||
* مالکیت نقشه میماند تا ترتیب پاکسازی هیچوقت به React وابسته نباشد.
|
||||
*/
|
||||
instance.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), 'top-right')
|
||||
|
||||
// maxzoom منبع همراه متادیتای TileJSON میرسد — همان لحظه سقف را قطعی کن
|
||||
instance.on('sourcedata', (e) => {
|
||||
if (e.sourceDataType === 'metadata' && e.sourceId === 'openmaptiles') applyZoomLimit()
|
||||
})
|
||||
instance.on('load', applyZoomLimit)
|
||||
applyZoomLimit()
|
||||
|
||||
// تایمر فالبک امنیتی
|
||||
readyFallback = window.setTimeout(() => {
|
||||
if (disposed) return
|
||||
setStatus('ready')
|
||||
instance?.resize()
|
||||
}, READY_FALLBACK_MS)
|
||||
|
||||
instance.on('error', (e) => {
|
||||
console.warn('MapLibre minor error:', e)
|
||||
})
|
||||
|
||||
/**
|
||||
* نسبت پیکسل دستگاه منبع حقیقت بافر بوم است؛ با زوم مرورگر
|
||||
* (Ctrl+zoom) یا جابهجایی پنجره روی مانیتور با چگالی دیگر عوض
|
||||
* میشود. اگر بافر با نسبت تازه همگام نشود، نقشه تا رفرش بعدی تار
|
||||
* میماند. matchMedia هر تغییر را دقیقاً همان لحظه گزارش میدهد و
|
||||
* پس از هر تغییر دوباره مسلح میشود.
|
||||
*/
|
||||
function syncPixelRatio() {
|
||||
if (!instance) return
|
||||
if (instance.getPixelRatio() !== window.devicePixelRatio) {
|
||||
instance.setPixelRatio(window.devicePixelRatio)
|
||||
}
|
||||
instance.resize()
|
||||
}
|
||||
|
||||
function watchPixelRatio() {
|
||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
||||
handleDprChange = () => {
|
||||
syncPixelRatio()
|
||||
watchPixelRatio()
|
||||
}
|
||||
dprQuery.addEventListener('change', handleDprChange)
|
||||
}
|
||||
|
||||
watchPixelRatio()
|
||||
|
||||
// دسترسی اشکالزدایی در محیط توسعه — در بیلد تولیدی وجود ندارد
|
||||
if (import.meta.env.DEV) {
|
||||
(window as unknown as { __map?: maplibregl.Map }).__map = instance
|
||||
}
|
||||
|
||||
setMap(instance)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setStatus('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearTimeout(timeoutId)
|
||||
if (readyFallback !== undefined) window.clearTimeout(readyFallback)
|
||||
if (dprQuery && handleDprChange) dprQuery.removeEventListener('change', handleDprChange)
|
||||
controller.abort()
|
||||
instance?.remove()
|
||||
}
|
||||
// مرکز و بزرگنمایی فقط موقعیت اولیه نقشهاند؛ فهرست وابستگی عمداً فقط reloadKey است
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey])
|
||||
|
||||
const retry = useCallback(() => setReloadKey((key) => key + 1), [])
|
||||
|
||||
return { containerRef, map, status, retry }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { MapProject } from '../data/mapProjects'
|
||||
import { ProjectPin } from './ProjectPin'
|
||||
|
||||
type PinHandle = { project: MapProject; marker: maplibregl.Marker; root: Root }
|
||||
|
||||
/** حاشیهٔ امن پاپآپ از لبههای نقشه — بالای صفحه بازتر، برای نوار برچسب و کنترلها */
|
||||
const AUTO_PAN_PADDING = { top: 150, bottom: 20, left: 20, right: 20 }
|
||||
|
||||
/**
|
||||
* نگهداشتن پاپآپ داخل کادر دید — معادل رفتار autoPan که مپلیبر (برخلاف
|
||||
* مپباکس) گزینهاش را ندارد: اگر کارت از حاشیهٔ امن بیرون بزند، نقشه نرم
|
||||
* جابهجا میشود تا کارت کامل دیده شود.
|
||||
*/
|
||||
function ensurePopupVisible(instance: maplibregl.Map, popup: maplibregl.Popup): void {
|
||||
const element = popup.getElement()
|
||||
const container = instance.getContainer()
|
||||
if (!element || !container) return
|
||||
|
||||
// چیدمان پاپآپ در همان فریم addTo کامل میشود، پس اندازهگیری به فریم بعد موکول است
|
||||
window.requestAnimationFrame(() => {
|
||||
const popupRect = element.getBoundingClientRect()
|
||||
const viewRect = container.getBoundingClientRect()
|
||||
|
||||
const excessTop = viewRect.top + AUTO_PAN_PADDING.top - popupRect.top
|
||||
const excessBottom = popupRect.bottom - (viewRect.bottom - AUTO_PAN_PADDING.bottom)
|
||||
const excessLeft = viewRect.left + AUTO_PAN_PADDING.left - popupRect.left
|
||||
const excessRight = popupRect.right - (viewRect.right - AUTO_PAN_PADDING.right)
|
||||
|
||||
// کمبود افقی و عمودی؛ دوربین به اندازهٔ بیرونزدگی جابهجا میشود
|
||||
const dx = Math.max(0, excessLeft) - Math.max(0, excessRight)
|
||||
const dy = Math.max(0, excessTop) - Math.max(0, excessBottom)
|
||||
if (!dx && !dy) return
|
||||
|
||||
instance.panBy([-dx, -dy], { duration: 320 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* لایهی نشانگرهای پروژه — ساخت نشانگرها با React (هر نشانگر یک ریشهی
|
||||
* کوچک)، یک پاپآپ مشترک برای همه، و وضعیت پروژهی فعال.
|
||||
*
|
||||
* پاپآپ هیچوقت خودکار باز نمیشود؛ فقط با کلیک کاربر روی سنجاق. پاپآپ یک
|
||||
* المان میزبان دارد که بیرون از React ساخته میشود؛ مصرفکننده محتوای React را
|
||||
* با createPortal داخل همان میزبان میریزد تا Link روتر، حالت هاور و
|
||||
* انیمیشنها همه در درخت React بمانند.
|
||||
*/
|
||||
export function useProjectMarkers(map: maplibregl.Map | null, projects: MapProject[]) {
|
||||
const [popupHost, setPopupHost] = useState<HTMLDivElement | null>(null)
|
||||
const [activeProject, setActiveProject] = useState<MapProject | null>(null)
|
||||
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null)
|
||||
const pinsRef = useRef<PinHandle[]>([])
|
||||
const activeRef = useRef<MapProject | null>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
mapRef.current = map
|
||||
|
||||
const openProject = useCallback((project: MapProject) => {
|
||||
const popup = popupRef.current
|
||||
const instance = mapRef.current
|
||||
if (!popup || !instance) return
|
||||
activeRef.current = project
|
||||
popup.setLngLat([project.lng, project.lat]).addTo(instance)
|
||||
ensurePopupVisible(instance, popup)
|
||||
setActiveProject(project)
|
||||
}, [])
|
||||
|
||||
const closePopup = useCallback(() => {
|
||||
activeRef.current = null
|
||||
const popup = popupRef.current
|
||||
// remove() فقط روی پاپآپ باز صدا زده میشود؛ پاپآپ بسته مالک مرورگر است
|
||||
if (popup?.isOpen()) popup.remove()
|
||||
setActiveProject(null)
|
||||
}, [])
|
||||
|
||||
/** پاپآپ مشترک + المان میزبان محتوا — یکبار برای هر نمونه نقشه */
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const host = document.createElement('div')
|
||||
const popup = new maplibregl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: true,
|
||||
offset: 46,
|
||||
maxWidth: 'none',
|
||||
className: 'lux-map-popup',
|
||||
}).setDOMContent(host)
|
||||
|
||||
// بستن با کلیک روی نقشه یا دکمهی کارت، وضعیت فعال را هم پاک میکند
|
||||
popup.on('close', () => {
|
||||
activeRef.current = null
|
||||
setActiveProject(null)
|
||||
})
|
||||
|
||||
popupRef.current = popup
|
||||
setPopupHost(host)
|
||||
|
||||
return () => {
|
||||
// فقط پاپآپِ باز پایین آورده میشود؛ popup.remove خودش هم به رویداد
|
||||
// remove نقشه گوش میدهد، پس این پاکسازی تکراری و بیخطر است
|
||||
if (popup.isOpen()) popup.remove()
|
||||
popupRef.current = null
|
||||
setPopupHost(null)
|
||||
}
|
||||
}, [map])
|
||||
|
||||
/** نشانگرها — با تغییر فهرست پروژهها یا نمونهی نقشه بازساخته میشوند */
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const pins: PinHandle[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const element = document.createElement('div')
|
||||
const marker = new maplibregl.Marker({ element, anchor: 'bottom' })
|
||||
.setLngLat([project.lng, project.lat])
|
||||
.addTo(map)
|
||||
const root = createRoot(element)
|
||||
root.render(<ProjectPin label={project.name} active={false} onClick={() => openProject(project)} />)
|
||||
pins.push({ project, marker, root })
|
||||
}
|
||||
|
||||
pinsRef.current = pins
|
||||
|
||||
return () => {
|
||||
for (const pin of pins) {
|
||||
// نشانگر خودش پاکسازی امن دارد (حذف شنوندهها + برداشتن المان)
|
||||
pin.marker.remove()
|
||||
/**
|
||||
* unmount ریشهی React هنگام جدا شدن والد، وسط چرخهی رندر رخ میدهد و
|
||||
* React 18 برای unmount همگام ریشهی تودرتو هشدار میدهد
|
||||
* («Attempted to synchronously unmount a root while React was already
|
||||
* rendering»). پس به میکروتسک بعد از پایان کامیت موکول میشود؛ المان
|
||||
* نشانگر تا آن لحظه از DOM جدا شده و unmount روی ظرف جدا امن است.
|
||||
*/
|
||||
queueMicrotask(() => pin.root.unmount())
|
||||
}
|
||||
pinsRef.current = []
|
||||
}
|
||||
}, [map, projects, openProject])
|
||||
|
||||
/** با تغییر پروژهی فعال، فقط هالهی سنجاقها بهروز میشود (بازسازی نمیشوند) */
|
||||
useEffect(() => {
|
||||
for (const pin of pinsRef.current) {
|
||||
pin.root.render(
|
||||
<ProjectPin
|
||||
label={pin.project.name}
|
||||
active={activeProject?.id === pin.project.id}
|
||||
onClick={() => openProject(pin.project)}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}, [activeProject, openProject])
|
||||
|
||||
return { popupHost, activeProject, openProject, closePopup }
|
||||
}
|
||||