diff --git a/apps/admin-panel/nginx.conf b/apps/admin-panel/nginx.conf
index 9064b3a..c7c4592 100644
--- a/apps/admin-panel/nginx.conf
+++ b/apps/admin-panel/nginx.conf
@@ -45,6 +45,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # The files that say which release is live are never cached: index.html
+ # (it names the hashed bundles), sw.js (a stale service worker keeps serving
+ # the old precache) and version.json (the update gate polls it). They used
+ # to fall under the 6-month rule below, which could pin a browser to an old
+ # release. Exact matches win over that regex.
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /version.json {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /manifest.webmanifest {
+ add_header Cache-Control "no-cache" always;
+ try_files $uri =404;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/apps/admin-panel/src/App.vue b/apps/admin-panel/src/App.vue
index b11406f..d0fa724 100644
--- a/apps/admin-panel/src/App.vue
+++ b/apps/admin-panel/src/App.vue
@@ -21,6 +21,7 @@
>
+
@@ -36,6 +37,7 @@ import { notification } from 'ant-design-vue';
import faIR from 'ant-design-vue/es/locale/fa_IR';
import themeConfig from './themeConfig';
import { useLayoutStore } from './stores/layout';
+import AppUpdateGate from './components/common/AppUpdateGate.vue';
import { userName } from './utils';
const layout = useLayoutStore();
diff --git a/apps/admin-panel/src/components/common/AppUpdateGate.vue b/apps/admin-panel/src/components/common/AppUpdateGate.vue
new file mode 100644
index 0000000..de0fb14
--- /dev/null
+++ b/apps/admin-panel/src/components/common/AppUpdateGate.vue
@@ -0,0 +1,62 @@
+
+
+
+
+ نسخهی تازهی پنل آماده است
+ نسخهی {{ available.version }}
+
+
+
+
در این نسخه:
+
+
+
{{ n }}
+
+
+
+
بهبودها و رفع اشکالها.
+
کار ذخیرهنشدهای اگر در صفحه دارید، پیش از بهروزرسانی ثبتش کنید.
+
+
+ بهروزرسانی
+
+
+
+
+
+
+
diff --git a/apps/admin-panel/src/composables/useAppUpdate.ts b/apps/admin-panel/src/composables/useAppUpdate.ts
new file mode 100644
index 0000000..a3712f9
--- /dev/null
+++ b/apps/admin-panel/src/composables/useAppUpdate.ts
@@ -0,0 +1,84 @@
+// The update gate (scripts/app-version.mjs builds the other half).
+//
+// The bundle knows its own build number (__APP_BUILD__). Every few minutes,
+// and whenever the app comes back to the foreground, it reads version.json
+// from the server, bypassing every cache. A newer build there means a release
+// went out: the modal lists what changed since the build this tab is running,
+// and «بهروزرسانی» drops the service worker and every Cache Storage entry,
+// then reloads — so the next load is the new release, not a precached copy.
+// localStorage is left alone: the sign-in and settings survive the update.
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+
+declare const __APP_BUILD__: number
+declare const __APP_VERSION__: string
+
+export interface VersionInfo {
+ build: number
+ version: string
+ history: { build: number; date: string; notes: string[] }[]
+}
+
+const CHECK_EVERY_MS = 5 * 60 * 1000
+
+export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
+export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
+
+export function useAppUpdate() {
+ const available = ref(null)
+ const updating = ref(false)
+
+ /** What changed between the running build and the server's, newest first. */
+ const changes = computed(() => {
+ const info = available.value
+ if (!info) return []
+ return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
+ })
+
+ async function check() {
+ if (import.meta.env.DEV || !runningBuild || available.value) return
+ try {
+ const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
+ if (!res.ok) return
+ const info = (await res.json()) as VersionInfo
+ if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
+ } catch {
+ // Offline or a deploy in progress: try again on the next tick.
+ }
+ }
+
+ async function applyUpdate() {
+ updating.value = true
+ try {
+ const regs = await navigator.serviceWorker?.getRegistrations?.()
+ if (regs) await Promise.all(regs.map((r) => r.unregister()))
+ } catch {
+ /* no service worker */
+ }
+ try {
+ if ('caches' in window) {
+ const keys = await caches.keys()
+ await Promise.all(keys.map((k) => caches.delete(k)))
+ }
+ } catch {
+ /* Cache Storage unavailable */
+ }
+ window.location.reload()
+ }
+
+ let timer: ReturnType | undefined
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') check()
+ }
+
+ onMounted(() => {
+ check()
+ timer = setInterval(check, CHECK_EVERY_MS)
+ document.addEventListener('visibilitychange', onVisible)
+ })
+ onBeforeUnmount(() => {
+ if (timer) clearInterval(timer)
+ document.removeEventListener('visibilitychange', onVisible)
+ })
+
+ return { available, changes, updating, applyUpdate, check }
+}
diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts
index 03e6f7e..5c5ca93 100644
--- a/apps/admin-panel/vite.config.ts
+++ b/apps/admin-panel/vite.config.ts
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
+import { appVersion } from '../../scripts/app-version.mjs';
// Ported from legacy henza_admin/vue.config.js.
// The legacy build switched publicPath/outputDir on NODE_ENV=='branch'; in Vite the
@@ -23,7 +24,8 @@ export default defineConfig(({ mode }) => {
// the image only has `/assets/…` → white screen 404.
// Branch mode still mounts under `/branch/` when that mode is used.
base: isBranch ? '/branch/' : '/',
- plugins: [vue()],
+ // Build number + «what changed» notes → version.json (scripts/app-version.mjs).
+ plugins: [vue(), appVersion('admin-panel')],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
diff --git a/apps/user-app-v2/nginx.conf b/apps/user-app-v2/nginx.conf
index ac3d472..b8c6183 100644
--- a/apps/user-app-v2/nginx.conf
+++ b/apps/user-app-v2/nginx.conf
@@ -43,6 +43,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # The files that say which release is live are never cached: index.html
+ # (it names the hashed bundles), sw.js (a stale service worker keeps serving
+ # the old precache) and version.json (the update gate polls it). They used
+ # to fall under the 6-month rule below, which could pin a browser to an old
+ # release. Exact matches win over that regex.
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /version.json {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /manifest.webmanifest {
+ add_header Cache-Control "no-cache" always;
+ try_files $uri =404;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/apps/user-app-v2/src/App.vue b/apps/user-app-v2/src/App.vue
index 952463c..643cb8a 100644
--- a/apps/user-app-v2/src/App.vue
+++ b/apps/user-app-v2/src/App.vue
@@ -1,6 +1,6 @@
+
+
+
+
نسخهی {{ available.version }}
+
نسخهی تازه آماده است
+
+
+
+
در این نسخه:
+
+
+
{{ n }}
+
+
+
+
بهبودها و رفع اشکالها.
+
+
+
+
ورود و سبد خرید شما سر جایش میماند.
+
+
+
+
+
+
+
+
diff --git a/apps/user-app-v2/src/composables/useAppUpdate.ts b/apps/user-app-v2/src/composables/useAppUpdate.ts
new file mode 100644
index 0000000..7198575
--- /dev/null
+++ b/apps/user-app-v2/src/composables/useAppUpdate.ts
@@ -0,0 +1,84 @@
+// The update gate (scripts/app-version.mjs builds the other half).
+//
+// The bundle knows its own build number (__APP_BUILD__). Every few minutes,
+// and whenever the app comes back to the foreground, it reads version.json
+// from the server, bypassing every cache. A newer build there means a release
+// went out: the modal lists what changed since the build this tab is running,
+// and «بهروزرسانی» drops the service worker and every Cache Storage entry,
+// then reloads — so the next load is the new release, not a precached copy.
+// localStorage is left alone: sign-in, cart and settings survive the update.
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+
+declare const __APP_BUILD__: number
+declare const __APP_VERSION__: string
+
+export interface VersionInfo {
+ build: number
+ version: string
+ history: { build: number; date: string; notes: string[] }[]
+}
+
+const CHECK_EVERY_MS = 5 * 60 * 1000
+
+export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
+export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
+
+export function useAppUpdate() {
+ const available = ref(null)
+ const updating = ref(false)
+
+ /** What changed between the running build and the server's, newest first. */
+ const changes = computed(() => {
+ const info = available.value
+ if (!info) return []
+ return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
+ })
+
+ async function check() {
+ if (import.meta.env.DEV || !runningBuild || available.value) return
+ try {
+ const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
+ if (!res.ok) return
+ const info = (await res.json()) as VersionInfo
+ if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
+ } catch {
+ // Offline or a deploy in progress: try again on the next tick.
+ }
+ }
+
+ async function applyUpdate() {
+ updating.value = true
+ try {
+ const regs = await navigator.serviceWorker?.getRegistrations?.()
+ if (regs) await Promise.all(regs.map((r) => r.unregister()))
+ } catch {
+ /* no service worker */
+ }
+ try {
+ if ('caches' in window) {
+ const keys = await caches.keys()
+ await Promise.all(keys.map((k) => caches.delete(k)))
+ }
+ } catch {
+ /* Cache Storage unavailable */
+ }
+ window.location.reload()
+ }
+
+ let timer: ReturnType | undefined
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') check()
+ }
+
+ onMounted(() => {
+ check()
+ timer = setInterval(check, CHECK_EVERY_MS)
+ document.addEventListener('visibilitychange', onVisible)
+ })
+ onBeforeUnmount(() => {
+ if (timer) clearInterval(timer)
+ document.removeEventListener('visibilitychange', onVisible)
+ })
+
+ return { available, changes, updating, applyUpdate, check }
+}
diff --git a/apps/user-app-v2/src/composables/useRefresh.ts b/apps/user-app-v2/src/composables/useRefresh.ts
deleted file mode 100644
index 6f62268..0000000
--- a/apps/user-app-v2/src/composables/useRefresh.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// Ported from legacy henza_pwa/src/Mixins/useRefresh.ts.
-// Auto-applies a waiting service worker as soon as one is available, so the
-// PWA self-updates instead of serving a stale precache indefinitely.
-import { unref, watchEffect } from 'vue'
-import { useRegisterSW } from 'virtual:pwa-register/vue'
-
-export function useRefresh() {
- const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
-
- const refreshApp = async () => {
- await updateServiceWorker()
- offlineReady.value = false
- needRefresh.value = false
- }
-
- watchEffect(async () => (unref(offlineReady) || unref(needRefresh)) && (await refreshApp()))
-}
diff --git a/apps/user-app-v2/vite.config.ts b/apps/user-app-v2/vite.config.ts
index d44e87a..81eac3b 100644
--- a/apps/user-app-v2/vite.config.ts
+++ b/apps/user-app-v2/vite.config.ts
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
+import { appVersion } from '../../scripts/app-version.mjs';
import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
@@ -15,6 +16,8 @@ const apiPort = Number(rootEnv.API_PORT ?? 3000);
export default defineConfig({
envDir: rootDir,
plugins: [
+ // Build number + «what changed» notes → version.json (scripts/app-version.mjs).
+ appVersion('user-app-v2'),
vue(),
// virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(),
diff --git a/apps/user-app/nginx.conf b/apps/user-app/nginx.conf
index f61f04a..6b6a171 100644
--- a/apps/user-app/nginx.conf
+++ b/apps/user-app/nginx.conf
@@ -46,6 +46,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # The files that say which release is live are never cached: index.html
+ # (it names the hashed bundles), sw.js (a stale service worker keeps serving
+ # the old precache) and version.json (the update gate polls it). They used
+ # to fall under the 6-month rule below, which could pin a browser to an old
+ # release. Exact matches win over that regex.
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /version.json {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /manifest.webmanifest {
+ add_header Cache-Control "no-cache" always;
+ try_files $uri =404;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/apps/user-app/src/App.vue b/apps/user-app/src/App.vue
index ad0ee9e..643cb8a 100644
--- a/apps/user-app/src/App.vue
+++ b/apps/user-app/src/App.vue
@@ -1,6 +1,6 @@
-
-
-
-
-
نسخه جدید منتزر منتشر شد!
-
-
-
-
به روز رسانی
-
-
+
@@ -45,22 +27,19 @@
diff --git a/apps/user-app/src/composables/useAppUpdate.ts b/apps/user-app/src/composables/useAppUpdate.ts
new file mode 100644
index 0000000..7198575
--- /dev/null
+++ b/apps/user-app/src/composables/useAppUpdate.ts
@@ -0,0 +1,84 @@
+// The update gate (scripts/app-version.mjs builds the other half).
+//
+// The bundle knows its own build number (__APP_BUILD__). Every few minutes,
+// and whenever the app comes back to the foreground, it reads version.json
+// from the server, bypassing every cache. A newer build there means a release
+// went out: the modal lists what changed since the build this tab is running,
+// and «بهروزرسانی» drops the service worker and every Cache Storage entry,
+// then reloads — so the next load is the new release, not a precached copy.
+// localStorage is left alone: sign-in, cart and settings survive the update.
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+
+declare const __APP_BUILD__: number
+declare const __APP_VERSION__: string
+
+export interface VersionInfo {
+ build: number
+ version: string
+ history: { build: number; date: string; notes: string[] }[]
+}
+
+const CHECK_EVERY_MS = 5 * 60 * 1000
+
+export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
+export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
+
+export function useAppUpdate() {
+ const available = ref(null)
+ const updating = ref(false)
+
+ /** What changed between the running build and the server's, newest first. */
+ const changes = computed(() => {
+ const info = available.value
+ if (!info) return []
+ return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
+ })
+
+ async function check() {
+ if (import.meta.env.DEV || !runningBuild || available.value) return
+ try {
+ const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
+ if (!res.ok) return
+ const info = (await res.json()) as VersionInfo
+ if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
+ } catch {
+ // Offline or a deploy in progress: try again on the next tick.
+ }
+ }
+
+ async function applyUpdate() {
+ updating.value = true
+ try {
+ const regs = await navigator.serviceWorker?.getRegistrations?.()
+ if (regs) await Promise.all(regs.map((r) => r.unregister()))
+ } catch {
+ /* no service worker */
+ }
+ try {
+ if ('caches' in window) {
+ const keys = await caches.keys()
+ await Promise.all(keys.map((k) => caches.delete(k)))
+ }
+ } catch {
+ /* Cache Storage unavailable */
+ }
+ window.location.reload()
+ }
+
+ let timer: ReturnType | undefined
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') check()
+ }
+
+ onMounted(() => {
+ check()
+ timer = setInterval(check, CHECK_EVERY_MS)
+ document.addEventListener('visibilitychange', onVisible)
+ })
+ onBeforeUnmount(() => {
+ if (timer) clearInterval(timer)
+ document.removeEventListener('visibilitychange', onVisible)
+ })
+
+ return { available, changes, updating, applyUpdate, check }
+}
diff --git a/apps/user-app/src/composables/useRefresh.ts b/apps/user-app/src/composables/useRefresh.ts
deleted file mode 100644
index 6f62268..0000000
--- a/apps/user-app/src/composables/useRefresh.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// Ported from legacy henza_pwa/src/Mixins/useRefresh.ts.
-// Auto-applies a waiting service worker as soon as one is available, so the
-// PWA self-updates instead of serving a stale precache indefinitely.
-import { unref, watchEffect } from 'vue'
-import { useRegisterSW } from 'virtual:pwa-register/vue'
-
-export function useRefresh() {
- const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
-
- const refreshApp = async () => {
- await updateServiceWorker()
- offlineReady.value = false
- needRefresh.value = false
- }
-
- watchEffect(async () => (unref(offlineReady) || unref(needRefresh)) && (await refreshApp()))
-}
diff --git a/apps/user-app/vite.config.ts b/apps/user-app/vite.config.ts
index 52bc8d0..ece2db1 100644
--- a/apps/user-app/vite.config.ts
+++ b/apps/user-app/vite.config.ts
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
+import { appVersion } from '../../scripts/app-version.mjs';
import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
@@ -15,6 +16,8 @@ const apiPort = Number(rootEnv.API_PORT ?? 3000);
export default defineConfig({
envDir: rootDir,
plugins: [
+ // Build number + «what changed» notes → version.json (scripts/app-version.mjs).
+ appVersion('user-app'),
vue(),
// virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(),
diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md
new file mode 100644
index 0000000..3d62102
--- /dev/null
+++ b/docs/RELEASE-NOTES.md
@@ -0,0 +1,52 @@
+# نسخه و پیام «چه چیزی تغییر کرد»
+
+هر بار که پنل یا اپها ساخته میشوند، شمارهی نسخه **خودش بالا میرود**؛ شماره همان
+تعداد کامیتهای شاخه است و فایلی برای دستی بالا بردن ندارد.
+
+بعد از انتشار، هر کسی که برنامه را باز دارد حداکثر تا ۵ دقیقه، یا همان لحظهای که
+دوباره به برنامه برمیگردد، پنجرهی «نسخهی تازه آماده است» را میبیند:
+
+- فهرست تغییراتِ **همان بخش** نشان داده میشود: اپ مشتری تغییرات اپ را میبیند و
+ پنل تغییرات پنل را.
+- پنجره بسته نمیشود؛ تنها راه، دکمهی «بهروزرسانی» است.
+- با زدن دکمه، حافظهی نهان برنامه پاک و صفحه دوباره بار میشود.
+- ورود کاربر، سبد خرید و تنظیمات **پاک نمیشوند**.
+
+## متن تغییرات از کجا میآید؟
+
+از پیام کامیتها. متن فنی و انگلیسی **هیچوقت** به کاربر نشان داده نمیشود؛ فقط
+خطهایی که با یکی از این برچسبها شروع شوند:
+
+| برچسب | کجا نشان داده میشود |
+|---|---|
+| `اپ:` | هر دو اپ مشتری |
+| `اپ۱:` | فقط اپ نسخهی ۱ |
+| `اپ۲:` | فقط اپ نسخهی ۲ |
+| `پنل:` | پنل مدیریت |
+
+نمونهی پیام کامیت:
+
+```
+fix(cart): keep the cart when the price refreshes
+
+اپ: سبد خرید با بهروز شدن قیمت دیگر خالی نمیشود.
+پنل: در فهرست سفارشها، تاریخ ثبت دقیق نشان داده میشود.
+```
+
+### چطور بنویسیم
+
+- برای **کاربر** بنویسید، نه برنامهنویس: «چه چیزی برای او فرق کرده».
+- یک خط کوتاه برای هر تغییر.
+- کامیتی که کاربر چیزی از آن نمیبیند (تمیزکاری، تست، تغییر داخلی) **برچسب نمیخواهد**.
+- اگر در یک انتشار هیچ خطِ برچسبداری نباشد، پنجره فقط «بهبودها و رفع اشکالها»
+ را نشان میدهد.
+
+## از نظر فنی
+
+- `scripts/app-version.mjs` موقع ساخت، شماره و یادداشتها را درمیآورد و
+ `version.json` را کنار برنامه میگذارد.
+- `useAppUpdate.ts` در هر برنامه آن فایل را بدون حافظهی نهان میخواند و با شمارهی
+ خودش مقایسه میکند.
+- تنظیم nginx هر سه برنامه: `index.html`، `sw.js` و `version.json` هرگز در مرورگر
+ نگه داشته نمیشوند. پیشتر `sw.js` شش ماه نگه داشته میشد و میتوانست مرورگر را
+ روی نسخهی قدیمی نگه دارد.
diff --git a/nginx-spa.conf b/nginx-spa.conf
index 5b46960..dc5aee1 100644
--- a/nginx-spa.conf
+++ b/nginx-spa.conf
@@ -14,6 +14,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # The files that say which release is live are never cached: index.html
+ # (it names the hashed bundles), sw.js (a stale service worker keeps serving
+ # the old precache) and version.json (the update gate polls it). They used
+ # to fall under the 6-month rule below, which could pin a browser to an old
+ # release. Exact matches win over that regex.
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /sw.js {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /version.json {
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ try_files $uri =404;
+ }
+ location = /manifest.webmanifest {
+ add_header Cache-Control "no-cache" always;
+ try_files $uri =404;
+ }
+
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/scripts/app-version.d.mts b/scripts/app-version.d.mts
new file mode 100644
index 0000000..212b7ed
--- /dev/null
+++ b/scripts/app-version.d.mts
@@ -0,0 +1,14 @@
+import type { Plugin } from 'vite';
+
+export interface AppVersionInfo {
+ app: string;
+ build: number;
+ version: string;
+ commit: string | null;
+ builtAt: string;
+ history: { build: number; date: string; notes: string[] }[];
+}
+
+export function notesFor(app: string, message: string): string[];
+export function computeAppVersion(app: string, cwd?: string): AppVersionInfo;
+export function appVersion(app: 'user-app' | 'user-app-v2' | 'admin-panel'): Plugin;
diff --git a/scripts/app-version.mjs b/scripts/app-version.mjs
new file mode 100644
index 0000000..cc45438
--- /dev/null
+++ b/scripts/app-version.mjs
@@ -0,0 +1,89 @@
+// App version + «what changed» notes, baked into each front-end at build time.
+//
+// Version: the build number is the commit count of HEAD, so it only ever goes
+// up and needs no file to bump. It is compiled into the bundle (__APP_BUILD__)
+// and written to dist/version.json; the running app polls that file and, when
+// the server's build is newer than its own, shows the update modal.
+//
+// Notes come from commit messages. Technical text is never shown: only lines
+// that start with a Persian tag, written for the person using that part:
+//
+// اپ: … both customer apps
+// اپ۱: … user-app only
+// اپ۲: … user-app-v2 only
+// پنل: … the admin panel
+//
+// A commit may carry several such lines. Each app shows only its own.
+
+import { execFileSync } from 'node:child_process';
+
+const TAGS = {
+ 'user-app': ['اپ', 'اپ۱', 'اپ1'],
+ 'user-app-v2': ['اپ', 'اپ۲', 'اپ2'],
+ 'admin-panel': ['پنل'],
+};
+
+/** How many released builds with notes the file keeps. */
+const HISTORY = 30;
+
+function git(args, cwd) {
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
+}
+
+/** Pure, for testing: the note lines in one commit message that belong to `app`. */
+export function notesFor(app, message) {
+ const tags = TAGS[app] ?? [];
+ const out = [];
+ for (const raw of message.split(/\r?\n/)) {
+ const m = /^\s*([^\s::]+)\s*[::]\s*(.+?)\s*$/.exec(raw);
+ // The note must be Persian: a wrapped English line that happens to begin
+ // with a tag (e.g. in a commit body explaining the tags) is not a note.
+ if (m && tags.includes(m[1]) && /[-ۿ]/.test(m[2])) out.push(m[2]);
+ }
+ return out;
+}
+
+export function computeAppVersion(app, cwd = process.cwd()) {
+ const builtAt = new Date().toISOString();
+ try {
+ const build = Number(git(['rev-list', '--count', 'HEAD'], cwd).trim());
+ const commit = git(['rev-parse', '--short', 'HEAD'], cwd).trim();
+ // Newest first; a record separator between commits, unit separator between fields.
+ const log = git(['log', '-n', '400', '--format=%ct%x1f%B%x1e'], cwd);
+ const history = [];
+ log.split('\x1e').forEach((rec, i) => {
+ const [ts, body] = rec.replace(/^\s+/, '').split('\x1f');
+ if (!body) return;
+ const notes = notesFor(app, body);
+ if (!notes.length) return;
+ history.push({ build: build - i, date: new Date(Number(ts) * 1000).toISOString(), notes });
+ });
+ return { app, build, version: `1.${build}`, commit, builtAt, history: history.slice(0, HISTORY) };
+ } catch {
+ // Not a git checkout (e.g. a copied tree): still a valid, if note-less, build.
+ const build = Math.floor(Date.now() / 1000);
+ return { app, build, version: `1.${build}`, commit: null, builtAt, history: [] };
+ }
+}
+
+/**
+ * Vite plugin: defines __APP_BUILD__ / __APP_VERSION__ and emits version.json.
+ * The dev server has no version.json, so the update check stays quiet there.
+ */
+export function appVersion(app) {
+ const info = computeAppVersion(app);
+ return {
+ name: 'app-version',
+ config() {
+ return {
+ define: {
+ __APP_BUILD__: JSON.stringify(info.build),
+ __APP_VERSION__: JSON.stringify(info.version),
+ },
+ };
+ },
+ generateBundle() {
+ this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify(info, null, 2) });
+ },
+ };
+}