وب‌سایت موتور

راهنمای طراحی پوسته

۱. پوسته چگونه کار می‌کند؟

پوسته، ظاهر سایت است. محتوای سایت در پایگاه‌های داده نگهداری می‌شود و با تغییر پوسته، فقط نحوه نمایش آن عوض می‌شود؛ مطالب، دسته‌ها، دیدگاه‌ها و تنظیمات دست‌نخورده می‌مانند.

برای نمایش هر صفحه، موتور فایل html مربوط به آن صفحه را از پوسته فعال می‌خواند، کلیدواژه‌های درصددار را با مقدار واقعی جایگزین می‌کند و نتیجه را به مرورگر می‌فرستد.

مثال — این سطر در فایل قالب:

<h2>%slogan</h2>

این‌گونه به مرورگر تحویل می‌شود:

<h2>شعار سایت اینجا نمایش داده می‌شود.</h2>

چهار قاعده اساسی

  1. پوسته کد PHP ندارد؛ فقط HTML و CSS و در صورت نیاز جاوااسکریپت.
  2. کلیدواژه‌ها دو دسته‌اند: متغیرهای پوسته که خودتان در فایل theme.json تعریف می‌کنید، و کلیدواژه‌های آماده موتور که از قبل تعریف شده‌اند.
  3. حداقل برای نمایش سایت به سه فایل قالب نیاز است: index.html و category.html و post.html.
  4. کلیدواژه‌ای که تعریف نشده باشد خطا ایجاد نمی‌کند؛ با متن خالی جایگزین می‌شود.

شروع سریع

سریع‌ترین راه، ویرایش یکی از پوسته‌های موجود است:

  1. پوشه یک پوسته نمونه را به نام دلخواه (مثلاً mytheme) کپی کنید.
  2. فایل pages/theme.json را باز کنید و theme_name را به نام جدید تغییر دهید.
  3. فایل‌های pages/*.html و style.css را مطابق سلیقه خود ویرایش کنید.
  4. پوشه پوسته را روی سایت، در مسیر themes بارگذاری کنید.
  5. از بخش «پوسته» در ناحیه مدیریت، پوسته را فعال کنید.

میان‌بر: تولید پوسته با هوش مصنوعی

فایل «پرامپت پوسته» که همراه موتور منتشر می‌شود، مشخصات کامل طراحی پوسته را در خود دارد. متن آن را در ابتدای یک گفتگوی تازه با هر سرویس هوش مصنوعی قرار دهید و در بخش پایانی آن، نوع پوسته و ویژگی‌های دلخواه را بنویسید. فایل‌های تحویلی را مطابق مسیرهای اعلام‌شده ذخیره و نصب کنید.

نصب و فعال‌سازی

روشنحوه فعال‌سازی
بارگذاری فایل ZIP از بخش «پوسته» در مدیریتنصب و فعال‌سازی، خودکار
دریافت از مخزن پوسته‌هانصب و فعال‌سازی، خودکار
بارگذاری پوشه با FTP یا مدیریت پروندهفعال‌سازی دستی از بخش «پوسته»

با تغییر پوسته، تنظیمات هر پوسته در فایل theme.json همان پوسته ذخیره می‌شود؛ پس اگر بعداً به پوسته قبلی برگردید، تنظیمات آن سر جای خود خواهد بود.

۲. ساختار فایل‌های پوسته

هر پوسته یک پوشه در مسیر themes است:

themes/mytheme/
├── screenshot.jpg        پیش‌نمایش پوسته، ۳۰۰×۴۰۰ پیکسل
├── style.css             شیوه‌نامه اصلی
├── script.js             جاوااسکریپت پوسته
├── img/
│   └── logo.jpg          تصاویر و دیگر دارایی‌ها
└── pages/
    ├── .htaccess         جلوگیری از دسترسی وب (حذف نکنید)
    ├── theme.json        مشخصات و تنظیمات پوسته
    ├── index.html        صفحه اصلی                      (الزامی)
    ├── category.html     دسته‌ها، برچسب‌ها، نتایج جستجو   (الزامی)
    ├── post.html         صفحه اختصاصی مطلب              (الزامی)
    ├── tag.html          برچسب‌ها                        (اختیاری)
    ├── search.html       نتایج جستجو                    (اختیاری)
    ├── 404.html          صفحه خطای یافت نشد             (اختیاری)
    └── *.html            قالب‌های دلخواه با نام آزاد
فایلنقشالزامی؟
pages/index.htmlصفحه اصلی سایتبله
pages/category.htmlمطالب یک دستهبله
pages/post.htmlصفحه اختصاصی هر مطلببله
pages/tag.htmlمطالب یک برچسبخیر — در نبودش category.html
pages/search.htmlنتایج جستجوخیر — در نبودش category.html
pages/404.htmlصفحه خطای ۴۰۴خیر — در نبودش صفحه پیش‌فرض موتور
pages/*.htmlقالب اختصاصی برای مطلب یا دسته خاصخیر — با «قالب نمایش» مطلب انتخاب می‌شود
screenshot.jpgپیش‌نمایش در بخش پوسته مدیریتتوصیه می‌شود

قواعد نام‌گذاری: نام پوسته و نام فایل‌های قالب فقط می‌تواند شامل حروف انگلیسی، رقم، خط تیره (-) و زیرخط (_) باشد. قالب‌هایی که با این قاعده نام‌گذاری شوند، در ردیف «قالب نمایش» صفحه ویرایش مطلب قابل انتخاب‌اند.

مسیر دارایی‌ها: فایل‌های CSS و JS و تصاویر پوسته با نشانی کامل فراخوانی می‌شوند:

<link rel="stylesheet" href="/themes/mytheme/style.css">
<script src="/themes/mytheme/script.js"></script>

الزامات هر فایل قالب:

<title>%mtitle</title>
<meta name="description" content="%mdescription">
<meta name="keywords" content="%mkeywords">

نکته: موتور هنگام پردازش، ابتدا متغیرهای theme.json را جایگزین می‌کند و سپس حلقه‌های مطالب را پردازش می‌کند. بنابراین می‌توانید متغیر پوسته را درون مشخصه‌های حلقه هم به کار ببرید.

۳. فایل theme.json

این فایل دو گروه کلید دارد:

  1. کلیدهای فنی که با theme_ شروع می‌شوند: مشخصات و رفتار پوسته را بیان می‌کنند، در html جایگزین نمی‌شوند و بیشترشان در مدیریت نمایش داده نمی‌شوند.
  2. متغیرهای پوسته: هر کدام یک ردیف در بخش تنظیمات نمایشِ مدیریت می‌سازند که مدیر سایت مقدار آن را تعیین می‌کند. مقدار نهایی در html با %نام‌کلید جایگزین می‌شود.

ساختار متغیر پوسته

هر متغیر آرایه‌ای دو عضوی است: [برچسب در مدیریت، مقدار پیش‌فرض]

"slogan": [
    "شعار سایت",
    "شعار سایت اینجا نمایش داده می‌شود."
]

انواع متغیر بر اساس پسوند نام

نوع ردیف در مدیریت (و رفتار مقدار) با پسوند آخر نام کلید تعیین می‌شود:

پسوندردیف در مدیریتکاربرد نمونه
بدون پسوندورودی تک‌خطیعنوان بخش، متن کوتاه
_textجعبه متن چندخطیمعرفی سایت (هر خط جدید به <br> تبدیل می‌شود)
_htmlویرایشگر غنی (WYSIWYG)بلوک معرفی با تصویر و قالب‌بندی
_codeجعبه متن کد خامکد درج ویدئو یا نقشه (iframe)
_imgانتخاب تصویرنشان سایت
_imgsetچند تصویر، جدا شده با |تصاویر لغزانه
_colorانتخاب رنگ از جدول طیفرنگ مضمون
_emailورودی ایمیلایمیل دریافت پیام فرم تماس
_numberورودی عددیتعداد مطالب یک بخش
_urlورودی نشانی وبنشانی شبکه اجتماعی
_menuویرایشگر فهرستفهرست اصلی سایت (بخش ۴)
_categoryانتخاب یک دستهدسته مطالب صفحه اصلی
_postsانتخاب چند مطلبمطالب منتخب لغزانه

نکته درباره _imgset: مقدار این متغیر متن خامی از نشانی تصاویر است که با | از هم جدا شده‌اند؛ موتور آن را عیناً جایگزین می‌کند. تبدیل به کد تصاویر، با جاوااسکریپت و متد split('|') به عهده پوسته است.

نکته درباره _color: الگوی رایج، تزریق مقدار رنگ به متغیر CSS است:

<style>:root{--primary-color:%primary_color}</style>

نمونه کامل theme.json

{
    "theme_name": "mytheme",
    "theme_ver": "1.0",
    "theme_info": "پوسته نمونه وب‌سایت موتور.",
    "primary_color": ["رنگ مضمون", "#439792"],
    "logo_img": ["تصویر نشان سایت", "/themes/mytheme/img/logo.jpg"],
    "main_menu": ["فهرست اصلی", "صفحه اصلی|/\nدسته نمونه|/دسته-نمونه-1/\n"],
    "home_category": ["دسته مطالب صفحه اصلی", "4"],
    "copyright_text": ["متن حقوق محفوظ", "تمامی حقوق این سایت محفوظ است."]
}

در این نمونه، سه کلید اول فنی‌اند و پنج کلید بعدی متغیر پوسته‌اند که هر کدام یک ردیف قابل تنظیم در مدیریت می‌سازند.

کلیدهای فنی (theme_*)

کلیدکاربردشرح کامل
theme_nameنام پوسته؛ در مدیریت به‌عنوان مشخصات نمایش داده می‌شود—
theme_verنسخه پوسته—
theme_urlنشانی صفحه معرفی پوسته—
theme_infoتوضیح پوسته (متن html مجاز است)—
theme_main_menu_classingکلاس‌های CSS منوبخش ۴
theme_varsتعریف فیلدهای اختصاصی مطلببخش ۷
theme_tags_imgفعال‌کردن تصویر شاخص برچسب‌ها (yes)بخش ۷
theme_categories_imgفعال‌کردن تصویر شاخص دسته‌ها (yes)بخش ۷
theme_similar_postsفعال‌کردن انتخاب مطالب مشابه (yes)بخش ۷
theme_phpپردازش فرم‌های اختصاصی پوستهبخش ۷
theme_devنسخه‌بندی خودکار style.css و script.js در زمان توسعه (yes)بخش ۱۱

۴. منوها

متغیری که نامش با _menu پایان می‌یابد، یک فهرست است. مقدار آن متن ساده‌ای است که هر سطرش یک گزینه را تعریف می‌کند:

عنوان گزینه|نشانی

در theme.json سطرها با کاراکتر \n از هم جدا می‌شوند:

"main_menu": [
    "فهرست اصلی",
    "صفحه اصلی|/\nنمونه مطلب|/نمونه-مطلب-1/\nدسته نمونه|/دسته-نمونه-1/\nپیوند نمونه|https://example.com/\n"
]

فراخوانی در قالب:

<nav>%main_menu</nav>

خروجی:

<nav><ul class="navbar-nav">
    <li class="nav-item active"><a class="nav-link" href="/">صفحه اصلی</a></li>
    <li class="nav-item"><a class="nav-link" href="/نمونه-مطلب-1/">نمونه مطلب</a></li>
    <li class="nav-item"><a class="nav-link" href="/دسته-نمونه-1/">دسته نمونه</a></li>
    <li class="nav-item"><a class="nav-link" href="https://example.com/" target="_blank">پیوند نمونه</a></li>
</ul></nav>

خروجی شامل خودِ <ul> است؛ پس پوسته فقط باید محل قرارگیری آن (مثلاً درون <nav>) و ظاهرش با CSS را تعیین کند.

قواعد نشانی‌ها

زیرمنوها

گزینه‌های تودرتو فقط با تورفتگی فاصله (یا tab) در ابتدای سطر ساخته می‌شوند. اندازه تورفتگی نسبت به سطر اول سنجیده می‌شود؛ تعداد دقیق فاصله‌ها مهم نیست، نسبت سطرها به هم مهم است:

"main_menu": [
    "فهرست اصلی",
    "صفحه اصلی|/\nمحصولات|\n  موبایل|/موبایل/\n  لپ‌تاپ|/لپ-تاپ/\nتماس با ما|/تماس-با-ما/\n"
]

گزینه «محصولات» بدون نشانی، والدِ زیرمنویی است که دو گزینه دارد.

کلاس active

گزینه‌ای که نشانی‌اش با نشانی صفحه جاری برابر باشد، کلاس active می‌گیرد. در صفحاتی که پارامتر کوئری دارند (مثل صفحه نتایج جستجو)، هیچ گزینه‌ای active نمی‌شود.

تنظیم کلاس‌های CSS

کلاس‌های خروجی منو با کلید فنی theme_نام‌منو_classing تنظیم می‌شود — شش مقدار، جداشده با کاما:

ردیفکلاسِپیش‌فرض
۱<ul> اصلیnavbar-nav
۲<li> گزینه‌هاnav-item
۳<a> گزینه‌هاnav-link
۴کلاس اضافه <li> والدِ زیرمنوdropdown
۵کلاس اضافه <a> والدِ زیرمنوdropdown-toggle
۶<ul> زیرمنوdropdown-menu

پیش‌فرض، همان مجموعه کلاس‌های بوت‌استرپ است؛ بنابراین اگر طراحی شما بوت‌استرپی نیست، کلاس‌های خودتان را تعریف کنید:

"theme_main_menu_classing": "menu,menu-item,menu-link,,menu-parent,submenu",

مقدار خالی یعنی آن عنصر کلاس اضافه‌ای نمی‌گیرد.

دو نکته درباره پیوندِ والد زیرمنو:

چند منو

هر تعداد متغیر _menu می‌توانید تعریف کنید (مثلاً منوی اصلی و منوی پانویس) و کلاس‌بندی هر کدام مستقل است: برای منویی به نام footer_menu، کلید theme_footer_menu_classing.

۵. متغیرهای سراسری

این کلیدواژه‌ها از قبل تعریف شده‌اند، نیازی به تعریف در theme.json ندارند و در همه صفحات مقدار می‌گیرند. درون حلقه ITEMS هم قابل استفاده‌اند.

کلیدواژهمقدار
%routeنوع صفحه جاری: index | category | post | tag | search | 404
%sitenameنام سایت (از تنظیمات)
%siteurlنشانی اصلی سایت
%sitelinkکد آمادهٔ <a href="%siteurl">%sitename</a>
%mtitleعنوان سئو
%mdescriptionتوضیح سئو
%mkeywordsکلیدواژه‌های سئو
%post_idشناسه محتوای جاری صفحه
%post_urlنشانی یکتای محتوای جاری
%post_titleعنوان محتوای جاری
%post_excerptچکیده محتوای جاری
%search_queryعبارت جستجو — فقط در صفحه جستجو مقدار دارد؛ بقیه صفحات خالی
%captchanoشمار عملیات باقی‌مانده تا نمایش مجدد کپچا (بخش ۹)
%URL(n)اسلاگ مطلب با شناسه n، بدون اسلش — برای پیوند: href="/%URL(3)/"
%TITLE(n)عنوان مطلب با شناسه n
%user_idشناسه عددی عضو واردشده؛ برای مهمان 0
%user_nameنام عضو واردشده؛ برای مهمان خالی
%user_roleنقش عضو: admin | editor | author؛ برای مهمان خالی
%comment_display_classفقط در post.html — بخش ۹
%site_storeyes اگر فروشگاه فعال است، وگرنه no

نکات مهم

۶. حلقه مطالب ITEMS

ITEMS محدوده یک حلقه تکرار است. موتور بر اساس مشخصه‌های برچسب، مطالب را از پایگاه می‌خواند و در هر تکرار، کلیدواژه‌های درون بدنه را با داده‌های همان مطلب جایگزین می‌کند:

<ITEMS i="4" limit="3">
    <h2>%title</h2>
    %img
</ITEMS>

الگوی رایج post.html: کل صفحه مطلب درون <ITEMS> ... </ITEMS> بدون مقدار i قرار می‌گیرد.

ترتیب پردازش: موتور ابتدا متغیرهای theme.json و کلیدواژه‌های سراسری را جایگزین می‌کند و سپس حلقه‌ها را پردازش می‌کند. بنابراین استفاده از متغیر درون مشخصه‌ها معتبر است: i="{%home_posts}".

مقدار i (شناسه‌یاب)

مقدارنتیجه
خالیpost.html: مطلب جاری — category/tag/search: مطالب همان صفحه (با صفحه‌بندی) — index.html: آخرین مطالب
"post"مطلب جاری
عددمطالب ذیل آن شناسه (دسته یا برچسب): i="4" یا i="%home_category"
{5,6,7}مطالب با آن شناسه‌ها — از متغیر پوسته: i="{%home_posts}"
recent_postsآخرین مطالب
recent_top_visitsپربازدیدترین مطالب ۱۰ روز اخیر
top_visitsپربازدیدترین مطالب کل
recent_top_commentsپرنظرترین مطالب ۱۰ روز اخیر
top_commentsپرنظرترین مطالب کل
similarمطالب مشابه انتخابی (نیازمند theme_similar_posts=yes)
same_categoryهم‌دسته‌های مطلب جاری
same_tagsهم‌برچسب‌های مطلب جاری
tagsبرچسب‌های مطلب جاری
categoryدسته‌های مطلب جاری

مشخصه limit

مشخصه display (شرطی‌سازی بلوک)

<ITEMS display="post"> ... </ITEMS>

اگر %route جاری برابر مقدار display بود، بدنه بلوک چاپ می‌شود و در غیر این صورت کل بلوک حذف می‌شود. کلیدواژه‌های حلقه (%title و مانند آن) درون بلوک display پردازش نمی‌شوند؛ متغیرهای پوسته و کلیدواژه‌های سراسری (که پیش از ITEMS جایگزین شده‌اند) کار می‌کنند.

کلیدواژه‌های درون حلقه

شناسه و نشانی

کلیدواژهمقدار
%indexشماره تکرار، از ۱
%first_*در تکرار اول، مقدار پس از first_؛ در بقیه خالی — مثال: <div class="carousel-item %first_active">
%all_itemsتعداد کل نتایج حلقه
%idشناسه مطلب
%url و %hrefنشانی مطلب: /نمونه-مطلب-1/
%linkکد آماده <a href="..." title="عنوان">عنوان</a>
%titleعنوان مطلب

متن و تصویر

کلیدواژهمقدار
%excerptچکیده (خط جدید به <br> تبدیل می‌شود)
%contentمتن کامل مطلب
%srcنشانی تصویر شاخص، یا خالی اگر مطلب تصویر ندارد
%imgکد آماده <img loading="lazy" src="..." alt="عنوان"> — بدون تصویر شاخص، خالی
%thumbکد <img> بندانگشتی: src="/...-thumb.jpg"

بندانگشتی حداکثر ۱۰۰×۱۰۰ پیکسل است و کنار تصویر اصلی با پسوند -thumb.jpg ساخته می‌شود. توصیه: در فهرست‌های عمومی %img و در ابزارک‌های کم‌حجم %thumb به‌همراه پس‌زمینه placeholder در CSS.

زمان — قالب‌ها ثابت‌اند؛ قالب دلخواه با جاوااسکریپت و تبدیل %date (تابع pdate در پیوست الف):

کلیدواژهقالب
%dateمیلادی Y-m-d
%timeH:i:s
%pdateشمسی Y/m/d
%htimeزمان نسبی: «۲ ساعت پیش»

دسته، برچسب و بازخورد

کلیدواژهمقدار
%category_linksپیوندهای کلیک‌پذیر دسته‌های مطلب
%category_namesعناوین دسته‌ها، جداشده با tab (برای پردازش JS)
%tag_links / %tag_namesمانند بالا برای برچسب‌ها
%comments_noشمار دیدگاه‌های سطح اولِ منتشرشده
%comments_all_noشمار همه دیدگاه‌های منتشرشده (شامل پاسخ‌ها)
%visitبازدید یکتا (بدون اعضا و خزنده‌ها)
%votesرشته آرا مثل 1,0,0,2,1,0 یا خالی (بخش ۹)

دسته‌ها و برچسب‌هایی که نشانی ندارند (ذخیره‌شده با #) در خروجی نمی‌آیند.

نویسنده، فروشگاه و فیلدهای خام

کلیدواژهمقدار
%author_name / %author_url / %author_linkنام، نشانی معرفی، کد <a> نویسنده
%buyکد دکمه خرید — فقط اگر فروشگاه فعال و قیمت بزرگ‌تر از صفر باشد (بخش ۱۰)
%priceقیمت با جداکننده هزارگان
%has_priceyes / no
%type %category %tags %template %commenting %created %modifiedمقادیر خام جدول مطالب
%نام‌فیلدفیلدهای اختصاصی مطلب (theme_vars — بخش ۷)

صفحه‌بندی

%paging()      ← در category/tag/search با i خالی
%paging(4)     ← اگر حلقه i="4" دارد
%link(4)       ← پیوند کلیک‌پذیر صفحه دسته، هم‌راه %paging(4)

خروجی فقط <li>هاست — شامل اعداد صفحات، «قبلی/بعدی»، سه‌نقطه برای فاصله‌ها و کلاس active روی صفحه جاری. خودتان با <ul> بپیچید:

<nav><ul class="pagination">%paging()</ul></nav>

در index.html با i خالی، %paging() خروجی ندارد. در صفحاتی که صفحه‌بندی معنا ندارد از درج %paging پرهیز کنید؛ چون جایگزین نمی‌شود و عیناً در خروجی می‌ماند.

کلیدواژه‌های ویژه صفحه جستجو

%search_finds (تعداد یافته‌ها) | %search_page (شماره صفحه جاری) | %search_pages (تعداد صفحات)

نمونه کامل

<div class="news_area">
    <ITEMS i="%news_category" limit="3">
        <div class="news_row">
            <h2>%title</h2>
            %img
            <p>%excerpt</p>
        </div>
    </ITEMS>
</div>

اگر %news_category متغیری از نوع _category با مقدار ۴ باشد، سه مطلب آخر دسته ۴ با عنوان و تصویر و چکیده نمایش داده می‌شود.

۷. امکانات پیکربندی پوسته

این امکانات با کلیدهای فنی theme_* در theme.json فعال می‌شوند.

فیلدهای اختصاصی مطلب — theme_vars

"theme_vars": "pretitle,پیش‌عنوان;pre_text,پیش‌متن;"

تصویر شاخص دسته و برچسب

"theme_categories_img": "yes",
"theme_tags_img": "yes"

با هر مقدار yes، در فرم ایجاد/ویرایش دسته‌ها یا برچسب‌ها ردیف «تصویر شاخص» نمایش داده می‌شود. نمایش تصویر دسته جاری در category.html:

<ITEMS>%img</ITEMS>

(بدون i — یعنی «محتوای جاری صفحه» = خود دسته.)

مطالب مشابه

"theme_similar_posts": "yes"

در فرم ویرایش مطلب، ردیفی برای انتخاب دستی مطالب مرتبط (مطلب، دسته یا برچسب) اضافه می‌شود. فراخوانی:

<ITEMS i="similar" limit="6"> ... </ITEMS>

پردازش فرم اختصاصی — theme_php

اگر پوسته فرمی دارد که با فرم‌های آماده موتور (تماس، دیدگاه) قابل انجام نیست — مثلاً ثبت ایمیل خبرنامه — مقدار theme_php یک کد PHP است که هنگام هر POST به نشانی ریشه سایت، پس از خواندن تنظیمات پوسته اجرا می‌شود:

"theme_php": "if(isset($_POST['newsletter']) and isset($_POST['email'])){file_put_contents(DATA_FOLDER.'/newsletter.log', date('Y-m-d H:i:s').'\\t'.$_POST['email'].'\\n', FILE_APPEND); die('OK.');}"

⚠ هشدار: نصب پوسته‌ای که theme_php دارد، یعنی اجرای کد PHP دلخواه روی سایت شما. پوسته را فقط از منبع مورد اعتماد نصب کنید و به محتوای theme.json آن توجه داشته باشید.

۸. فرم‌ها

جستجو

<form action="/" method="GET">
    <input name="search" value="%search_query">
    <button type="submit">جستجو</button>
</form>

فرم تماس

فیلدهای فرم با پیشوند contact به نشانی ریشه سایت POST می‌شوند، همراه با کد امنیتی. نمونه کامل HTML و جاوااسکریپت در پیوست پ:

<form method="POST" onsubmit="return sendform()">
    <input name="contact[name]" placeholder="نام شما" required>
    <input name="contact[email]" type="email" required>
    <input name="contact[subject]" placeholder="موضوع">
    <textarea name="contact[message]" required></textarea>
    <input type="number" name="captcha" required>
    <img src="/captcha/" alt="کد امنیتی" onclick="this.src='/captcha/?r='+Math.random()">
    <button type="submit">ارسال</button>
</form>

۹. دیدگاه‌ها و آرا

دیدگاه و بازخورد فقط برای مطالب (نه دسته‌ها و برچسب‌ها) و در post.html است. دریافت و نمایش دیدگاه‌ها با جاوااسکریپت (AJAX) انجام می‌شود — حلقه ITEMS برای دیدگاه‌ها وجود ندارد.

وضعیت دیدگاه هر مطلب (ردیف «وضعیت دیدگاه» در فرم ویرایش مطلب):

مقداررفتار
Cغیرفعال — فرم نمایش داده نمی‌شود
Oعمومی، انتشار فوری
Mفقط اعضا، انتشار فوری
خالیعمومی با تأیید مدیر (دیدگاه اعضا فوری منتشر می‌شود)

وقتی دیدگاه مجاز نیست، %comment_display_class مقدار no-display می‌دهد تا بخش بازخورد و دیدگاه با CSS پنهان شود. اگر فرم به هر حال ارسال شود، سرور خطای مناسب برمی‌گرداند.

کپچا

API (POST به نشانی ریشه سایت)

عملیاتفیلدهای POSTپاسخ موفق
دریافت دیدگاه‌ها readcomments=<شناسه مطلب> و last_cid=<آخرین شناسه بارگیری‌شده> و parent=<0 یا شناسه دیدگاه والد> OK\n سپس هر دیدگاه در یک سطر با ۷ فیلد جداشده با tab: شناسه، نام، متن، تاریخ (میلادی Y-m-d H:i:s)، مثبت، منفی، تعداد پاسخ‌ها — حداکثر ۵۰ مورد در هر درخواست؛ ادامه فهرست با last_cid
رأی به دیدگاهcomment_id=<id> و vote=<1|-1> (+captcha)OK\n<مثبت>,<منفی>,<captchano>
رأی به مطلبpost_id=<id> و vote=<0..5> (+captcha)OK\n<v0>,...,<v5>,<captchano>
ثبت دیدگاهpost_id و comment-name و comment-text و parent (+captcha)OK|دیدگاه شما منتشر شد. یا OK|دیدگاه شما دریافت شد و پس از بازبینی مدیر سایت، منتشر خواهد شد.

هر خطا با پیشوند ERROR|پیام فارسی آماده برمی‌گردد.

قواعد سمت سرور (برای طراحی فرم): نام تک‌خطی و بدون کاراکترهای < > " ' / | % @ — پیشنهاد maxlength="30". متن بدون تگ و حداکثر ۲۰۴۸ کاراکتر — پیشنهاد maxlength="2048". عضو غیرمدیر با نام ثبت‌شده خودش دیدگاه می‌دهد؛ مهمان نمی‌تواند از نام اعضای سایت استفاده کند.

اسکلت HTML الزامی (درون ITEMS در post.html)

<div class="post-media-votes %comment_display_class">
    <div class="post-media-title">بازخورد</div>
    <div class="votes_area" votes="%votes" post_id="%post_id"></div>
</div>
<div class="comment-section %comment_display_class">
    <div class="post-media-title comment-section-title">دیدگاه‌ها</div>
    <div id="comments-container" captchano="%captchano" post_id="%post_id" comments_no="%comments_no"></div>
    <div class="comment-form">
        <form onsubmit="send_comment();return false" method="POST">
            <div class="comment-form-info"></div>
            <input type="hidden" name="post_id" value="%post_id">
            <input type="hidden" name="parent" value="0">
            <input class="form-control comment-form-name" name="comment-name" placeholder="نام شما" value="%user_name" required>
            <textarea class="form-control" name="comment-text" placeholder="دیدگاه شما" required></textarea>
            <button type="submit">ارسال دیدگاه</button>
        </form>
    </div>
</div>

نقش جاوااسکریپت و کلاس‌های CSS

کد پیوست الف را عیناً در script.js قرار دهید و فقط HTML و CSS تولید کنید. اسکریپت، دکمه‌های رأی و بدنه دیدگاه‌ها را می‌سازد؛ CSS پوسته باید این کلاس‌ها را پوشش دهد:

گروهکلاس‌ها
دکمه‌های رأی.vote_btns و .vote-plus .vote-minus .vote-haha .vote-wow .vote-sad .vote-angry — حالت انتخاب‌شده: .active
برچسب رأی مطلبموافق، مخالف، خنده‌دار، تعجب‌آور، غمگین، ظالمانه — در اسکریپت مرجع قابل تغییر است
دیدگاه.comment .comment-inf .comment-name .comment-date .comment-votes .comment-plus .comment-minus .comment-text .comment-buttons .comment-load-replies (شمار پاسخ‌ها) .comment-reply-toggle (بازکردن فرم پاسخ) .readcomments-more («N دیدگاه دیگر») .comments-loading
فرم و خطاها.comment-form .comment-form-info (با حالت‌های success / error / wait) #form_reply #captcha_modal

تاریخ شمسی دیدگاه با pdate(p[3])+p[3].substring(10) ساخته می‌شود؛ تابع pdate سراسری است و در پیوست الف تعریف شده.

۱۰. فروشگاه

وقتی قابلیت فروشگاه در تنظیمات سایت فعال شود، موتور پیش از تگ <body> این اسکریپت را به خروجی اضافه می‌کند:

<script id="wotor_store_data">var STORE={sitename:"نام سایت", store_desc:"توضیحات سبد خرید",
store_cond:"توضیحات فرم سفارش", STORE_CURRENCY:"تومان",
catalog:{2:["نمونه-مطلب-2","نمونه مطلب 2",20000,"عدد"]},
payment_methods:{0:["پی‌پینگ","پرداخت به وسیله کلیه کارت‌های عضو شتاب..."]},
receipts:[1263357]};</script>

پوسته این تگ را نمی‌سازد و بازنویسی نمی‌کند. کد پیوست ب با گارد if(typeof(STORE)=='object') نوشته شده و در سایت غیرفروشگاهی بی‌خطر است؛ می‌توانید عیناً استفاده کنید یا با همان قراردادها بازنویسی کنید.

دکمه خرید

%buy در حلقه ITEMS کد دکمه را می‌دهد (فقط اگر فروشگاه فعال و قیمت بزرگ‌تر از صفر باشد):

<div class="site-store site-store-post" post_id="12" price="250000">
    <span class="site-store-price">250,000</span>
    <button class="site-store-btn" onclick="buy(12)"></button>
</div>

دکمه بدون متن است؛ برچسب یا آیکون با CSS ساخته می‌شود: .site-store-btn:before{content:'افزودن به سبد'}. کلاس دوم site-store-{route} برای تفاوت نمایش دکمه در فهرست‌ها و صفحه مطلب است.

سبد خرید و سفارش

کلاس‌های CSS فروشگاه

اگر از کد مرجع پیوست ب استفاده می‌کنید، این کلاس‌ها را استایل دهید: #store_cart_button #store_cart_modal .store_cart_modal_inner .store_cart_modal_header .close_cart_btn .store_cart_modal_table .store_cart_modal_row .store_cart_modal_footer .store_cart_modal_buttons .store_cart_modal_over .store_cart_modal_cond .store-customer-form .store_cart_modal_payment_methods .store_cart_modal_payment_method_desc .catalog_modal_form #filterinp .catalog_data_table_head #catalog_data_table .catalog_table_row #recent_receipts_button .close_receipts_btn — مودال کپچا (#captcha_modal) مشترک با بخش ۹ است.

۱۱. توسعه و اشکال‌زدایی

نسخه‌بندی خودکار — theme_dev

در زمان طراحی، این کلید را در theme.json بگذارید:

"theme_dev": "yes"

موتور به انتهای نشانی style.css و script.js علامت نسخه زمانی اضافه می‌کند (?v=...) تا تغییرات بدون درگیری با حافظه نهان مرورگر دیده شود. در نسخه نهایی پوسته، این کلید را حذف کنید.

اشکالات رایج

علامتعلت محتملراه‌حل
صفحه سفید یا خطای PHPخطای اجراییوارد مدیریت شوید (نمایش خطا آنجا فعال است) یا لاگ سرور را ببینید
کلیدواژه‌ای جایگزین نمی‌شودغلط املایی؛ یا متغیر در theme.json پوسته فعال تعریف نشده؛ یا کلیدواژه حلقه خارج از ITEMS به کار رفتهنام کلیدواژه و محل استفاده را چک کنید
تغییرات CSS اعمال نمی‌شودحافظه نهان مرورگرtheme_dev یا بارگذاری اجباری (Ctrl+F5)
«پرونده پوسته یافت نشد»قالب تعیین‌شده در «قالب نمایش» مطلب، در پوشه pages موجود نیستفایل را اضافه کنید یا قالب مطلب را اصلاح کنید
صفحه 404 برای مطلب تازهنشانی یکتا یا قالب انتخابی ناموجودنشانی/قالب را بررسی کنید
دیدگاه‌ها بارگیری نمی‌شوندحذف عناصر الزامی (#comments-container، .comment-section-title) یا خطای جاوااسکریپتکنسول مرورگر را ببینید
theme.json نادیده گرفته می‌شودJSON نامعتبر — مثلاً کامای اضافیبا یک اعتبارسنج JSON بررسی کنید

عیب‌یابی AJAX: همه پاسخ‌های موتور (OK... و ERROR|...) را در تب Network ابزار توسعه‌دهنده مرورگر می‌بینید.

فونت

برای پوسته سبک و مستقل، فونت را درون CSS جاسازی کنید:

@font-face{font-family:WebYekan;src:url(data:application/x-font-woff;charset=utf-8;base64,...) format('woff')}

اگر پوسته را با هوش مصنوعی می‌سازید، مدل نمی‌تواند base64 واقعی فونت تولید کند؛ از آن بخواهید فقط اسکلت @font-face را با علامت‌گذاری /* FONT_BASE64 */ بسازد و سپس خودتان رشته base64 فونت را جایگزین کنید. در غیر این صورت فونت سیستم (مثل Tahoma) کافی است.

۱۲. چک‌لیست انتشار پوسته

ساختار

محتوا و ظاهر

آزمون

انتشار

پیوست الف — کد مرجع دیدگاه‌ها و آرا

این کد، پیاده‌سازی مرجع بخش ۹ است. آن را عیناً در فایل script.js پوسته قرار دهید؛ کار شما فقط ساخت HTML و استایل‌دادن کلاس‌های معرفی‌شده است.

دو نکته مهم:

  • تابع pdate() در ابتدای این کد و به‌صورت سراسری تعریف شده است؛ پیوست ب (فروشگاه) نیز به همین تابع وابسته است. بنابراین هر دو کد باید در یک فایل (script.js) باشند و این تابع پیش از هر دو قرار گیرد.
  • در نمایش تاریخ دیدگاه‌ها از قالب شمسی استفاده می‌شود: pdate(p[3])+p[3].substring(10) — بخش تاریخ شمسی و بخش ساعتِ رشته میلادی سرور، کنار هم.
// -------- { Shared helper — تابع سراسری تاریخ شمسی --------
function pdate(datestr){
var y=0;
var m=0;
var d=0;
if(!datestr){
    var today = new Date();
    y=today.getFullYear();
    m=today.getMonth()+1;
    d=today.getDate();
}else{
    var p=datestr.replace(/-/g,' ').replace(/\//g,' ').split(' ');
    y=parseInt(p[0],10);
    m=parseInt(p[1],10);
    d=parseInt(p[2],10);
}
var g_days_in_month=[31,28,31,30,31,30,31,31,30,31,30,31];
var m_days_in_month=[31,31,31,31,31,31,30,30,30,30,30,29];
var gy = y-1600;
var g_day_no = 365*gy+Math.floor((gy+3)/4)-Math.floor((gy+99)/100)+Math.floor((gy+399)/400);
for(var j=0; j<m-1;j++){
    g_day_no += g_days_in_month[j];
}
if(m>2 && ((gy%4==0 && gy%100!=0) || (gy%400==0))){
    g_day_no++;
}
var m_day_no = g_day_no + d - 80;
var j_np = Math.floor(m_day_no/12053);
m_day_no = m_day_no % 12053;
var py = 979 + 33*j_np + 4*Math.floor(m_day_no/1461);
m_day_no %= 1461;
if(m_day_no >= 366){
    py += Math.floor((m_day_no-1)/365);
    m_day_no = (m_day_no-1)%365;
}
for(var j=0; j<11 && m_day_no >= m_days_in_month[j];j++){
    m_day_no -= m_days_in_month[j];
}
var pm=j+1;
var pd=m_day_no+1;
return py+'/'+(pm<10?'0':'')+pm+'/'+(pd<10?'0':'')+pd;
}
// -------- Shared helper } --------



// -------- { Comment and Vote Script --------
var captchano = 0;
if(document.querySelector('#comments-container') || document.querySelector('.votes_area')){
    var e=document.getElementById('comments-container');
    if(e){
        readcomments(0);
        captchano=parseInt(e.getAttribute('captchano'));
    }
    if(document.querySelector('.comment-form-name') && document.querySelector('.comment-form-name').value.length==0 && localStorage.getItem('comment-form-name')){
        document.querySelector('.comment-form-name').value=localStorage.getItem('comment-form-name');
    }
    
    var vas=document.querySelectorAll('.votes_area');
    if(vas){		
        for(var i=0;i<vas.length;i++){
            var v6=[0,0,0,0,0,0];
            var s=vas[i].getAttribute('votes');
            if(s){
                var a=s.split(',');
                for(var j=0;j<v6.length;j++){
                    if(a[j] && a[j].match(/\d+/)){
                        v6[j]=parseInt(a[j]);
                    }
                }
            }
            s='<div class="vote_btns" id="post-'+vas[i].getAttribute('post_id')+'-votes" >';
            for(var j=0;j<v6.length;j++){
                s+='<i onclick="vote()" class="vote-'+['plus','minus','haha','wow','sad','angry'][j]+'" vote="'+j+'" title="'+['موافق','مخالف','خنده‌دار','تعجب‌آور','غمگین','ظالمانه'][j]+'">'+v6[j]+'</i>';
            }
            s+='</div>';
            vas[i].innerHTML=s;
        }
        function vote(captcha){
            var e=captcha?document.querySelector('.triggered'):event.target;
            if(e.className.indexOf('active')!=-1){return;}
            var et=document.querySelector('.triggered');
            if(et){et.classList.remove('triggered');}
            e.classList.add('triggered');
            if(!captcha && captchano<=0){return go_captcha('vote');}
            e.style.pointerEvents='none';
            e.style.opacity=0.6;
            var xhr = new XMLHttpRequest();
            xhr.onload = function(){
                e.style.pointerEvents='auto';
                e.style.opacity=1;
                if(xhr.responseText.indexOf('ERROR')===0){
                    go_captcha('vote');
                }else if(xhr.responseText.indexOf('OK')===0){
                    var ac=e.parentNode.querySelector('.active');
                    if(ac){ac.classList.remove('active');}
                    e.classList.add('active');
                    var p=xhr.responseText.substring(3).split(',');
                    if(e.parentNode.id.indexOf('post')==0){
                        captchano=parseInt(p[6]);
                        var a=e.parentNode.querySelectorAll('i');
                        for(var j=0;j<a.length;j++){a[j].innerHTML=p[j];}
                    }else{
                        e.parentNode.querySelector('.comment-plus').innerHTML=(parseInt(p[0])?'+':' ')+p[0];
                        e.parentNode.querySelector('.comment-minus').innerHTML=(parseInt(p[1])?'-':' ')+p[1];
                        captchano=parseInt(p[2]);
                    }
                }
            }
            xhr.open('POST', '/');
            xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
            xhr.send(e.parentNode.id.replace('-votes','').replace('-','_id=')+'&vote='+e.getAttribute('vote')+(captcha?('&captcha='+captcha):''));
        }
    }
    
    function readcomments(n){
        var e=document.getElementById(n?('comment-'+n):'comments-container');
        var last_cid=0;
        if(!n){
            var cs=document.querySelectorAll('#comments-container .comment');
            if(cs.length){last_cid=cs[cs.length-1].id.replace('comment-','');}
            var b=document.querySelector('.readcomments-more button');
            if(b){
                b.classList.add('comments-loading');
            }else{
                document.querySelector('.comment-section-title').classList.add('comments-loading');
            }
        }else{
            var b=e.querySelector('.comment-load-replies');
            if(b){b.classList.add('comments-loading');}
        }
        var xhr = new XMLHttpRequest();
        xhr.onload = function(){
            document.querySelector('.comment-section-title').classList.remove('comments-loading');
            if(xhr.responseText.indexOf('OK')===0){
                var h='';
                var a=xhr.responseText.substring(3).split("\n");
                for(var i=0;i<a.length;i++){
                    var p=a[i].split("\t");
                    if(p.length==7){
                        //0id|1name|2text|3date|4plus|5minus|6children
                        h+='<div class="comment" id="comment-'+p[0]+'">'+
                            '<div class="comment-inf">'+
                                '<div class="comment-name">'+p[1]+'</div>'+
                                '<div class="comment-date">'+pdate(p[3])+p[3].substring(10)+'</div>'+
                                '<div class="comment-votes" id="comment-'+p[0]+'-votes">'+
                                '<i onclick="vote()" class="comment-plus" vote="1">'+(parseInt(p[4])?'+':' ')+p[4]+'</i>'+
                                '<i onclick="vote()" class="comment-minus" vote="-1">'+(parseInt(p[5])?'-':' ')+p[5]+'</i></div>'+
                            '</div>'+
                            '<div class="comment-text">'+p[2]+'</div>'+
                            '<div class="comment-buttons">'+(parseInt(p[6])?('<i onclick="readcomments('+p[0]+')"   class="comment-load-replies" >'+p[6] +' پاسخ </i>'):'')+
                            '<i onclick="reply('+p[0]+')" class="comment-reply-toggle"> پاسخ</i>'+
                            '</div>'+
                        '</div>';
                    }
                }
                e.insertAdjacentHTML('beforeend',h);

                if(n){
                    e.querySelector('.comment-load-replies').style.display='none';
                }else{
                    var b=document.querySelector('.readcomments-more');
                    if(b){b.parentNode.removeChild(b);}

                    var m=parseInt(document.getElementById('comments-container').getAttribute('comments_no'))-document.querySelectorAll('#comments-container > .comment').length;

                    if(m>0){
                        e.insertAdjacentHTML('beforeend','<div class="readcomments-more"><button onclick="readcomments(0)">'+m+' دیدگاه دیگر</button></div>');
                    }
                }				
                if(window.location.hash.indexOf('#comment-')==0){
                    setTimeout(function(){var c=document.querySelector(window.location.hash);if(c){c.scrollIntoView();}},500);
                }				
            }
        }
        xhr.open('POST', '/');
        xhr.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
        xhr.send('readcomments='+document.getElementById('comments-container').getAttribute('post_id')+'&last_cid='+last_cid+'&parent='+n);
    }
    function reply(n){
        var e=document.getElementById('form_reply');
        if(e){
            e.parentNode.removeChild(e);
            if(e.getAttribute('reply_to')==n){
                return;
            }
        }
        var h='<div class="comment-form" id="form_reply" reply_to="'+n+'" >'+document.querySelector('.comment-form').innerHTML+'</div>';
        var name=document.querySelector('.comment-form input[name=comment-name]').value;
        document.getElementById('comment-'+n).insertAdjacentHTML('beforeend',h);
        document.querySelector('#form_reply input[name=comment-name]').value=name;
        document.querySelector('#form_reply input[name=parent]').value=n;
        document.querySelector('#form_reply textarea').focus();

    }
    function go_captcha(cb){
        var h='<div id="captcha_modal" onclick="if(this==event.target){document.body.removeChild(this)}" style="position:fixed;z-index:9999;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)"><div style="direction:rtl;position:relative;background-color:#fff;margin:0 auto auto auto;padding:0;border:1px solid #fff;width:315px;max-width:90%;box-shadow:1px 1px 5px #000;transition:0.5s all"><span style="padding:5px;color:#f00;cursor:pointer;font-size:32px;line-height:1" onclick="document.body.removeChild(document.getElementById(\'captcha_modal\'))">&times;</span><div style="padding:5px 15px"><p>لطفاً کد چهار رقمی زیر را وارد کنید.</p><form onsubmit="'+cb+'(this.querySelector(\'input\').value);document.body.removeChild(document.getElementById(\'captcha_modal\'));return false"><img src="/captcha/?'+Math.random()+'" style="max-width:85%;margin:15px auto;display:block;cursor:pointer" onclick="this.src=\'/captcha/?\'+Math.random();this.parentNode.querySelector(\'input\').value=\'\'" /><input name="cptinp" class="form-control" type="number" style="display:block;margin:15px auto;text-align:center;width:175px" required /><button type="submit" style="background:seagreen;color:#fff;border:none;cursor:pointer;width:100px;padding:5px;border-radius:2px;margin:15px 0;font-family:inherit"> تأیید</button></form></div></div></div>';
        document.body.insertAdjacentHTML('beforeend',h);
        var mdl=document.querySelector('#captcha_modal > div');
        mdl.style.marginTop=(mdl.parentNode.clientHeight-mdl.clientHeight)/3.5+'px';
        mdl.querySelector('input').focus();
    }
    function send_comment(captcha){
        var f=captcha?document.querySelector('.triggered'):event.target;
        var p=f.querySelector('.comment-form-info');
        p.innerHTML='';
        p.className='comment-form-info';
        var et=document.querySelector('.triggered');
        if(et){et.classList.remove('triggered');}
        f.classList.add('triggered');
        if(!captcha && captchano<=0){return go_captcha('send_comment');}
        var formdata = new FormData(f);
        if(captcha){formdata.append('captcha',captcha);}
        p.innerHTML='<i class="comments-loading"></i> در حال ارسال...';
        p.classList.add('wait');
        f.style.pointerEvents='none';
        var xhr = new XMLHttpRequest();
        xhr.onload = function(){
            f.style.pointerEvents='auto';
            captchano=0;
            if(xhr.response.indexOf('OK')===0){
                p.innerHTML=xhr.response.substr(3);
                p.className='comment-form-info success';
                f.querySelector('Textarea').value='';
                localStorage.setItem('comment-form-name',f.querySelector('.comment-form-name').value);
                readcomments(parseInt(document.querySelector('input[name=parent]').value));
                var r=document.getElementById('form_reply');
                if(r){r.outerHTML='<p class="success">'+xhr.response.substr(3)+'</p>';}
            }else if(xhr.responseText.indexOf('ERROR|')===0){
                p.innerHTML=xhr.response.substr(6);
                p.className='comment-form-info error';
            }
        };
        xhr.onerror = function(){
            f.style.pointerEvents='auto';
            p.innerHTML='دیدگاه ارسال نشد.';
            p.className='comment-form-info error';
        }
        xhr.open('POST', '/');
        xhr.send(formdata);
    }
}
// -------- Comment and Vote Script } --------

پیوست ب — کد مرجع فروشگاه

این کد، پیاده‌سازی مرجع بخش ۱۰ است: کلید سبد خرید، مودال سبد، کاتالوگ کالا، فرم سفارش با روش‌های پرداخت و فهرست رسیدهای اخیر. با گارد if(typeof(STORE)=='object') در سایت غیرفروشگاهی کاملاً بی‌اثر است.

دو نکته نسبت به نسخه‌های پیشین:

  • واحد پول از STORE.STORE_CURRENCY خوانده می‌شود و هاردکد نیست.
  • تابع pdate() از این کد به ابتدای پیوست الف منتقل شده (سراسری) و برای «تاریخ» در سرصفحه سبد خرید از همان استفاده می‌شود.
// -------- { Store Script --------
if(typeof(STORE)=='object'){
    if(localStorage.getItem('cart_str')){buy(-1);}
    if(typeof(STORE.receipts)=='object' && STORE.receipts.length>0 && localStorage.getItem('dismiss_recent_receipts')!='yes'){
        var h='<div id="recent_receipts_button"><span class="close_receipts_btn" onclick="localStorage.setItem(\'dismiss_recent_receipts\',\'yes\');document.body.removeChild(this.parentNode)">&times;</span><div class="label">رسیدهای اخیر</div>';
        for(var i=(STORE.receipts.length-1);i>=0;i--){
            h+='<a target="_blank" href="/receipt/'+STORE.receipts[i]+'/">سفارش '+
            (STORE.receipts[i]+'').substr(0,(STORE.receipts[i]+'').length-6) +'</a>';
        }
        h+='</div>';
        document.body.insertAdjacentHTML('beforeend',h);
    }
    function buy(pid){
        var e=document.getElementById('store_cart_button');
        if(!e){
            document.body.insertAdjacentHTML('beforeend',
            '<div id="store_cart_button" onclick="buy(0)" >سبد خرید </div>'+
            '<div id="store_cart_modal" style="display:none" onclick="if(this==event.target && !this.querySelector(\'.store_cart_modal_inner_over\')){document.getElementById(\'store_cart_button\').style.display=\'block\';document.getElementById(\'store_cart_modal\').style.display=\'none\';}">'+
        '<div class="store_cart_modal_inner">'+
        '<div class="store_cart_modal_header"><h1>'+STORE.sitename+'</h1><div class="inf">	تاریخ: '+pdate()+'</div><p>'+STORE.store_desc.replace(/\n/g,'<br>')+'</p></div><span class="close_cart_btn" onclick="if(document.querySelector(\'.store_cart_modal_inner.store_cart_modal_inner_over\')){document.querySelector(\'.store_cart_modal_inner\').classList.remove(\'store_cart_modal_inner_over\');justify_store_cart_modal();}else{document.getElementById(\'store_cart_button\').style.display=\'block\';document.getElementById(\'store_cart_modal\').style.display=\'none\';}">&times;</span>'+

        '<table class="store_cart_modal_table"></table><div class="store_cart_modal_footer">	قابل پرداخت: <span>0</span> '+STORE.STORE_CURRENCY+'</div><div class="store_cart_modal_buttons"><button onclick="catalog_modal()">افزودن</button> <button onClick="recaclc();var w = window.open(&quot;&quot;, &quot;MsgWindow&quot;);w.document.open();w.document.write(\'&lt;!DOCTYPE html&gt;&lt;html lang=&quot;fa&quot;&gt;&lt;head&gt;\'+document.head.innerHTML+\'&lt;/head&gt;<body><style>@media print{@page{size:landscape}body{background:#fff}}</style><div class=&quot;store_cart_print&quot;&gt;\'+document.querySelector(\'.store_cart_modal_inner\').innerHTML.replace(\'close_cart_btn\',\'&quot; style=&quot;display:none\')+\'</div><script>document.addEventListener(&quot;DOMContentLoaded&quot;,function(){print();}); </script></body></html>\');w.document.close();">پیش‌فاکتور</button> <button onclick="pay_method_modal()" >تکمیل سفارش</button></div>'+
        '<div class="store_cart_modal_over"></div>'+
        '</div></div>');
        }

        if(pid!=-1){

            document.getElementById('store_cart_button').style.display='none';
            document.getElementById('store_cart_modal').style.display='block';

            var cart_str=(localStorage.getItem('cart_str'))?(localStorage.getItem('cart_str')):'';
            var a=cart_str.split(';');
            var pids=[];
            var ords=[];
            for(i=0;i<a.length;i++){
                var p=a[i].split(',');
                if(p.length==2){
                    var pi=parseInt(p[0]);
                    var pn=parseInt(p[1]);

                    if(pid==pi){pid=0;pn++;}

                    if(pids.indexOf(pi)==-1){
                        pids.push(pi);
                        ords.push(pn);
                    }else{
                        ords[pids.indexOf(pi)]++;
                    }
                }
            }
            if(pid>0){
                pids.push(pid);
                ords.push(1);
            }
            var h='<tr><th>ردیف</th><th>شناسه</th><th>شرح</th><th>تعداد</th><th>واحد</th><th>فی</th><th>جمع</th></tr>';
            var rows=1;
            for(var j=0;j<pids.length;j++){
                if(STORE.catalog[pids[j]]){
                    h+='<tr class="store_cart_modal_row fadeIn" pid="'+pids[j]+'"><td><span>'+(rows++)+
                    '</span><i title="حذف ردیف" class="store_cart_modal_row_del_btn" onclick="this.parentNode.parentNode.parentNode.removeChild(this.parentNode.parentNode);recaclc();">&times;</i>'+

                    '</td><td>'+pids[j]+'</td><td><a href="/'+STORE.catalog[pids[j]][0]+'/" target="_blank">'+STORE.catalog[pids[j]][1]+'</a></td>'+
                    '<td><input name="num" type="number" min="1" onchange="recaclc()" class="form-control" value="'+ords[j]+'"></td><td>'+STORE.catalog[pids[j]][3]+'</td>'+
                    '<td>'+	(STORE.catalog[pids[j]][2]+'').replace(/\d(?=(\d{3})+$)/g, '$&,')+'</td>'+
                    '<td class="st"></td></tr>';
                }

            }
            document.querySelector('.store_cart_modal_table').innerHTML='<tbody>'+h+'</tbody>';
            recaclc();
        }

        document.querySelector('.store_cart_modal_inner').classList.remove('store_cart_modal_inner_over');
        justify_store_cart_modal();
    }
    function recaclc(){
        var cart_str='';
        var a=document.querySelectorAll('.store_cart_modal_table .store_cart_modal_row');
        var gt=0;
        for(i=0;i<a.length;i++){
            var no=parseInt(a[i].querySelector('input').value);
            var pid=parseInt(a[i].getAttribute('pid'));
            if(no<1 || STORE.catalog[pid][3].length==0){no=1;}
            a[i].querySelector('input').value=no;
            cart_str+=pid+','+no+';';
            var st=STORE.catalog[pid][2]*no;
            gt+=st;
            a[i].querySelector('td span').innerHTML=(i+1);
            a[i].querySelector('.st').innerHTML=(st+'').replace(/\d(?=(\d{3})+$)/g, '$&,');
        }
        document.querySelector('.store_cart_modal_footer span').innerHTML=(gt+'').replace(/\d(?=(\d{3})+$)/g, '$&,');
        localStorage.setItem('cart_str',cart_str);
        if(localStorage.getItem('dismiss_recent_receipts')){
            localStorage.removeItem('dismiss_recent_receipts');
        }
    }
    function catalog_modal(){

        document.querySelector('.store_cart_modal_inner').classList.add('store_cart_modal_inner_over');

        var h='<div class="catalog_modal_form"><input class="form-control" type="text" id="filterinp" onkeyup="refilter()" placeholder="جستجوی کالا به نام یا شناسه"><span onclick="document.getElementById(\'filterinp\').value=\'\';refilter()">&times;</span></div><div class="catalog_data_table_head" style="padding:2px;background:#eee"><span>شناسه</span><span>شرح</span><span>واحد</span><span>فی</span></div><div id="catalog_data_table" style="overflow:auto;max-height:75vh;margin-bottom:15px;">';
        
        var ks=Object.keys(STORE.catalog);
        if(ks.length==0){
            h+='<p style="width:90%;margin:auto;">موردی یافت نشد.</p>';
        }

        for(var i=0;i<ks.length;i++){
            h+='<div class="catalog_table_row" onclick="buy('+ks[i]+')"><span>'+ks[i]+'</span><span>'+STORE.catalog[ks[i]][1]+'</span><span>'+STORE.catalog[ks[i]][3]+'</span><span fee="'+STORE.catalog[ks[i]][2]+'">'+(STORE.catalog[ks[i]][2]+'').replace(/\d(?=(\d{3})+$)/g, '$&,') +'</span></div>';
        }

        h+='</div>';

        document.querySelector('.store_cart_modal_over').innerHTML=h;
        justify_store_cart_modal();

    }
    function refilter() {
      var q=document.getElementById('filterinp').value.toUpperCase();
     document.getElementById('filterinp').parentNode.querySelector('span').style.display=(q.length==0)?'none':'block';

      var a=document.querySelectorAll('.catalog_table_row');
      for(var i=0;i<a.length;i++){
        var p=a[i].querySelectorAll('span');
        if(p[0].innerText.toUpperCase().indexOf(q)==-1 && p[1].innerText.toUpperCase().indexOf(q)==-1 ){
            a[i].style.display='none';
        }else{
            a[i].style.display='block';
        }
      }
     }
    function justify_store_cart_modal(){
        var m=document.querySelector('.store_cart_modal_inner');
        m.style.marginTop=(m.parentNode.clientHeight-m.clientHeight)/3.5+'px';
    }
    function pay_method_modal(){

        var cart_str=localStorage.getItem('cart_str');
        if(cart_str.length<3){
            return;
        }


        document.querySelector('.store_cart_modal_inner').classList.add('store_cart_modal_inner_over');

        var h='';


        h+='<div class="store_cart_modal_cond">'+STORE.store_cond.replace(/\n/g,'<br>')+'</div>';

        if(Object.keys(STORE.payment_methods).length==0){
            h+='<p>روشی برای پرداخت تعیین نشده است.</p>';
            }else{
                h+='<form class="store_cart_modal_form" method="POST" action="/checkout/">'+

                '<div class="store-customer-form">'+
                '<input type="hidden" name="cart" value="'+cart_str+'">'+
                '<input class="form-control" type="text" name="customer[name]" placeholder="نام و نام خانوادگی" value="" required>'+
                '<input class="form-control" dir="ltr" style="font-family:sans-serif" type="email" name="customer[email]" placeholder="ایمیل" value="" required>'+
                '<input class="form-control" dir="ltr" type="tel" name="customer[phone]" placeholder="تلفن تماس" value="" required>'+
                '<textarea class="form-control" type="text" name="customer[comment]" placeholder="توضیحات"></textarea>'+
                '</div>'+

                '<div class="store_cart_modal_form_row2"><div class="label">روش پرداخت:</div>'+
                '<div class="store_cart_modal_payment_methods">';
                for(var i=0;i<Object.keys(STORE.payment_methods).length;i++){
                    h+='<div class="label"><input type="radio" name="pay_method" value="'+i+'" onchange="document.querySelector(\'.store_cart_modal_payment_method_desc\').innerHTML=STORE.payment_methods['+i+'][1];" '+((i==0)?'checked':'')+'> '+STORE.payment_methods[i][0]+'</div>';
                }
                h+='<div class="store_cart_modal_payment_method_desc">'+STORE.payment_methods[0][1]+'</div>';
                h+='</div>';

                h+='</div><div class="store_cart_modal_pay"><button type="submit" onclick="localStorage.removeItem(\'cart_str\')" >پرداخت</button></div></form>';
            }





        document.querySelector('.store_cart_modal_over').innerHTML=h;
        justify_store_cart_modal();
    }
}
// -------- Store Script } --------

پیوست پ — نمونه فرم تماس (HTML + JS)

پیاده‌سازی مرجع بخش ۸. کلاس‌های چیدمان (row، col-lg-6 و…) از نمونه اصلی نگه داشته شده‌اند؛ در پوسته خود می‌توانید ساختار چیدمان را آزادانه عوض کنید — مهم، نام فیلدها و اسکریپت ارسال است.

اصلاح نسبت به نمونه اولیه: type="name" و type="subject" به type="text" تغییر کرد (این دو، نوع معتبر HTML نیستند) و return false; در انتهای تابع اضافه شد.

<form id="contact-form" method="post" onsubmit="return sendform()">
    <div class="row">
        <div class="col-lg-6">
            <fieldset>
                <input type="text" name="contact[name]" id="name" placeholder="نام شما..." autocomplete="name" required>
            </fieldset>
        </div>
        <div class="col-lg-6">
            <fieldset>
                <input type="text" name="contact[email]" id="email" pattern="[^ @]*@[^ @]*" placeholder="E-mail..." required="" style="font-family:sans-serif;direction:ltr" autocomplete="email">
            </fieldset>
        </div>
        <div class="col-lg-6">
            <fieldset>
                <input type="text" name="contact[subject]" id="subject" placeholder="موضوع..." autocomplete="on">
            </fieldset>
        </div>
        <div class="col-lg-6 captcha">
            <input type="number" name="captcha" class="form-control"  placeholder="کد امنیتی" autocomplete="off" required>
            <img src="/captcha/" alt="کد امنیتی" onclick="this.src='/captcha/?r='+Math.random()" style="filter:none">
        </div>
        <div class="col-lg-12">
            <fieldset>
                <textarea name="contact[message]" id="message" placeholder="پیام شما..." required></textarea>
            </fieldset>
        </div>
        <div class="col-lg-12">
            <div class="info" style="color:#fff;margin:5px 0;padding:5px"></div>
            <fieldset>
                <button type="submit" id="form-submit" class="orange-button">ارسال</button>
            </fieldset>
        </div>
    </div>
</form>

<script>
    function sendform(){
        event.preventDefault();
        var f=event.target;
        var formdata = new FormData(f);
        var p=f.querySelector('.info');
        p.innerHTML='در حال ارسال...';
        p.style.background ='teal';
        f.style.pointerEvents='none';
        var xhr = new XMLHttpRequest();
        xhr.onload = function(){
            f.querySelector('img').src='/captcha/?'+Math.random();
            f.style.pointerEvents='auto';
            if(xhr.status == 200){
                if(xhr.response.startsWith('OK')){
                    p.innerHTML="پیام شما با موفقیت ثبت شد.";
                    p.style.background ='green';
                    return;
                }else if(xhr.responseText.startsWith('ERROR|')){
                    p.innerHTML=xhr.response.substr(6);
                    p.style.background='red';
                    return;
                }
            }
            p.innerHTML='خطا در ارتباط با سرور';
            p.style.background ='red';
        };
        xhr.open('POST', '/');
        xhr.send(formdata);
        return false;
    }
</script>

نکته: تابع sendform نام عمومی دارد و با send_comment پیوست الف تداخلی ندارد؛ پوسته‌ای که هم فرم تماس و هم دیدگاه دارد می‌تواند هر دو را در یک script.js داشته باشد.