diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 3e4cd48..3e392bb 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,110 @@ 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/key/ + page_key) با همان کلید؛ بقیه فقط وقتی سطرها با seed دستنخوردهاند (تعداد و + idها مطابق) و بهترتیب. هیچ مقدار انگلیسیِ ثبتشده بازنویسی نمیشود. + """ + from . import models, seed_data, seed_data_en # lazy — جلوگیری از import حلقهای + + # (مدل، فهرست فارسی، فهرست انگلیسی، کلید تطبیق یا None برای ترتیبی) + PLAN: list[tuple[object, list[dict], list[dict], str | None]] = [ + (models.Brand, [seed_data.BRAND], [seed_data_en.BRAND], None), + (models.Hero, [seed_data.HERO], [seed_data_en.HERO], None), + (models.StorySection, [seed_data.STORY], [seed_data_en.STORY], None), + (models.SectionHeadingContent, seed_data.SECTION_HEADINGS, seed_data_en.SECTION_HEADINGS, "key"), + (models.PageHeroContent, seed_data.PAGE_HEROES, seed_data_en.PAGE_HEROES, "key"), + (models.NavItem, seed_data.NAV_ITEMS, seed_data_en.NAV_ITEMS, None), + (models.InvestmentModel, seed_data.INVESTMENT_MODELS, seed_data_en.INVESTMENT_MODELS, None), + (models.ProcessStep, seed_data.PROCESS_STEPS, seed_data_en.PROCESS_STEPS, None), + (models.Office, seed_data.OFFICES, seed_data_en.OFFICES, None), + (models.GalleryImage, seed_data.GALLERY_IMAGES, seed_data_en.GALLERY_IMAGES, None), + (models.StatusLabel, seed_data.STATUS_LABELS, seed_data_en.STATUS_LABELS, "key"), + (models.ContactSectionContent, [seed_data.CONTACT_SECTION], [seed_data_en.CONTACT_SECTION], None), + (models.NewsletterSectionContent, [seed_data.NEWSLETTER], [seed_data_en.NEWSLETTER], None), + (models.FooterContent, [seed_data.FOOTER], [seed_data_en.FOOTER], None), + (models.Service, seed_data.SERVICES, seed_data_en.SERVICES, None), + (models.Stat, seed_data.STATS, seed_data_en.STATS, None), + (models.Project, seed_data.PROJECTS, seed_data_en.PROJECTS, "slug"), + (models.Article, seed_data.ARTICLES, seed_data_en.ARTICLES, "slug"), + (models.Testimonial, seed_data.TESTIMONIALS, seed_data_en.TESTIMONIALS, None), + (models.Faq, seed_data.FAQS, seed_data_en.FAQS, None), + (models.TeamMember, seed_data.TEAM, seed_data_en.TEAM, None), + (models.TimelineItem, seed_data.TIMELINE, seed_data_en.TIMELINE, None), + (models.WhyUsItem, seed_data.WHY, seed_data_en.WHY, None), + (models.BeforeAfter, [seed_data.BEFORE_AFTER], [seed_data_en.BEFORE_AFTER], None), + (models.SeoMeta, seed_data.SEO_META, seed_data_en.SEO_META, "page_key"), + ] + + db = SessionLocal() + try: + total_rows = 0 + total_fields = 0 + skipped: list[str] = [] + for model, fa_rows, en_rows, match_key in PLAN: + rows = db.query(model).all() + if not rows or not en_rows: + continue + + if match_key is not None: + en_by_key = {r[match_key]: r for r in en_rows if match_key in r} + matched = [(row, en_by_key.get(getattr(row, match_key, None))) for row in rows] + pairs = [(row, en) for row, en in matched if en is not None] + elif len(rows) == 1 and len(en_rows) == 1: + pairs = [(rows[0], en_rows[0])] + else: + # تطبیق ترتیبی فقط برای جدولهای دستنخورده — idها از ۱ پیوسته + ids = [row.id for row in rows] + if ids != list(range(1, len(rows) + 1)) or len(rows) != len(en_rows): + skipped.append(getattr(model, "__tablename__", str(model))) + continue + pairs = list(zip(rows, en_rows)) + + 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 skipped: + print(f"جدولهای تغییرکرده (بدون backfill خودکار — از پنل مدیریت ترجمه کنید): {', '.join(skipped)}") + 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..4b4783a 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,46 @@ 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(description)}
" return HTMLResponse( - f'' - f"