feat: update Docker configuration and add deployment documentation for Coolify
This commit is contained in:
@@ -23,6 +23,10 @@
|
|||||||
/phpstan.neon
|
/phpstan.neon
|
||||||
|
|
||||||
# Local env & secrets
|
# Local env & secrets
|
||||||
|
# NOTE: .env IS kept in the image — Symfony's Dotenv::bootEnv requires it to
|
||||||
|
# exist. Its APP_ENV=dev default is harmless in prod: compose sets the real
|
||||||
|
# APP_ENV=prod env var, and Dotenv never overwrites an already-set var
|
||||||
|
# (provided FPM clear_env=no, see docker/php/zz-pool.conf).
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
/config/jwt/*.pem
|
/config/jwt/*.pem
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,7 @@ RUN composer install \
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 2 — Frontend assets (Webpack Encore / React 19)
|
# Stage 2 — Frontend assets (Webpack Encore / React 19)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM node:20-alpine AS assets
|
FROM node:22-alpine AS assets
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# vendor is required: package.json references "@symfony/ux-react": "file:vendor/symfony/ux-react/assets"
|
# vendor is required: package.json references "@symfony/ux-react": "file:vendor/symfony/ux-react/assets"
|
||||||
COPY --from=vendor /app/vendor ./vendor
|
COPY --from=vendor /app/vendor ./vendor
|
||||||
@@ -50,6 +50,7 @@ COPY --from=assets /app/public/build ./public/build
|
|||||||
|
|
||||||
# Container configuration.
|
# Container configuration.
|
||||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
|
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
|
||||||
|
COPY docker/php/zz-pool.conf /usr/local/etc/php-fpm.d/zz-pool.conf
|
||||||
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
|
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
; Production FPM pool overrides for ClinicPro.
|
||||||
|
; Default www.conf ships clear_env = yes, which strips the container
|
||||||
|
; environment (APP_ENV=prod, DATABASE_URL, ...) from PHP worker processes.
|
||||||
|
; Without this, PHP falls back to the baked .env (APP_ENV=dev) and boots the
|
||||||
|
; wrong environment. Keep the container env so compose-provided vars reach PHP.
|
||||||
|
[www]
|
||||||
|
clear_env = no
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
# راهنمای دیپلوی ClinicPro (Coolify + Docker Compose)
|
||||||
|
|
||||||
|
این راهنما برای دیپلوی بکاند ClinicPro روی **Coolify** با استفاده از `docker-compose.yml` نوشته شده.
|
||||||
|
|
||||||
|
> توجه: این `docker-compose.yml` فقط برای **production** است. محیط لوکال از `compose.yaml` خودِ ddev استفاده میکند — این دو را با هم اشتباه نگیر.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## معماری استک
|
||||||
|
|
||||||
|
`docker-compose.yml` پنج سرویس بالا میآورد:
|
||||||
|
|
||||||
|
| سرویس | نقش | نکته |
|
||||||
|
|---|---|---|
|
||||||
|
| `app` | PHP-FPM + Nginx (وب) | تنها سرویسی که `RUN_INIT=1` دارد؛ مهاجرت DB و تولید کلید JWT را اجرا میکند. دامنهها را به این سرویس (پورت 80) وصل کن. |
|
||||||
|
| `worker-async` | مصرفکننده صف async (SMS و کارهای async) | `messenger:consume async` |
|
||||||
|
| `worker-scheduler` | زمانبند | هر ۱ دقیقه نوبتهای پرداختنشده را منقضی میکند |
|
||||||
|
| `mariadb` | پایگاهداده MariaDB 11.8 | healthcheck دارد؛ بقیه منتظرش میمانند |
|
||||||
|
| `redis` | صف Messenger + کش | `appendonly yes` (ماندگار) |
|
||||||
|
|
||||||
|
**ولومهای ماندگار** (دادهها در ریدیپلوی حفظ میشوند):
|
||||||
|
|
||||||
|
- `jwt_keys` → کلیدهای JWT
|
||||||
|
- `uploads_public` و `uploads_var` → فایلهای آپلودی
|
||||||
|
- `mariadb_data` → داده DB
|
||||||
|
- `redis_data` → داده Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
|
||||||
|
- نمونهی Coolify در حال اجرا با Traefik (پیشفرض Coolify).
|
||||||
|
- ریپوی Git متصل به Coolify.
|
||||||
|
- رکوردهای DNS برای دامنهی API و همهی دامنههای فرانتاند که به سرور اشاره کنند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۱ — ساخت منبع (Resource) در Coolify
|
||||||
|
|
||||||
|
1. **New Resource → Docker Compose** (Build Pack: `Docker Compose`).
|
||||||
|
2. ریپو و برنچ را انتخاب کن.
|
||||||
|
3. فیلد **Compose file** را روی `docker-compose.yml` بگذار.
|
||||||
|
4. `networks:` سفارشی تعریف **نکن** — شبکه را Coolify مدیریت میکند؛ شبکهی سفارشی روتینگ Traefik را میشکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۲ — دامنهها
|
||||||
|
|
||||||
|
همهی دامنههای سرو شونده را به سرویس **`app`** (پورت 80) اختصاص بده — هم دامنهی API و هم همهی دامنههای فرانتاند. Coolify لیست دامنهی جداشده با کاما را روی یک سرویس قبول میکند و TLS را خودش صادر میکند.
|
||||||
|
|
||||||
|
> اجازهدادن CORS و host فرانتاندها از طریق متغیرهای `CORS_ALLOW_ORIGIN` / `ALLOWED_FRONTEND_HOSTS` کنترل میشود، نه دامنهی Coolify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۳ — متغیرهای محیطی
|
||||||
|
|
||||||
|
از `.env.coolify.example` کپی کن و در تب **Environment Variables** منبع Coolify بگذار.
|
||||||
|
|
||||||
|
فقط متغیرهایی که در `docker-compose.yml` بهصورت `${...}` ارجاع شدهاند لازماند. `DATABASE_URL` / `MESSENGER_TRANSPORT_DSN` / `REDIS_URL` داخل خود compose از روی نام سرویسها ساخته میشوند.
|
||||||
|
|
||||||
|
### اسرار (الزامی — قبل از اولین دیپلوی)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
JWT_PASSPHRASE= # openssl rand -hex 32 (باید قبل از اولین استارت موجود باشد؛ کلید JWT با همین ساخته میشود)
|
||||||
|
DB_PASSWORD= # رمز کاربر DB اپلیکیشن
|
||||||
|
DB_ROOT_PASSWORD= # رمز root مریادیبی
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `JWT_PASSPHRASE` را بعد از اولین دیپلوی عوض نکن — کلید JWT یکبار با همین passphrase تولید و روی ولوم `jwt_keys` ماندگار میشود. تغییرش همهی توکنها را میشکند.
|
||||||
|
|
||||||
|
در Coolify میتوانی بهجای هاردکد از magic var استفاده کنی:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_PASSWORD=${SERVICE_PASSWORD_DB}
|
||||||
|
APP_SECRET=${SERVICE_HEX_APPSECRET}
|
||||||
|
```
|
||||||
|
|
||||||
|
### دامنهها و CORS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_BASE_URL=https://api.nobat724.com # دامنهی خودِ بکاند (برای callback پرداخت و URLهای مطلق)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ALLOWED_FRONTEND_HOSTS` و `CORS_ALLOW_ORIGIN` از `docker/frontend-domains.json` **تولید** میشوند. برای اضافه/حذف دامنهی شهر:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# آن فایل را ویرایش کن، سپس:
|
||||||
|
php docker/gen-cors-env.php # روی سرور
|
||||||
|
# یا لوکال:
|
||||||
|
ddev exec php docker/gen-cors-env.php
|
||||||
|
```
|
||||||
|
|
||||||
|
خروجی را در Coolify جایگزین کن.
|
||||||
|
|
||||||
|
### ریورسپراکسی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
تا Symfony به هدرهای `X-Forwarded-*` ترافیک اعتماد کند.
|
||||||
|
|
||||||
|
### api.ir (استعلام هویت — Shahkar / IbanMatch)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_IR_BASE_URL=https://s.api.ir
|
||||||
|
API_IR_TOKEN= # خالی => fail-closed (تأیید نماینده رد میشود)
|
||||||
|
```
|
||||||
|
|
||||||
|
### چیزهایی که env لازم ندارند
|
||||||
|
|
||||||
|
- کلیدهای SMS (kavenegar/rangineh) و درگاه پرداخت (mellat/sep) از **DB ("تنظیمات سایت")** خوانده میشوند، نه env.
|
||||||
|
- `REFRESH_TOKEN_TTL` / `OTP_TTL` / `MAX_FILE_SIZE_BYTES` در خود compose ثابتاند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۴ — دیپلوی
|
||||||
|
|
||||||
|
روی **Deploy** بزن. در اولین استارت بهصورت خودکار اینها اتفاق میافتد (`entrypoint.sh` + `RUN_INIT=1` روی سرویس `app`):
|
||||||
|
|
||||||
|
1. مالکیت `var`, `public/uploads`, `config/jwt` به `www-data` داده میشود.
|
||||||
|
2. کلید JWT اگر روی ولوم نباشد ساخته میشود (`--skip-if-exists`).
|
||||||
|
3. کش prod پاک و warmup میشود.
|
||||||
|
4. مهاجرتهای DB با `--all-or-nothing` اعمال میشوند (ترنزکشن).
|
||||||
|
|
||||||
|
> ورکرها `RUN_INIT=0` دارند تا مهاجرت/تولید کلید با هم تداخل نکنند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مرحله ۵ — پس از اولین دیپلوی
|
||||||
|
|
||||||
|
### ساخت اولین ادمین
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# داخل کانتینر سرویس app
|
||||||
|
php bin/console app:create-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
### بررسی سلامت
|
||||||
|
|
||||||
|
- healthcheck سرویس `app`: `php fsockopen 127.0.0.1:80`.
|
||||||
|
- Swagger: `https://<APP_BASE_URL>/api/doc`
|
||||||
|
- پنل ادمین: `https://<APP_BASE_URL>/admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## دیپلویهای بعدی
|
||||||
|
|
||||||
|
push روی برنچ متصل (یا Deploy دستی). در هر ریدیپلوی:
|
||||||
|
|
||||||
|
- ایمیج دوباره build میشود (vendor + اسمبل فرانتاند multi-stage).
|
||||||
|
- مهاجرتهای جدید روی استارت `app` اعمال میشوند.
|
||||||
|
- ولومها حفظ میشوند (DB، آپلودها، کلیدهای JWT، Redis سالم میمانند).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## عیبیابی
|
||||||
|
|
||||||
|
| نشانه | علت محتمل |
|
||||||
|
|---|---|
|
||||||
|
| ارورهای CORS در فرانت | `CORS_ALLOW_ORIGIN` با دامنه نمیخواند؛ از `gen-cors-env.php` بازتولید کن |
|
||||||
|
| `app` بالا نمیآید، منتظر DB میماند | healthcheck `mariadb` رد نشده؛ لاگ mariadb را ببین |
|
||||||
|
| ۴۰۱/توکن نامعتبر بعد از ریدیپلوی | `JWT_PASSPHRASE` تغییر کرده یا ولوم `jwt_keys` پاک شده |
|
||||||
|
| IPها/HTTPS اشتباه پشت پراکسی | `TRUSTED_PROXIES` ست نشده |
|
||||||
|
| مهاجرت اجرا نشد | فقط `app` با `RUN_INIT=1` اجرا میکند؛ مطمئن شو override نشده |
|
||||||
|
| تأیید نماینده رد میشود | `API_IR_TOKEN` خالی است (fail-closed) |
|
||||||
Generated
+4
-6
@@ -49,12 +49,6 @@
|
|||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"webpack": "^5.72",
|
"webpack": "^5.72",
|
||||||
"webpack-cli": "^6.0.0"
|
"webpack-cli": "^6.0.0"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": "^4.3.0",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "^4.3.0",
|
|
||||||
"lightningcss-darwin-arm64": "^1.32.0",
|
|
||||||
"lightningcss-linux-arm64-gnu": "^1.32.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -15902,6 +15896,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -15969,6 +15964,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20035,6 +20031,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -20118,6 +20115,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -44,12 +44,6 @@
|
|||||||
"zod": "^3.0.0",
|
"zod": "^3.0.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": "^4.3.0",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "^4.3.0",
|
|
||||||
"lightningcss-darwin-arm64": "^1.32.0",
|
|
||||||
"lightningcss-linux-arm64-gnu": "^1.32.0"
|
|
||||||
},
|
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
<svg viewbox="0 0 24 24" fill="none"><path d="M12 21s-7-4.3-7-9.5A4 4 0 0 1 12 8a4 4 0 0 1 7 3.5C19 16.7 12 21 12 21Z" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg>
|
<svg viewbox="0 0 24 24" fill="none"><path d="M12 21s-7-4.3-7-9.5A4 4 0 0 1 12 8a4 4 0 0 1 7 3.5C19 16.7 12 21 12 21Z" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<b class="mono">۹۵٪</b>
|
<b style="font-variant-numeric:tabular-nums">۹۵٪</b>
|
||||||
<span>رضایت بیماران</span>
|
<span>رضایت بیماران</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
<svg viewbox="0 0 24 24" fill="none"><path d="M8 7V5m8 2V5M4 9h16M5 7h14v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7Z" stroke="currentColor" stroke-width="1.9"/></svg>
|
<svg viewbox="0 0 24 24" fill="none"><path d="M8 7V5m8 2V5M4 9h16M5 7h14v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7Z" stroke="currentColor" stroke-width="1.9"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<b class="mono">۳۸</b>
|
<b style="font-variant-numeric:tabular-nums">۳۸</b>
|
||||||
<span>نوبت امروز</span>
|
<span>نوبت امروز</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,10 +553,12 @@ var btn2 = document.getElementById('openRegModal2');
|
|||||||
if (btn1)
|
if (btn1)
|
||||||
btn1.addEventListener('click', openRegModal);
|
btn1.addEventListener('click', openRegModal);
|
||||||
|
|
||||||
|
|
||||||
if (btn2)
|
if (btn2)
|
||||||
btn2.addEventListener('click', openRegModal);
|
btn2.addEventListener('click', openRegModal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
document.querySelectorAll('.reg-card').forEach(function (card) {
|
document.querySelectorAll('.reg-card').forEach(function (card) {
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
document.querySelectorAll('.reg-card').forEach(function (c) {
|
document.querySelectorAll('.reg-card').forEach(function (c) {
|
||||||
@@ -567,6 +569,7 @@ var radio = card.querySelector('input[type=radio]');
|
|||||||
if (radio)
|
if (radio)
|
||||||
radio.checked = true;
|
radio.checked = true;
|
||||||
|
|
||||||
|
|
||||||
selectedType = card.dataset.type;
|
selectedType = card.dataset.type;
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.style.opacity = '1';
|
submitBtn.style.opacity = '1';
|
||||||
@@ -582,6 +585,7 @@ var msg = document.getElementById('regMsg');
|
|||||||
if (! selectedType)
|
if (! selectedType)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
||||||
if (! name) {
|
if (! name) {
|
||||||
showMsg('نام را وارد کنید', 'error');
|
showMsg('نام را وارد کنید', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -613,7 +617,9 @@ return r.json();
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showMsg('درخواست شما ثبت شد. پس از بررسی، اطلاعات ورود از طریق SMS ارسال میشود.', 'success');
|
showMsg('درخواست شما ثبت شد. پس از بررسی، اطلاعات ورود از طریق SMS ارسال میشود.', 'success');
|
||||||
submitBtn.textContent = 'ارسال شد ✓';
|
submitBtn.textContent = 'ارسال شد ✓';
|
||||||
setTimeout(function() { closeRegModal(); }, 2200);
|
setTimeout(function () {
|
||||||
|
closeRegModal();
|
||||||
|
}, 2200);
|
||||||
} else {
|
} else {
|
||||||
var errMsg = (data.errors && data.errors[0]) ? data.errors[0].message : 'خطایی رخ داد';
|
var errMsg = (data.errors && data.errors[0]) ? data.errors[0].message : 'خطایی رخ داد';
|
||||||
showMsg(errMsg, 'error');
|
showMsg(errMsg, 'error');
|
||||||
@@ -663,6 +669,8 @@ msg.style.border = type === 'success' ? '1px solid #bbf7d0' : '1px solid #fecaca
|
|||||||
<svg viewbox="0 0 24 24" fill="none"><path d="M4 20l1.4-4A8 8 0 1 1 9 18.6L4 20Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
<svg viewbox="0 0 24 24" fill="none"><path d="M4 20l1.4-4A8 8 0 1 1 9 18.6L4 20Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- نماد اعتماد الکترونیکی — بعد از لود کامل صفحه تزریق میشود (footer JS) -->
|
||||||
|
<div id="enamad-seal" style="margin-top:18px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
@@ -684,7 +692,7 @@ msg.style.border = type === 'success' ? '1px solid #bbf7d0' : '1px solid #fecaca
|
|||||||
</div>
|
</div>
|
||||||
<div class="fl">
|
<div class="fl">
|
||||||
<svg viewbox="0 0 24 24" fill="none"><path d="M4 6.5C4 5 5 4 6.5 4h2c.6 0 1.1.4 1.3 1l1 3c.2.6 0 1.2-.5 1.6L9 11c1 2 2.5 3.5 4.5 4.5l1.4-1.3c.4-.4 1-.6 1.6-.4l3 1c.6.2 1 .7 1 1.3v2c0 1.5-1 2.5-2.5 2.4C10.5 20 4 13.5 4 6.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
<svg viewbox="0 0 24 24" fill="none"><path d="M4 6.5C4 5 5 4 6.5 4h2c.6 0 1.1.4 1.3 1l1 3c.2.6 0 1.2-.5 1.6L9 11c1 2 2.5 3.5 4.5 4.5l1.4-1.3c.4-.4 1-.6 1.6-.4l3 1c.6.2 1 .7 1 1.3v2c0 1.5-1 2.5-2.5 2.4C10.5 20 4 13.5 4 6.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
||||||
<a href="tel:07491012178" class="mono" dir="ltr">۰۷۴۹۱۰۱۲۱۷۸</a>
|
<a href="tel:07491012178" style="font-variant-numeric:tabular-nums" dir="ltr">۰۷۴۹۱۰۱۲۱۷۸</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="fl">
|
<div class="fl">
|
||||||
<svg viewbox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="m4 7 8 5 8-5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
<svg viewbox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="m4 7 8 5 8-5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
|
||||||
@@ -764,6 +772,7 @@ navLinks.style.boxShadow = '0 16px 30px oklch(0.4 0.08 285 / 0.1)';
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var sections = [
|
var sections = [
|
||||||
'hero',
|
'hero',
|
||||||
'features',
|
'features',
|
||||||
@@ -780,6 +789,7 @@ var el = document.getElementById(id);
|
|||||||
if (el && el.offsetTop <= y)
|
if (el && el.offsetTop <= y)
|
||||||
cur = id;
|
cur = id;
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
links.forEach(function (a) {
|
links.forEach(function (a) {
|
||||||
a.classList.toggle('active', a.getAttribute('href') === '#' + cur);
|
a.classList.toggle('active', a.getAttribute('href') === '#' + cur);
|
||||||
@@ -805,6 +815,7 @@ reveals.forEach(function (el) {
|
|||||||
if (inView(el))
|
if (inView(el))
|
||||||
reveal(el);
|
reveal(el);
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
if ('IntersectionObserver' in window) {
|
if ('IntersectionObserver' in window) {
|
||||||
var io = new IntersectionObserver(function (es) {
|
var io = new IntersectionObserver(function (es) {
|
||||||
@@ -822,6 +833,7 @@ reveals.forEach(function (el) {
|
|||||||
if (! el.classList.contains('in'))
|
if (! el.classList.contains('in'))
|
||||||
io.observe(el);
|
io.observe(el);
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var ticking = false;
|
var ticking = false;
|
||||||
@@ -829,12 +841,14 @@ function sweep() {
|
|||||||
if (ticking)
|
if (ticking)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
||||||
ticking = true;
|
ticking = true;
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
reveals.forEach(function (el) {
|
reveals.forEach(function (el) {
|
||||||
if (inView(el))
|
if (inView(el))
|
||||||
reveal(el);
|
reveal(el);
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
ticking = false;
|
ticking = false;
|
||||||
});
|
});
|
||||||
@@ -845,6 +859,33 @@ setTimeout(function () {
|
|||||||
reveals.forEach(reveal);
|
reveals.forEach(reveal);
|
||||||
}, 2800);
|
}, 2800);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// نماد اعتماد الکترونیکی (Enamad) — فقط بعد از لود کامل صفحه تزریق میشود تا
|
||||||
|
// درخواست تصویرِ دامنهی بیرونی با منابع خود سایت رقابت نکند و سرعت لود پایین نیاید.
|
||||||
|
function mountEnamad() {
|
||||||
|
var slot = document.getElementById('enamad-seal');
|
||||||
|
if (! slot)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.setAttribute('referrerpolicy', 'origin');
|
||||||
|
a.target = '_blank';
|
||||||
|
a.href = 'https://trustseal.enamad.ir/?id=648891&Code=tudyxbILEtrmS2VZHeGr1LfmkrEerqeF';
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.setAttribute('referrerpolicy', 'origin');
|
||||||
|
img.src = 'https://trustseal.enamad.ir/logo.aspx?id=648891&Code=tudyxbILEtrmS2VZHeGr1LfmkrEerqeF';
|
||||||
|
img.alt = 'نماد اعتماد الکترونیکی';
|
||||||
|
img.style.cursor = 'pointer';
|
||||||
|
img.setAttribute('code', 'tudyxbILEtrmS2VZHeGr1LfmkrEerqeF');
|
||||||
|
img.loading = 'lazy';
|
||||||
|
a.appendChild(img);
|
||||||
|
slot.appendChild(a);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
mountEnamad();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', mountEnamad);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2744,12 +2744,12 @@
|
|||||||
source-map-js "^1.2.1"
|
source-map-js "^1.2.1"
|
||||||
tailwindcss "4.3.0"
|
tailwindcss "4.3.0"
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64@^4.3.0", "@tailwindcss/oxide-darwin-arm64@4.3.0":
|
"@tailwindcss/oxide-darwin-arm64@4.3.0":
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz"
|
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz"
|
||||||
integrity sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==
|
integrity sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu@^4.3.0", "@tailwindcss/oxide-linux-arm64-gnu@4.3.0":
|
"@tailwindcss/oxide-linux-arm64-gnu@4.3.0":
|
||||||
version "4.3.0"
|
version "4.3.0"
|
||||||
resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz"
|
resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz"
|
||||||
integrity sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==
|
integrity sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==
|
||||||
@@ -4849,12 +4849,12 @@ leaflet@^1.9.0, leaflet@^1.9.4:
|
|||||||
resolved "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz"
|
resolved "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz"
|
||||||
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
|
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
|
||||||
|
|
||||||
lightningcss-darwin-arm64@^1.32.0, lightningcss-darwin-arm64@1.32.0:
|
lightningcss-darwin-arm64@1.32.0:
|
||||||
version "1.32.0"
|
version "1.32.0"
|
||||||
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz"
|
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz"
|
||||||
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
|
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
|
||||||
|
|
||||||
lightningcss-linux-arm64-gnu@^1.32.0, lightningcss-linux-arm64-gnu@1.32.0:
|
lightningcss-linux-arm64-gnu@1.32.0:
|
||||||
version "1.32.0"
|
version "1.32.0"
|
||||||
resolved "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz"
|
resolved "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz"
|
||||||
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
|
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
|
||||||
|
|||||||
Reference in New Issue
Block a user