diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 3e4cd48..60ca893 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -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() diff --git a/backend/app/models.py b/backend/app/models.py index f5929a7..c8b9d31 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0e5811e..5eda507 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/seed.py b/backend/app/seed.py index 07b091e..8b851bb 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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: diff --git a/backend/app/seed_data_en.py b/backend/app/seed_data_en.py new file mode 100644 index 0000000..9da7a97 --- /dev/null +++ b/backend/app/seed_data_en.py @@ -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."}, +] diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py index 6df8cea..3ad0baf 100644 --- a/backend/app/seo_pages.py +++ b/backend/app/seo_pages.py @@ -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'\n' + f' \n' + f' ' + ) + + 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'', f'', f'', + f'', f'', ] 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"

{esc(title)}

{esc(description)}

" return HTMLResponse( - f'' - f"{esc(title)}{block}{body}" + f'' + f"{esc(title)}{alternates}{block}{body}" ) + if lang == "en": + # پوسته بیلدشده فارسی است — برای نسخه انگلیسی جهت، زبان و برند سند عوض می‌شود + raw = re.sub(r']*>', '', raw, count=1) + raw = raw.replace(FALLBACK_FA_BRAND, FALLBACK_EN_BRAND) + raw = raw.replace('"inLanguage":"fa"', '"inLanguage":"en"') + # description استاتیک حذف می‌شود تا با متای تزریقی تکراری نشود + raw = re.sub(r']*/>\s*', '', raw, count=1) + # og:locale — در پوسته استاتیک به‌روز می‌شود و از بلوک تزریقی حذف تا دوباره نوشته نشود + og_locale = "en_US" if lang == "en" else "fa_IR" + if re.search(r'.*?", f"{esc(title)}", raw, count=1, flags=re.S) - raw = raw.replace("", f" {block}\n ", 1) + raw = raw.replace("", f" {alternates}\n {block}\n ", 1) if noscript: raw = raw.replace("", f"\n ", 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" {esc(_absolute(loc))}\n" + f' \n' + f' \n' + f" " + ) + + @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" {html.escape(_absolute(u))}" for u in urls - ) + items = "\n".join(_sitemap_entry(u, u, _en_path(u)) for u in fa_urls) xml = ( '\n' - '\n' + '\n' f"{items}\n\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"

{html.escape(article.title)}

{html.escape(first_text)}

" + noscript = f"

{html.escape(title)}

{html.escape(first_text)}

" 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"

{html.escape(title)}

{html.escape(first_text)}

" + 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") diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 953e734..174c8a6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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"", 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" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4784fa5..e0ec848 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5e5147c..27f2e42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 94f729c..c2934a1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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> diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fa715f..3eb9337 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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,44 +113,44 @@ function AppChrome() { function AppShell() { const { data, error, reload } = useSiteData(); + const { t, dir, lang } = useLang(); return ( - <div dir="rtl" lang="fa" className="min-h-screen w-full overflow-x-hidden bg-ink font-pinar text-ivory"> + <div dir={dir} lang={lang} className="min-h-screen w-full overflow-x-hidden 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> ); diff --git a/frontend/src/admin/AdminBar.tsx b/frontend/src/admin/AdminBar.tsx index 2e83be1..1080f05 100644 --- a/frontend/src/admin/AdminBar.tsx +++ b/frontend/src/admin/AdminBar.tsx @@ -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]} diff --git a/frontend/src/admin/AdminContext.tsx b/frontend/src/admin/AdminContext.tsx index 8bed752..d70befc 100644 --- a/frontend/src/admin/AdminContext.tsx +++ b/frontend/src/admin/AdminContext.tsx @@ -4,8 +4,11 @@ import Swal from 'sweetalert2' import type { SiteData } from '../api' import { useSiteData } from '../api' import { adminApi, getToken, HttpError, setToken } 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) @@ -131,8 +148,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 +160,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 +178,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], ) } diff --git a/frontend/src/admin/ArticleWritePage.tsx b/frontend/src/admin/ArticleWritePage.tsx index cd668fe..a3f4c9f 100644 --- a/frontend/src/admin/ArticleWritePage.tsx +++ b/frontend/src/admin/ArticleWritePage.tsx @@ -15,8 +15,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 } /** داده‌های قدیمی ممکن است هشتگ را رشته کاماشده داشته باشند */ function normalizeTags(value: unknown): string[] { @@ -36,7 +36,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() @@ -49,16 +49,22 @@ 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: '', author: '', meta_description: '', read: '۵ دقیقه', + read_en: '', excerpt: '', + excerpt_en: '', body: EMPTY_DOC, + body_en: EMPTY_DOC, status: 'draft', seo_keyphrase: '', }) @@ -93,10 +99,15 @@ 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: (Array.isArray(article.body) ? EMPTY_DOC : article.body) as ArticleBodyDoc, + body_en: + rawEnBody && typeof rawEnBody === 'object' && !Array.isArray(rawEnBody) + ? (rawEnBody as ArticleBodyDoc) + : EMPTY_DOC, }) setCreatedId(article.id) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -195,6 +206,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 return ( <div dir="rtl" className="min-h-screen bg-ink font-pinar text-ivory"> @@ -211,6 +224,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]} @@ -245,7 +270,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" > @@ -276,11 +301,11 @@ export function ArticleWritePage() { : 'مقاله منتشرشده — تغییرها همان لحظه در سایت اعمال می‌شود'} </p> - {/* عنوان */} + {/* عنوان — در ویرایش انگلیسی، متن فارسی فعلی جای‌نگهدار می‌ماند */} <input type="text" - {...textField('title')} - placeholder="عنوان مقاله…" + {...textField(suf('title'))} + 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" /> @@ -304,16 +329,21 @@ export function ArticleWritePage() { /> </div> - {/* متن */} + {/* متن — ویرایشگر روی سند زبان جاری کار می‌کند */} <div className="mt-8"> <ArticleEditor - 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) => { - // محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن + // محاسبه خودکار زمان مطالعه بر اساس تعداد کلمات متن — به زبان جاری const textContent = JSON.stringify(json) const wordCount = textContent.split(/\s+/).length - const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه' - queueSave({ body: json, read: calculatedReadTime }) + if (editLang === 'en') { + const minutes = Math.max(1, Math.ceil(wordCount / 200)) + queueSave({ [suf('body')]: json, [suf('read')]: `${minutes} min read` }) + } else { + const calculatedReadTime = Math.max(1, Math.ceil(wordCount / 200)) + ' دقیقه' + queueSave({ body: json, read: calculatedReadTime }) + } }} /> </div> @@ -373,8 +403,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> @@ -428,9 +458,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> diff --git a/frontend/src/admin/EntityDrawer.tsx b/frontend/src/admin/EntityDrawer.tsx index ac0bea2..d9960d4 100644 --- a/frontend/src/admin/EntityDrawer.tsx +++ b/frontend/src/admin/EntityDrawer.tsx @@ -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 type { EntityConfig, FieldConfig } from './entities' import { ImageField } from '../components/admin/ImageField' import { JalaliDatePicker } from '../components/admin/JalaliDatePicker' @@ -22,11 +23,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() @@ -113,7 +119,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) => { // فیلد Alt تصویر: برای گالری همان ستون alt و برای بقیه <نام فیلد>_alt const altKey = field.type === 'image' @@ -126,6 +132,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={String(item[config.titleKey] ?? '')} onSave={(value) => saveField(field.name, value)} @@ -166,6 +173,7 @@ function FieldControl({ value, altValue, altFallback, + placeholder, onSave, onSaveAlt, registerFlush, @@ -175,6 +183,8 @@ function FieldControl({ /** مقدار Alt تصویر — فقط برای فیلدهای image */ altValue?: string altFallback?: string + /** مقدار فارسی همان فیلد — فقط در ویرایش انگلیسی به‌عنوان جای‌نگهدار نمایش داده می‌شود */ + placeholder?: unknown onSave: (value: unknown) => Promise<boolean> onSaveAlt?: (alt: string) => Promise<boolean> registerFlush?: (fn: () => void) => void @@ -238,14 +248,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} /> ) } @@ -256,6 +274,7 @@ function FieldControl({ <textarea dir="auto" rows={4} + placeholder={placeholder ? String(placeholder) : undefined} value={text} onChange={(e) => setText(e.target.value)} onBlur={commitText} @@ -272,6 +291,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} @@ -314,10 +334,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[]) : []) @@ -330,6 +352,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"> @@ -377,11 +400,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>>(() => value && typeof value === 'object' && !Array.isArray(value) @@ -427,6 +452,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> diff --git a/frontend/src/admin/entities.ts b/frontend/src/admin/entities.ts index fdb7452..5ec00dc 100644 --- a/frontend/src/admin/entities.ts +++ b/frontend/src/admin/entities.ts @@ -11,6 +11,8 @@ export type FieldConfig = { options?: string[] /** ویجت سفارشی نوع list — 'tags' یعنی react-select قابل ساخت (Creatable) */ ui?: 'lines' | 'tags' + /** فیلدهای مشترک بین دو زبان (تصویر، لینک، اسلاگ، وضعیت، آیکون، عدد) ترجمه نمی‌شوند */ + shared?: boolean } export type EntityConfig = { @@ -28,13 +30,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 در کشو و صفحه نوشتن نمایش داده می‌شوند */ @@ -50,16 +54,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'), ], @@ -71,30 +75,29 @@ export const ENTITIES: Record<string, EntityConfig> = { fields: [ F('title', 'عنوان'), F('body', 'متن مقاله', 'wysiwyg'), - F('excerpt', 'چکیده (روی کارت مقاله)'), - F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'), - F('image', 'تصویر شاخص', 'image'), - F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES }), - 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, true), + F('category', 'دسته‌بندی (Category)', 'select', undefined, { options: ARTICLE_CATEGORIES, 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: '', - image_alt: '', + defaults: { + title: 'مقاله جدید', + slug: `article-${Date.now()}`, + date: new Date().toISOString().split('T')[0], + category: ARTICLE_CATEGORIES[0], + tags: ['مدیریت پروژه', 'ساخت‌وساز'], + image: '', author: '', meta_description: '', - read: '۵ دقیقه', - excerpt: '', + read: '۵ دقیقه', + excerpt: '', seo_keyphrase: '', - body: { type: 'doc', content: [{ type: 'paragraph' }] } + body: { type: 'doc', content: [{ type: 'paragraph' }] } }, }, testimonials: { @@ -118,13 +121,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: { @@ -154,20 +157,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'), ], @@ -176,25 +179,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: { @@ -225,6 +228,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 -} \ No newline at end of file +} diff --git a/frontend/src/api.tsx b/frontend/src/api.tsx index e2e8dbf..4739467 100644 --- a/frontend/src/api.tsx +++ b/frontend/src/api.tsx @@ -3,11 +3,14 @@ import type { ReactNode } from 'react' import type { Article, Project, Service } from './data/site' import { API_URL } from './config' import { getToken } 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 { @@ -244,13 +306,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 +332,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 } diff --git a/frontend/src/components/ArticleCard.tsx b/frontend/src/components/ArticleCard.tsx index 541dae2..55fa57d 100644 --- a/frontend/src/components/ArticleCard.tsx +++ b/frontend/src/components/ArticleCard.tsx @@ -3,22 +3,24 @@ 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' 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> @@ -43,8 +45,8 @@ export function ArticleCard({ article }: { article: Article }) { {/* دکمه کپسولی ادامه مطلب با افکت شیشه‌ای */} <span className="inline-flex items-center gap-2 rounded-full border border-copper/40 bg-copper/5 px-3.5 py-1.5 text-xs font-medium text-copper backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_15px_rgba(197,143,92,0.5)]"> - ادامه مطلب - <ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:-translate-x-1" /> + {t('readMore')} + <ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> </span> </div> </div> diff --git a/frontend/src/components/ContactSection.tsx b/frontend/src/components/ContactSection.tsx index cb9cb2a..4c5952e 100644 --- a/frontend/src/components/ContactSection.tsx +++ b/frontend/src/components/ContactSection.tsx @@ -4,6 +4,7 @@ import Swal from 'sweetalert2' import { Reveal } from './Reveal' import { Button } from './Button' 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) } @@ -232,7 +234,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> @@ -262,7 +264,7 @@ export function ContactSection() { ) : ( <> <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" /> + <SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> </> )} </Button> diff --git a/frontend/src/components/FloatingContact.tsx b/frontend/src/components/FloatingContact.tsx index fd2031f..70f7462 100644 --- a/frontend/src/components/FloatingContact.tsx +++ b/frontend/src/components/FloatingContact.tsx @@ -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 diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index b6f0e01..2ce76dc 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -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> diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 66341a5..7589d22 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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> ); -} \ No newline at end of file +} diff --git a/frontend/src/components/LangToggle.tsx b/frontend/src/components/LangToggle.tsx new file mode 100644 index 0000000..c85a56d --- /dev/null +++ b/frontend/src/components/LangToggle.tsx @@ -0,0 +1,31 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { LanguagesIcon } from 'lucide-react' +import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang' + +/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */ +export function LangToggle({ className = '' }: { className?: string }) { + const { lang, t } = useLang() + const navigate = useNavigate() + const { pathname } = useLocation() + const target = counterpartPath(pathname, lang) + + const switchLang = () => { + writeLangCookie(lang === 'fa' ? 'en' : 'fa') + // پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد + sessionStorage.setItem('langAutoRedirect', '1') + navigate(target) + } + + return ( + <button + type="button" + onClick={switchLang} + aria-label={t('switchLangAria')} + title={t('switchLangAria')} + className={`group flex items-center gap-1.5 rounded-full border border-white/15 bg-white/5 px-3.5 py-2 text-xs font-bold text-ivory/80 backdrop-blur-lg shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] transition-all duration-300 ease-lux hover:border-copper/60 hover:bg-white/10 hover:text-copper active:scale-95 cursor-pointer ${className}`} + > + <LanguagesIcon className="h-3.5 w-3.5 transition-transform duration-300 group-hover:rotate-12" /> + <span>{t('switchLang')}</span> + </button> + ) +} diff --git a/frontend/src/components/PageHero.tsx b/frontend/src/components/PageHero.tsx index 98eb147..94709bb 100644 --- a/frontend/src/components/PageHero.tsx +++ b/frontend/src/components/PageHero.tsx @@ -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 diff --git a/frontend/src/components/Preloader.tsx b/frontend/src/components/Preloader.tsx index 287c1cc..ccfdba8 100644 --- a/frontend/src/components/Preloader.tsx +++ b/frontend/src/components/Preloader.tsx @@ -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 diff --git a/frontend/src/components/ProjectCard.tsx b/frontend/src/components/ProjectCard.tsx index 1b0c185..7d292d9 100644 --- a/frontend/src/components/ProjectCard.tsx +++ b/frontend/src/components/ProjectCard.tsx @@ -2,17 +2,19 @@ import React from 'react' import { Link } from 'react-router-dom' import { ArrowLeftIcon } from 'lucide-react' import { useStatusLabel } from '../api' +import { useLang } from '../i18n/lang' export function ProjectCard({ project }: any) { const isOngoing = project.status === 'progress' const statusText = useStatusLabel(project.status) + const { t, href, lang } = useLang() 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"> + <Link to={href(`/projects/${project.slug || '#'}`)} className="flex h-full flex-col focus:outline-none"> {/* کادر تصویر به همراه نشانگر وضعیت کپسولی و گوی دکمه شیشه‌ای */} <div className="relative aspect-[4/3] w-full overflow-hidden"> @@ -23,14 +25,14 @@ export function ProjectCard({ project }: any) { /> {/* نشانگر وضعیت کپسولی شیشه‌ای */} - <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 className="absolute bottom-4 ltr:right-4 rtl:left-4 flex h-11 w-11 items-center justify-center rounded-full border border-white/25 bg-ink/65 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.25),0_6px_20px_rgba(0,0,0,0.4)] transition-all duration-400 ease-lux group-hover:scale-110 group-hover:border-copper group-hover:bg-copper group-hover:text-char group-hover:shadow-[0_0_25px_rgba(197,143,92,0.6)]"> + <ArrowLeftIcon className="h-5 w-5 ltr:-scale-x-100 transition-transform duration-300 rtl:group-hover:-translate-x-0.5 ltr:group-hover:translate-x-0.5" /> </div> </div> @@ -47,8 +49,9 @@ export function ProjectCard({ project }: any) { <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> + <span>{t('yearLabel')} {project.year}</span> + {/* واحد زیربنا در محتوای انگلیسی داخل خود مقدار است — فقط در فارسی اضافه می‌شود */} + <span>{lang === 'fa' ? `${project.area} ${t('sqm')}` : project.area}</span> </div> </div> </Link> diff --git a/frontend/src/components/SectionHeading.tsx b/frontend/src/components/SectionHeading.tsx index e1f735c..112d418 100644 --- a/frontend/src/components/SectionHeading.tsx +++ b/frontend/src/components/SectionHeading.tsx @@ -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} diff --git a/frontend/src/components/home/Articles.tsx b/frontend/src/components/home/Articles.tsx index 3e933b7..636ddde 100644 --- a/frontend/src/components/home/Articles.tsx +++ b/frontend/src/components/home/Articles.tsx @@ -4,12 +4,14 @@ import { Reveal } from '../Reveal'; import { ButtonLink } from '../Button'; 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,9 +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"> + <ButtonLink to={href(heading.cta_href)} variant="outline" className="font-light"> <InlineEdit value={heading.cta_label} onSave={(v) => saveHeading('cta_label', v)} /> - <ArrowLeftIcon className="h-4 w-4" /> + <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> </ButtonLink> </div> </Reveal> diff --git a/frontend/src/components/home/BeforeAfter.tsx b/frontend/src/components/home/BeforeAfter.tsx index b7a71c9..1063380 100644 --- a/frontend/src/components/home/BeforeAfter.tsx +++ b/frontend/src/components/home/BeforeAfter.tsx @@ -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)} diff --git a/frontend/src/components/home/Faq.tsx b/frontend/src/components/home/Faq.tsx index c34339e..3dc02d1 100644 --- a/frontend/src/components/home/Faq.tsx +++ b/frontend/src/components/home/Faq.tsx @@ -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,6 +13,7 @@ type FaqProps = { export function Faq({ hideHeading = false }: FaqProps) { const { faqs } = useSite() + const { t } = useLang() const [open, setOpen] = useState<number | null>(0) return ( @@ -22,8 +24,8 @@ export function Faq({ hideHeading = false }: FaqProps) { {!hideHeading && ( <div className="lg:col-span-4"> <SectionHeading - title="سوالات متداول" - lead="پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران." + title={t('fallbackFaqTitle')} + lead={t('fallbackFaqLead')} headingKey="faq" /> </div> diff --git a/frontend/src/components/home/FeaturedProjects.tsx b/frontend/src/components/home/FeaturedProjects.tsx index c473fc2..8b3a846 100644 --- a/frontend/src/components/home/FeaturedProjects.tsx +++ b/frontend/src/components/home/FeaturedProjects.tsx @@ -7,12 +7,14 @@ 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' export function FeaturedProjects() { const { projects, status_labels } = useSite() + const { t, href } = useLang() const heading = useSectionHeading('featured_projects') const save = useFieldSaver() const saveStatusLabel = (key: string, value: string) => { @@ -37,7 +39,7 @@ 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 ( @@ -102,7 +104,7 @@ export function FeaturedProjects() { <Reveal delay={0.1}> <div className="mt-12 flex justify-center"> <ButtonLink - to={heading.cta_href} + to={href(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)]" > @@ -111,12 +113,12 @@ export function FeaturedProjects() { 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 className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-copper transition-all duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1 group-hover:bg-copper group-hover:text-char"> + <ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100" /> </span> </ButtonLink> </div> diff --git a/frontend/src/components/home/Gallery.tsx b/frontend/src/components/home/Gallery.tsx index d2ee9bb..7adf705 100644 --- a/frontend/src/components/home/Gallery.tsx +++ b/frontend/src/components/home/Gallery.tsx @@ -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} @@ -69,14 +71,14 @@ export function Gallery() { transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }} role="dialog" aria-modal="true" - aria-label="نمایش تصویر" + aria-label={t('viewImage')} onClick={close} > <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-8 ltr:right-8 rtl:left-8 flex h-11 w-11 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper" + aria-label={t('close')} > <XIcon className="h-5 w-5" /> </button> @@ -87,10 +89,10 @@ export function Gallery() { 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="تصویر قبلی" + className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:right-6 md:rtl:right-10 ltr:left-6 md:ltr:left-10" + aria-label={t('prevImage')} > - <ChevronRightIcon className="h-6 w-6" /> + <ChevronRightIcon className="h-6 w-6 ltr:rotate-180" /> </button> <button type="button" @@ -98,10 +100,10 @@ export function Gallery() { 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="تصویر بعدی" + className="absolute flex h-12 w-12 items-center justify-center border border-ivory/20 text-ivory transition-colors duration-200 ease-lux hover:border-copper hover:text-copper rtl:left-6 md:rtl:left-10 ltr:right-6 md:ltr:right-10" + aria-label={t('nextImage')} > - <ChevronLeftIcon className="h-6 w-6" /> + <ChevronLeftIcon className="h-6 w-6 ltr:rotate-180" /> </button> <motion.img diff --git a/frontend/src/components/home/Hero.tsx b/frontend/src/components/home/Hero.tsx index c6d78b1..c3a0dd2 100644 --- a/frontend/src/components/home/Hero.tsx +++ b/frontend/src/components/home/Hero.tsx @@ -3,12 +3,14 @@ import { motion, useScroll, useTransform } from 'framer-motion' import { ArrowLeftIcon, ChevronDownIcon } from 'lucide-react' import { ButtonLink } from '../Button' 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> {/* زیرتیتر */} @@ -84,21 +86,21 @@ export function Hero() { > {/* دکمه اصلی: کپسولی شیشه‌ای دودی با دایره کهربایی اختصاصی برای آیکون */} <ButtonLink - to={hero.cta_primary_href} + to={href(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 className="flex h-9 w-9 items-center justify-center rounded-full bg-copper text-char shadow-md transition-all duration-300 group-hover:scale-110 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1"> + <ArrowLeftIcon className="h-4 w-4 stroke-[2.5] ltr:-scale-x-100" /> </span> </ButtonLink> {/* دکمه ثانویه: شیشه دودی مات (Smoked Glass) با کادر ظریف */} <ButtonLink - to={hero.cta_secondary_href} + to={href(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" > @@ -112,7 +114,7 @@ export function Hero() { 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" + className="absolute bottom-28 ltr:right-6 rtl:left-6 hidden w-64 rounded-2xl border border-ivory/15 bg-char/70 p-6 backdrop-blur-xl shadow-2xl shadow-black/40 lg:block lg:ltr:right-10 lg:rtl:left-10" > <p className="text-3xl font-bold text-copper font-mono"> <InlineEdit value={hero.badge_value} onSave={(v) => saveHero('badge_value', v)} /> @@ -126,7 +128,7 @@ export function Hero() { {/* دکمه اسکرول پایین: تبدیل‌شده به یک گوی شیشه‌ای دایره‌ای */} <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"> diff --git a/frontend/src/components/home/Newsletter.tsx b/frontend/src/components/home/Newsletter.tsx index da99511..64d6ca1 100644 --- a/frontend/src/components/home/Newsletter.tsx +++ b/frontend/src/components/home/Newsletter.tsx @@ -4,6 +4,7 @@ import Swal from 'sweetalert2' import { Reveal } from '../Reveal' import { Button } from '../Button' 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,7 +91,7 @@ export function Newsletter() { setEmail('') } catch { setStatus('error') - showErrorAlert('خطا در اتصال به سرور', 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.') + showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText')) } finally { setSending(false) } @@ -146,7 +148,7 @@ export function Newsletter() { 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" > - <SendIcon className="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1" /> + <SendIcon className="h-4 w-4 transition-transform duration-300 rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> <span> {sending ? newsletter.sending_label : ( <InlineEdit value={newsletter.button_label} onSave={(v) => saveContent('button_label', v)} /> diff --git a/frontend/src/components/home/Stats.tsx b/frontend/src/components/home/Stats.tsx index 8007bc3..7ed41ec 100644 --- a/frontend/src/components/home/Stats.tsx +++ b/frontend/src/components/home/Stats.tsx @@ -1,15 +1,13 @@ 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;}) { + const { lang } = useLang(); const ref = useRef<HTMLSpanElement>(null); const inView = useInView(ref, { once: true, margin: '-60px' }); const [display, setDisplay] = useState(0); @@ -27,15 +25,16 @@ function Counter({ value }: {value: number;}) { return () => window.clearInterval(id); }, [inView, value]); - return <span ref={ref}>{toFa(display)}</span>; + return <span ref={ref}>{formatNumber(display, lang)}</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 +47,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"> diff --git a/frontend/src/components/home/Story.tsx b/frontend/src/components/home/Story.tsx index 61cb285..f12ec49 100644 --- a/frontend/src/components/home/Story.tsx +++ b/frontend/src/components/home/Story.tsx @@ -3,6 +3,7 @@ import { ArrowLeftIcon } from 'lucide-react' import { Reveal } from '../Reveal' import { ButtonLink } from '../Button' 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,9 +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"> + <ButtonLink to={href(story.link_href)} variant="outline" className="mt-10 font-light"> <InlineEdit value={story.link_label} onSave={(v) => saveStory('link_label', v)} /> - <ArrowLeftIcon className="h-4 w-4" /> + <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> </ButtonLink> </Reveal> )} @@ -102,7 +104,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"> diff --git a/frontend/src/components/home/Team.tsx b/frontend/src/components/home/Team.tsx index 9c83293..82d6c93 100644 --- a/frontend/src/components/home/Team.tsx +++ b/frontend/src/components/home/Team.tsx @@ -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"> <SectionHeading - title="تیم ما" - lead="کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند." + title={t('fallbackTeamTitle')} + lead={t('fallbackTeamLead')} headingKey="team" align="center" /> diff --git a/frontend/src/components/home/Testimonials.tsx b/frontend/src/components/home/Testimonials.tsx index e492bff..c1da059 100644 --- a/frontend/src/components/home/Testimonials.tsx +++ b/frontend/src/components/home/Testimonials.tsx @@ -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> diff --git a/frontend/src/components/home/Timeline.tsx b/frontend/src/components/home/Timeline.tsx index a952573..b9376d1 100644 --- a/frontend/src/components/home/Timeline.tsx +++ b/frontend/src/components/home/Timeline.tsx @@ -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' @@ -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,8 +64,8 @@ export function Timeline() { <div className="mb-16"> <SectionHeading - title="مسیر ما" - lead="از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید." + title={t('fallbackTimelineTitle')} + lead={t('fallbackTimelineLead')} headingKey="timeline" align="center" /> diff --git a/frontend/src/components/home/WhyUs.tsx b/frontend/src/components/home/WhyUs.tsx index 0331908..4fae903 100644 --- a/frontend/src/components/home/WhyUs.tsx +++ b/frontend/src/components/home/WhyUs.tsx @@ -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} diff --git a/frontend/src/data/site.ts b/frontend/src/data/site.ts index 042fd5d..704c3f9 100644 --- a/frontend/src/data/site.ts +++ b/frontend/src/data/site.ts @@ -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,26 @@ 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; summary: string; + summary_en?: string; body: string[]; + body_en?: string[] | null; }; /** سند TipTap (ProseMirror) — بدنه ساختاریافته مقاله */ @@ -33,9 +45,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; /** کلمه کلیدی هدف چک‌لیست سئو — راهنمای نوشتن، در سایت نمایش داده نمی‌شود */ diff --git a/frontend/src/i18n/dict.ts b/frontend/src/i18n/dict.ts new file mode 100644 index 0000000..1a9cd53 --- /dev/null +++ b/frontend/src/i18n/dict.ts @@ -0,0 +1,212 @@ +// دیکشنری دوزبانه رشته‌های هاردکد رابط کاربری (بدون محتوای دیتابیس). +// کلید فارسی مرجع است؛ متن‌های دیتابیس (سئو، پروژه‌ها، مقاله‌ها و...) از localize.ts می‌آیند. + +export const fa = { + // عمومی + brandFallback: 'گروه ساختمانی آرامش', + loading: 'در حال بارگذاری', + errorTitle: 'اتصال به سرور برقرار نشد', + errorText: 'محتوای سایت از سرور بارگذاری می‌شود؛ لطفاً اتصال اینترنت یا وضعیت سرور را بررسی کنید.', + retry: 'تلاش دوباره', + backToTop: 'بازگشت به بالا', + switchLang: 'English', + switchLangAria: 'تغییر زبان به انگلیسی', + understood: 'متوجه شدم', + + // هدر و ناوبری + mainNav: 'ناوبری اصلی', + mobileNav: 'ناوبری موبایل', + openMenu: 'باز کردن منو', + closeMenu: 'بستن منو', + callBrand: 'تماس تلفنی با گروه ساختمانی آرامش', + + // فرم تماس — پیام‌های اعتبارسنجی و خطا + incompleteInfo: 'اطلاعات ناقص است', + nameRequired: 'لطفاً نام و نام خانوادگی خود را کامل وارد کنید (حداقل ۳ حرف).', + phoneInvalidTitle: 'شماره تلفن نامعتبر است', + phoneInvalidText: 'لطفاً یک شماره تماس معتبر وارد کنید تا کارشناسان ما بتوانند با شما ارتباط بگیرند.', + submitErrorTitle: 'خطا در ثبت درخواست', + formCheck: 'لطفاً اطلاعات فرم را بررسی و دوباره تلاش کنید.', + connectionErrorTitle: 'خطا در اتصال به سرور', + connectionErrorText: 'ارتباط با سرور برقرار نشد. لطفاً بعداً دوباره تلاش کنید.', + + // خبرنامه + emailRequired: 'لطفاً نشانی ایمیل خود را برای عضویت در خبرنامه وارد کنید.', + emailInvalidTitle: 'فرمت ایمیل نامعتبر است', + emailInvalidText: 'لطفاً ایمیل خود را به درستی وارد کنید (مثال: example@domain.com).', + subscribeErrorTitle: 'خطا در عضویت', + emailCheck: 'لطفاً ایمیل را بررسی و دوباره تلاش کنید.', + + // تیتر بخش‌ها — فقط جایگزین وقتی دیتابیس خالی است + fallbackGalleryTitle: 'گالری تصاویر', + fallbackGalleryLead: 'قاب‌هایی از کارگاه‌ها و پروژه‌های تحویل‌شده گروه ساختمانی آرامش.', + fallbackTeamTitle: 'تیم ما', + fallbackTeamLead: 'کسانی که تصمیم‌های اصلی هر پروژه را می‌گیرند و پاسخ‌گوی آن هستند.', + fallbackTimelineTitle: 'مسیر ما', + fallbackTimelineLead: 'از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژه‌های ما را ببینید.', + fallbackFaqTitle: 'سوالات متداول', + fallbackFaqLead: 'پاسخ کوتاه به پرتکرارترین پرسش‌های سرمایه‌گذاران و خریداران.', + + // کارت پروژه + yearLabel: 'سال:', + sqm: 'متر مربع', + + // جزئیات پروژه + projectNotFound: 'این پروژه پیدا نشد', + projectNotFoundText: 'ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید.', + backToProjects: 'بازگشت به پروژه‌ها', + specLocation: 'موقعیت', + specYear: 'سال', + specArea: 'زیربنا', + specFloors: 'تعداد طبقات', + specUnits: 'واحدها', + specClient: 'کارفرما', + breadcrumbAria: 'مسیر صفحه', + home: 'صفحه اصلی', + projects: 'پروژه‌ها', + projectDetailsAria: 'جزئیات پروژه', + aboutProject: 'درباره پروژه', + similarProjectCta: 'پروژه‌ای مشابه دارید؟', + relatedProjectsAria: 'پروژه‌های مرتبط', + otherProjects: 'پروژه‌های دیگر', + + // مقاله و وبلاگ + articleNotFound: 'این مقاله پیدا نشد', + articleNotFoundText: 'فهرست کامل مقالات وبلاگ را ببینید.', + backToBlog: 'بازگشت به وبلاگ', + blog: 'وبلاگ', + relatedArticlesAria: 'مقالات مرتبط', + relatedArticles: 'مقالات مرتبط', + readArticlePrefix: 'خواندن مقاله', + articleImagePrefix: 'تصویر مقاله', + featuredArticle: 'مقاله ویژه', + readMore: 'ادامه مطلب', + draft: 'پیش‌نویس', + writeNewArticle: 'نوشتن مقاله جدید', + blogEmpty: 'هنوز مقاله‌ای منتشر نشده است', + blogEmptyText: 'به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود.', + articlesAria: 'مقالات', + + // برچسب‌های دسترس‌پذیری بخش‌ها + statsAria: 'آمار و ارقام', + projectsListAria: 'فهرست پروژه‌ها', + projectFilterAria: 'فیلتر وضعیت پروژه', + officesAria: 'دفاتر ما', + heroAria: 'معرفی گروه ساختمانی آرامش', + heroImageAlt: 'نمای شبانه برج مسکونی ساخته‌شده توسط گروه ساختمانی آرامش', + heroTitleAria: 'تیتر اصلی صفحه', + scrollDown: 'رفتن به بخش بعدی', + compareAria: 'مقایسه کانسپت و پروژه ساخته‌شده', + zoomImagePrefix: 'بزرگ‌نمایی تصویر شماره', + viewImage: 'نمایش تصویر', + close: 'بستن', + prevImage: 'تصویر قبلی', + nextImage: 'تصویر بعدی', + prevTestimonial: 'نظر قبلی', + nextTestimonial: 'نظر بعدی', + selectTestimonialAria: 'انتخاب نظر', + testimonialByPrefix: 'نظر', +} + +export type Dict = typeof fa + +export const en: Dict = { + brandFallback: 'Aramesh Construction Group', + loading: 'Loading', + errorTitle: 'Could not reach the server', + errorText: 'Site content is loaded from the server; please check your internet connection or the server status.', + retry: 'Try again', + backToTop: 'Back to top', + switchLang: 'فا', + switchLangAria: 'Switch language to Persian (Farsi)', + understood: 'Got it', + + mainNav: 'Main navigation', + mobileNav: 'Mobile navigation', + openMenu: 'Open menu', + closeMenu: 'Close menu', + callBrand: 'Call Aramesh Construction Group', + + incompleteInfo: 'Incomplete information', + nameRequired: 'Please enter your full name (at least 3 characters).', + phoneInvalidTitle: 'Invalid phone number', + phoneInvalidText: 'Please enter a valid phone number so our experts can get in touch with you.', + submitErrorTitle: 'Error submitting the request', + formCheck: 'Please check the form and try again.', + connectionErrorTitle: 'Server connection error', + connectionErrorText: 'Could not reach the server. Please try again later.', + + emailRequired: 'Please enter your email address to subscribe to the newsletter.', + emailInvalidTitle: 'Invalid email format', + emailInvalidText: 'Please enter your email correctly (e.g. example@domain.com).', + subscribeErrorTitle: 'Subscription error', + emailCheck: 'Please check your email and try again.', + + fallbackGalleryTitle: 'Gallery', + fallbackGalleryLead: 'Frames from the construction sites and delivered projects of Aramesh Construction Group.', + fallbackTeamTitle: 'Our Team', + fallbackTeamLead: 'The people who make each project’s key decisions — and answer for them.', + fallbackTimelineTitle: 'Our Path', + fallbackTimelineLead: 'From a four-person office to 47 projects. Walk through the timeline of our work.', + fallbackFaqTitle: 'Frequently Asked Questions', + fallbackFaqLead: 'Short answers to the questions investors and buyers ask most.', + + yearLabel: 'Year:', + sqm: 'm²', + + projectNotFound: 'Project not found', + projectNotFoundText: 'The address may have changed. Browse the full list of projects.', + backToProjects: 'Back to projects', + specLocation: 'Location', + specYear: 'Year', + specArea: 'Built area', + specFloors: 'Floors', + specUnits: 'Units', + specClient: 'Client', + breadcrumbAria: 'Breadcrumb', + home: 'Home', + projects: 'Projects', + projectDetailsAria: 'Project details', + aboutProject: 'About the project', + similarProjectCta: 'Have a similar project in mind?', + relatedProjectsAria: 'Related projects', + otherProjects: 'More projects', + + articleNotFound: 'Article not found', + articleNotFoundText: 'Browse the full list of blog articles.', + backToBlog: 'Back to blog', + blog: 'Blog', + relatedArticlesAria: 'Related articles', + relatedArticles: 'Related articles', + readArticlePrefix: 'Read article', + articleImagePrefix: 'Image of article', + featuredArticle: 'Featured article', + readMore: 'Read more', + draft: 'Draft', + writeNewArticle: 'Write a new article', + blogEmpty: 'No articles published yet', + blogEmptyText: 'Our first articles will be published here soon.', + articlesAria: 'Articles', + + statsAria: 'Stats and figures', + projectsListAria: 'Project list', + projectFilterAria: 'Filter projects by status', + officesAria: 'Our offices', + heroAria: 'Introduction of Aramesh Construction Group', + heroImageAlt: 'Night view of a residential tower built by Aramesh Construction Group', + heroTitleAria: 'Main headline', + scrollDown: 'Go to the next section', + compareAria: 'Compare the concept with the built project', + zoomImagePrefix: 'Zoom image No.', + viewImage: 'View image', + close: 'Close', + prevImage: 'Previous image', + nextImage: 'Next image', + prevTestimonial: 'Previous testimonial', + nextTestimonial: 'Next testimonial', + selectTestimonialAria: 'Select testimonial', + testimonialByPrefix: 'Testimonial by', +} + +export const dict = { fa, en } +export type DictKey = keyof Dict diff --git a/frontend/src/i18n/lang.tsx b/frontend/src/i18n/lang.tsx new file mode 100644 index 0000000..bb11db9 --- /dev/null +++ b/frontend/src/i18n/lang.tsx @@ -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') +} diff --git a/frontend/src/i18n/localize.ts b/frontend/src/i18n/localize.ts new file mode 100644 index 0000000..1e664e2 --- /dev/null +++ b/frontend/src/i18n/localize.ts @@ -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), + })), + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 651f81e..abefde6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; @@ -108,6 +126,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; /* تیتر ۳ سایز ۱۴ */ @@ -124,7 +144,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; @@ -142,7 +162,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; diff --git a/frontend/src/pages/ArticleDetail.tsx b/frontend/src/pages/ArticleDetail.tsx index 20c08df..0741f3b 100644 --- a/frontend/src/pages/ArticleDetail.tsx +++ b/frontend/src/pages/ArticleDetail.tsx @@ -9,6 +9,7 @@ import { ArticleMeta } from '../components/ArticleMeta'; import { TableOfContents, extractToc, type TocItem } from '../components/TableOfContents'; import { Newsletter } from '../components/home/Newsletter'; import { useSite } from '../api'; +import { useLang } from '../i18n/lang'; import { applySeo } from '../seo'; import { useEffect } from 'react'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; @@ -34,6 +35,7 @@ function renderBody(article: Article): { html: string | null; toc: TocItem[]; le export function ArticleDetail() { const { slug } = useParams<{slug: string;}>(); const { articles, brand } = useSite(); + const { t, href, lang } = useLang(); const article = articles.find((a) => a.slug === slug); // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله @@ -43,31 +45,32 @@ export function ArticleDetail() { title: `${article.title} | ${brand.name}`, // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده description: article.meta_description || article.excerpt, - path: `/blog/${article.slug}`, + path: href(`/blog/${article.slug}`), image: article.image, type: 'article', + lang, jsonLd: { '@context': 'https://schema.org', '@type': 'Article', headline: article.title, description: article.meta_description || article.excerpt, image: article.image ? new URL(article.image, window.location.origin).href : undefined, - inLanguage: 'fa', - author: { '@type': 'Organization', name: article.author?.trim() || brand }, + inLanguage: lang, + author: { '@type': 'Organization', name: article.author?.trim() || brand.name }, publisher: { '@type': 'Organization', name: brand }, - mainEntityOfPage: new URL(`/blog/${article.slug}`, window.location.origin).href, + mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href, }, }); - }, [article?.id]); + }, [article?.id, lang, href]); if (!article) { return ( <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> - <h1 className="text-section font-bold text-ivory">این مقاله پیدا نشد</h1> - <p className="mt-4 text-sm font-light text-ivory/60">فهرست کامل مقالات وبلاگ را ببینید.</p> - <ButtonLink to="/blog" variant="copper" className="mt-8"> - بازگشت به وبلاگ - <ArrowLeftIcon className="h-4 w-4" /> + <h1 className="text-section font-bold text-ivory">{t('articleNotFound')}</h1> + <p className="mt-4 text-sm font-light text-ivory/60">{t('articleNotFoundText')}</p> + <ButtonLink to={href('/blog')} variant="copper" className="mt-8"> + {t('backToBlog')} + <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> </ButtonLink> </section>); @@ -91,15 +94,15 @@ export function ArticleDetail() { aria-hidden="true" /> <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" /> - <Link to="/blog" className="transition-colors hover:text-copper"> - وبلاگ + <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> + <Link to={href('/blog')} className="transition-colors hover:text-copper"> + {t('blog')} </Link> - <ChevronLeftIcon className="h-3.5 w-3.5" /> + <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <span className="text-copper">{article.category}</span> </nav> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{article.title}</h1> @@ -114,7 +117,7 @@ export function ArticleDetail() { <div className="mx-auto grid max-w-5xl gap-12 lg:grid-cols-[minmax(0,1fr)_17rem] lg:gap-14"> <div className="min-w-0"> <Reveal> - <p className="border-r-2 border-copper pr-6 text-lg font-light leading-10 text-ivory/80"> + <p className="border-copper ltr:border-l-2 ltr:pl-6 rtl:border-r-2 rtl:pr-6 text-lg font-light leading-10 text-ivory/80"> {article.excerpt} </p> </Reveal> @@ -143,9 +146,9 @@ export function ArticleDetail() { </div> </article> - <section className="bg-char py-20 lg:py-28" aria-label="مقالات مرتبط"> + <section className="bg-char py-20 lg:py-28" aria-label={t('relatedArticlesAria')}> <div className="mx-auto max-w-site px-6 lg:px-10"> - <h2 className="text-section font-bold text-ivory">مقالات مرتبط</h2> + <h2 className="text-section font-bold text-ivory">{t('relatedArticles')}</h2> <div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-12 grid gap-6 lg:grid-cols-3"> {related.map((item, i) => diff --git a/frontend/src/pages/Blog.tsx b/frontend/src/pages/Blog.tsx index 1cbe693..0a4b832 100644 --- a/frontend/src/pages/Blog.tsx +++ b/frontend/src/pages/Blog.tsx @@ -6,23 +6,26 @@ import { Reveal } from '../components/Reveal' import { ArticleCard } from '../components/ArticleCard' import { Newsletter } from '../components/home/Newsletter' import { useSite } from '../api' +import { useLang } from '../i18n/lang' import { EditableItem } from '../admin/Editable' import { useAdmin } from '../admin/AdminContext' import { formatJalali } from '../utils/date' import { PlusIcon } from 'lucide-react' function DraftBadge({ className = '' }: { className?: string }) { + const { t } = useLang() return ( <span className={`border border-amber-400/60 bg-ink/90 px-2 py-1 text-xs font-bold text-amber-300 ${className}`} > - پیش‌نویس + {t('draft')} </span> ) } export function Blog() { const { articles } = useSite() + const { t, href } = useLang() const { isEditMode } = useAdmin() // دکمه «نوشتن مقاله جدید» فقط در حالت ویرایش ادمین دیده می‌شود @@ -33,7 +36,7 @@ export function Blog() { className="inline-flex items-center gap-2 bg-copper px-6 py-3 text-sm font-bold text-char transition-transform duration-200 hover:scale-105" > <PlusIcon className="h-4 w-4" /> - نوشتن مقاله جدید + {t('writeNewArticle')} </Link> ) : null @@ -42,11 +45,11 @@ export function Blog() { return ( <> <PageHero pageKey="blog" /> - <section className="bg-ink py-24 lg:py-32" aria-label="مقالات"> + <section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}> <div className="mx-auto max-w-site px-6 text-center lg:px-10"> - <h2 className="text-3xl font-bold text-ivory">هنوز مقاله‌ای منتشر نشده است</h2> + <h2 className="text-3xl font-bold text-ivory">{t('blogEmpty')}</h2> <p className="mt-4 text-sm font-light text-ivory/60"> - به‌زودی نخستین نوشته‌های ما اینجا منتشر می‌شود. + {t('blogEmptyText')} </p> <div className="mt-8 flex justify-center">{writeButton}</div> </div> @@ -62,28 +65,28 @@ export function Blog() { <> <PageHero pageKey="blog" /> - <section className="bg-ink py-24 lg:py-32" aria-label="مقالات"> + <section className="bg-ink py-24 lg:py-32" aria-label={t('articlesAria')}> <div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mb-10 flex justify-start">{writeButton}</div> <Reveal> <EditableItem resource="articles" item={featured} editHref={`/admin/write/${featured.id}`}> <Link - to={`/blog/${featured.slug}`} + to={href(`/blog/${featured.slug}`)} className="group grid items-stretch border border-ivory/10 bg-char transition-[transform,box-shadow,border-color] duration-300 ease-lux hover:-translate-y-2 hover:border-copper/50 hover:shadow-lift lg:grid-cols-2" - aria-label={`خواندن مقاله ${featured.title}`} + aria-label={`${t('readArticlePrefix')} ${featured.title}`} > <div className="overflow-hidden"> <img src={featured.image} - alt={`تصویر مقاله ${featured.title}`} + alt={`${t('articleImagePrefix')} ${featured.title}`} className="h-full min-h-[20rem] w-full object-cover transition-transform duration-500 ease-lux group-hover:scale-105" /> </div> <div className="flex flex-col justify-center p-8 lg:p-12"> <p className="text-xs font-light text-copper"> - مقاله ویژه — {featured.category} + {t('featuredArticle')} — {featured.category} {featured.status === 'draft' && ( - <DraftBadge className="mr-3 inline-block align-middle" /> + <DraftBadge className="inline-block align-middle ltr:ml-3 rtl:mr-3" /> )} </p> <h2 className="mt-6 text-2xl font-bold leading-[2.2] md:leading-[2.2] text-ivory transition-colors duration-200 ease-lux group-hover:text-copper md:text-3xl"> @@ -99,8 +102,8 @@ export function Blog() { </span> </span> <span className="flex items-center gap-2 text-copper"> - ادامه مطلب - <ArrowLeftIcon className="h-3.5 w-3.5 transition-transform duration-200 ease-lux group-hover:-translate-x-1" /> + {t('readMore')} + <ArrowLeftIcon className="h-3.5 w-3.5 ltr:-scale-x-100 transition-transform duration-200 ease-lux rtl:group-hover:-translate-x-1 ltr:group-hover:translate-x-1" /> </span> </div> </div> @@ -114,7 +117,7 @@ export function Blog() { <EditableItem resource="articles" item={article} className="h-full" editHref={`/admin/write/${article.id}`}> <div className="relative h-full"> {article.status === 'draft' && ( - <DraftBadge className="absolute left-3 top-3 z-10" /> + <DraftBadge className="absolute top-3 ltr:left-3 rtl:right-3 z-10" /> )} <ArticleCard article={article} /> </div> diff --git a/frontend/src/pages/ContactPage.tsx b/frontend/src/pages/ContactPage.tsx index f7cfe9e..288ac94 100644 --- a/frontend/src/pages/ContactPage.tsx +++ b/frontend/src/pages/ContactPage.tsx @@ -3,16 +3,18 @@ import { PageHero } from '../components/PageHero' import { ContactSection } from '../components/ContactSection' import { Reveal } from '../components/Reveal' import { useSite } from '../api' +import { useLang } from '../i18n/lang' import { AddItemButton, EditableItem } from '../admin/Editable' export function ContactPage() { const { offices } = useSite() + const { t } = useLang() return ( <> <PageHero pageKey="contact" /> - <section className="bg-ink py-24" aria-label="دفاتر ما"> + <section className="bg-ink py-24" aria-label={t('officesAria')}> <div className="mx-auto grid max-w-site gap-6 px-6 md:grid-cols-3 lg:px-10"> {offices.map((office, i) => ( <Reveal key={office.id} delay={0.06 * i} className="h-full"> diff --git a/frontend/src/pages/Investment.tsx b/frontend/src/pages/Investment.tsx index 51c8e78..3404a76 100644 --- a/frontend/src/pages/Investment.tsx +++ b/frontend/src/pages/Investment.tsx @@ -5,10 +5,12 @@ import { SectionHeading } from '../components/SectionHeading' import { Stats } from '../components/home/Stats' import { ContactSection } from '../components/ContactSection' import { useSectionHeading, useSite } from '../api' +import { useLang, formatNumber } from '../i18n/lang' import { AddItemButton, EditableItem } from '../admin/Editable' export function Investment() { const { investment_models } = useSite() + const { lang } = useLang() const heading = useSectionHeading('investment_models') return ( @@ -26,7 +28,7 @@ export function Investment() { {/* لایه نوری ظریف که در زمان هاور روشن می‌شود */} <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/[0.03] to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" /> - <p className="relative z-10 text-sm font-bold text-copper/80">۰{(i + 1).toLocaleString('fa-IR')}</p> + <p className="relative z-10 text-sm font-bold text-copper/80">{lang === 'fa' ? `۰${formatNumber(i + 1, lang)}` : formatNumber(i + 1, lang).padStart(2, '0')}</p> <h3 className="relative z-10 mt-6 text-2xl font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper"> {model.title} diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 32a6469..82d9adb 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -8,11 +8,13 @@ import { ContactSection } from '../components/ContactSection' import { Lightbox } from '../components/Lightbox' import { useEffect } from 'react'; import { useSite, useStatusLabel } from '../api'; +import { useLang } from '../i18n/lang'; import { applySeo } from '../seo'; export function ProjectDetail() { const { slug } = useParams<{ slug: string }>() const { projects, brand } = useSite() + const { t, href, lang } = useLang() const project = projects.find((p) => p.slug === slug) const statusLabel = useStatusLabel(project?.status ?? '') const [lightbox, setLightbox] = useState<number | null>(null) @@ -23,21 +25,22 @@ export function ProjectDetail() { applySeo({ title: `${project.title} | ${brand.name}`, description: project.summary, - path: `/projects/${project.slug}`, + path: href(`/projects/${project.slug}`), image: project.image, + lang, }); - }, [project?.id]); + }, [project?.id, lang, href]); if (!project) { return ( <section className="flex min-h-[70vh] flex-col items-center justify-center px-6 text-center"> - <h1 className="text-section font-bold text-ivory">این پروژه پیدا نشد</h1> + <h1 className="text-section font-bold text-ivory">{t('projectNotFound')}</h1> <p className="mt-4 text-sm font-light text-ivory/60"> - ممکن است نشانی تغییر کرده باشد. فهرست کامل پروژه‌ها را ببینید. + {t('projectNotFoundText')} </p> - <ButtonLink to="/projects" variant="copper" className="mt-8"> - بازگشت به پروژه‌ها - <ArrowLeftIcon className="h-4 w-4" /> + <ButtonLink to={href('/projects')} variant="copper" className="mt-8"> + {t('backToProjects')} + <ArrowLeftIcon className="h-4 w-4 ltr:-scale-x-100" /> </ButtonLink> </section> ) @@ -46,12 +49,12 @@ export function ProjectDetail() { const related = projects.filter((p) => p.slug !== project.slug).slice(0, 3) const facts = [ - { label: 'موقعیت', value: project.location }, - { label: 'سال', value: project.year }, - { label: 'زیربنا', value: project.area }, - { label: 'تعداد طبقات', value: project.floors }, - { label: 'واحدها', value: project.units }, - { label: 'کارفرما', value: project.client }, + { label: t('specLocation'), value: project.location }, + { label: t('specYear'), value: project.year }, + { label: t('specArea'), value: project.area }, + { label: t('specFloors'), value: project.floors }, + { label: t('specUnits'), value: project.units }, + { label: t('specClient'), value: project.client }, ] return ( @@ -65,15 +68,15 @@ export function ProjectDetail() { aria-hidden="true" /> <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" /> - <Link to="/projects" className="transition-colors hover:text-copper"> - پروژه‌ها + <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> + <Link to={href('/projects')} className="transition-colors hover:text-copper"> + {t('projects')} </Link> - <ChevronLeftIcon className="h-3.5 w-3.5" /> + <ChevronLeftIcon className="h-3.5 w-3.5 ltr:rotate-180" /> <span className="text-copper">{project.title}</span> </nav> <h1 className="mt-5 max-w-[700px] text-hero font-bold text-ivory">{project.title}</h1> @@ -81,7 +84,7 @@ export function ProjectDetail() { </div> </section> - <section className="bg-char py-20 lg:py-28" aria-label="جزئیات پروژه"> + <section className="bg-char py-20 lg:py-28" aria-label={t('projectDetailsAria')}> <div className="mx-auto grid max-w-site items-start gap-12 px-6 lg:grid-cols-2 lg:gap-16 lg:px-10"> <Reveal className="order-1"> @@ -89,7 +92,7 @@ export function ProjectDetail() { <span aria-hidden="true" className="h-px w-8 bg-copper" /> {statusLabel} </span> - <h2 className="mt-4 text-3xl font-bold text-ivory">درباره پروژه</h2> + <h2 className="mt-4 text-3xl font-bold text-ivory">{t('aboutProject')}</h2> {project.body.map((para) => ( <p key={para} className="mt-5 text-base font-light leading-9 text-ivory/70"> @@ -99,7 +102,7 @@ export function ProjectDetail() { <dl className="mt-10 grid grid-cols-2 gap-px border border-ivory/10 bg-ivory/10"> {facts.map((f) => ( - <div key={f.label} className="bg-ink p-6 md:p-8 text-right"> + <div key={f.label} className="bg-ink p-6 md:p-8 ltr:text-left rtl:text-right"> <dt className="text-xs font-light text-ivory/50">{f.label}</dt> <dd className="mt-3 text-sm font-bold text-ivory">{f.value}</dd> </div> @@ -107,8 +110,8 @@ export function ProjectDetail() { </dl> <div className="mt-10"> - <ButtonLink to="/contact" variant="outline" className="font-light"> - پروژه‌ای مشابه دارید؟ + <ButtonLink to={href('/contact')} variant="outline" className="font-light"> + {t('similarProjectCta')} </ButtonLink> </div> </Reveal> @@ -146,9 +149,9 @@ export function ProjectDetail() { </div> </section> - <section className="bg-ink py-20 lg:py-28" aria-label="پروژه‌های مرتبط"> + <section className="bg-ink py-20 lg:py-28" aria-label={t('relatedProjectsAria')}> <div className="mx-auto max-w-site px-6 lg:px-10"> - <h2 className="text-section font-bold text-ivory">پروژه‌های دیگر</h2> + <h2 className="text-section font-bold text-ivory">{t('otherProjects')}</h2> <div className="mt-6 h-px w-24 bg-copper/70" /> <div className="mt-12 grid gap-6 lg:grid-cols-3"> {related.map((item, i) => ( diff --git a/frontend/src/pages/Projects.tsx b/frontend/src/pages/Projects.tsx index 1cffb84..c886506 100644 --- a/frontend/src/pages/Projects.tsx +++ b/frontend/src/pages/Projects.tsx @@ -6,12 +6,14 @@ import { Gallery } from '../components/home/Gallery'; import { ContactSection } from '../components/ContactSection'; import type { ProjectStatus } from '../data/site'; import { useSite, useStatusLabel } from '../api'; +import { useLang } from '../i18n/lang'; import { useFieldSaver } from '../admin/AdminContext'; import { InlineEdit } from '../components/admin/InlineEdit'; import { AddItemButton, EditableItem } from '../admin/Editable'; export function Projects() { const { projects, status_labels } = useSite(); + const { t } = useLang(); const save = useFieldSaver(); const saveStatusLabel = (key: string, value: string) => { @@ -36,11 +38,11 @@ export function Projects() { <> <PageHero pageKey="projects" /> - <section className="bg-ink py-24 lg:py-32" aria-label="فهرست پروژه‌ها"> + <section className="bg-ink py-24 lg:py-32" aria-label={t('projectsListAria')}> <div className="mx-auto max-w-site px-6 lg:px-10"> {/* دکمه‌های فیلتر مینیمال و شیشه‌ای (وسط‌چین و بدون کادر) */} - <div className="flex items-center justify-center gap-4 md:gap-6" role="tablist" aria-label="فیلتر وضعیت پروژه"> + <div className="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 ( diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 31e12eb..28c5580 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -1,7 +1,10 @@ -// تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical و JSON-LD. +// تگ‌های سئوی سمت مرورگر — عنوان، توضیح متا، OG، توییتر، canonical، hreflang و JSON-LD. // محتوا از دیتابیس می‌آید؛ برای خزنده‌های بدون جاوااسکریپت، بک‌اند همان تگ‌ها را // سمت سرور داخل index.html تزریق می‌کند (app/seo_pages.py). +import { localeHref, langFromPathname } from './i18n/lang' +import type { Lang } from './i18n/lang' + export function setMetaDescription(content: string): void { // بدون توضیح از دیتابیس، پیش‌فرض استاتیک index.html دست‌نخورده می‌ماند if (!content.trim()) return @@ -16,11 +19,13 @@ export function setMetaDescription(content: string): void { /** کلید سئوی مسیر جاری — فقط صفحه‌های فهرست دقیق؛ صفحه‌های جزئیات خودشان عنوان می‌گذارند */ export function seoKeyForPath(pathname: string): string | null { - if (pathname === '/') return 'home' - const seg = pathname.split('/')[1] ?? '' + // زبان از پیشوند /en جدا می‌شود — کلیدهای سئو برای هر دو زبان یکی است + const clean = pathname === '/en' ? '/' : pathname.startsWith('/en/') ? pathname.slice(3) : pathname + if (clean === '/') return 'home' + const seg = clean.split('/')[1] ?? '' const known = ['about', 'services', 'projects', 'investment', 'blog', 'faq', 'contact'] // مسیرهای جزئی مثل /blog/{slug} شامل نمی‌شوند تا اثر خود صفحه جزئیات برسانند - return known.includes(seg) && pathname === `/${seg}` ? seg : null + return known.includes(seg) && clean === `/${seg}` ? seg : null } function upsertMeta(attr: 'name' | 'property', key: string, content: string): void { @@ -43,6 +48,30 @@ function upsertCanonical(path: string): void { el.setAttribute('href', new URL(path, window.location.origin).href) } +const HREFLANGS: [Lang, string][] = [ + ['fa', 'fa'], + ['en', 'en'], + ['fa', 'x-default'], +] + +/** لینک‌های alternate دو زبانه — مسیر جاری و معادل آن در زبان دیگر */ +function upsertAlternates(path: string): void { + for (const [targetLang, hreflang] of HREFLANGS) { + // x-default همیشه نسخه فارسی است + const target = localeHref(path, targetLang) + const attr = `data-hreflang="${hreflang}"` + let el = document.querySelector<HTMLLinkElement>(`link[${attr}]`) + if (!el) { + el = document.createElement('link') + el.setAttribute('rel', 'alternate') + el.setAttribute('hreflang', hreflang) + el.setAttribute('data-hreflang', hreflang) + document.head.appendChild(el) + } + el.setAttribute('href', new URL(target, window.location.origin).href) + } +} + function setJsonLd(data: Record<string, unknown> | null): void { const id = 'seo-jsonld' let el = document.getElementById(id) @@ -67,6 +96,8 @@ export type SeoInput = { /** تصویر OG — مسیر نسبی مثل /media/... به آدرس مطلق تبدیل می‌شود */ image?: string type?: 'website' | 'article' + /** زبان جاری — برای og:locale و لینک‌های hreflang */ + lang?: Lang jsonLd?: Record<string, unknown> | null } @@ -76,6 +107,7 @@ export function applySeo(input: SeoInput): void { const description = (input.description ?? '').trim() const path = input.path ?? window.location.pathname const image = (input.image ?? '').trim() + const lang: Lang = input.lang ?? langFromPathname(path) document.title = title setMetaDescription(description) @@ -84,11 +116,13 @@ export function applySeo(input: SeoInput): void { upsertMeta('property', 'og:description', description) upsertMeta('property', 'og:type', input.type ?? 'website') upsertMeta('property', 'og:url', new URL(path, window.location.origin).href) + upsertMeta('property', 'og:locale', lang === 'en' ? 'en_US' : 'fa_IR') upsertMeta('name', 'twitter:card', 'summary_large_image') if (image) { upsertMeta('property', 'og:image', new URL(image, window.location.origin).href) } else { document.querySelector<HTMLMetaElement>('meta[property="og:image"]')?.remove() } + upsertAlternates(path) setJsonLd(input.jsonLd ?? null) }