feat(apps): version on every build, a «what changed» modal, and a clean update

- The build number is the commit count, so every build raises it with no
  file to bump. scripts/app-version.mjs bakes it into each front-end
  (__APP_BUILD__) and writes dist/version.json with that app's notes.
- Notes come from commit messages: only Persian lines that start with
  one of the four app tags are shown, each to its own app
  (docs/RELEASE-NOTES.md).
- Both customer apps and the admin panel poll version.json (every 5
  minutes and on return to the tab, bypassing caches). A newer build opens
  a modal that cannot be dismissed. «به‌روزرسانی» unregisters the service
  worker, empties Cache Storage and reloads; localStorage (sign-in, cart,
  settings) is kept.
- Replaces legacy's version dialog, which compared against a
  package.json version that was never bumped and so never opened, and
  useRefresh, which swapped in a new service worker silently.
- nginx (all three apps and nginx-spa.conf): index.html, sw.js and
  version.json are never cached. sw.js used to fall under the 6-month rule,
  which could pin a browser to an old release.

Checked: served with the real nginx config, a newer version.json opens
the modal with its notes; the same build does not.

اپ: اجرت هر کالا حالا همان است که در فاکتور حساب می‌شود؛ درصد خود شما و تخفیف‌ها هم در آن آمده است.
اپ: علاقه‌مندی‌های شما ذخیره می‌شوند و در هر دستگاهی که وارد شوید دیده می‌شوند.
اپ: از این به بعد هر نسخه‌ی تازه با فهرست تغییراتش اعلام می‌شود.
پنل: بخش تازه‌ی «اجرت»: فرمول اجرت را با یک نمونه امتحان و بعد تأیید کنید.
پنل: شهرها مثل قبل به‌صورت درخت‌اند (کشور، استان، شهر، محله)؛ شهری که درصد ندارد درصد شهر بالاتر را می‌گیرد.
پنل: «تاریخ تولد» به فرم مشتری عمده و تکی برگشت.
پنل: در گزارش تجمیعی سفارش‌ها، دکمه‌ی «دریافت فایل اکسل» اضافه شد.
پنل: رد احراز هویت مشتری حالا دلیل هم می‌گیرد.
پنل: با تأیید یا رد سند مالی، به مشتری پیامک می‌رود؛ در پیامک گروهی، %name با نام مشتری عوض می‌شود.
پنل: از این به بعد هر نسخه‌ی تازه‌ی پنل با فهرست تغییراتش اعلام می‌شود.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-10 12:45:52 +03:30
co-authored by Claude Opus 5.5
parent 2bbd9408dd
commit dbead8a6ba
21 changed files with 816 additions and 126 deletions
+22
View File
@@ -45,6 +45,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+2
View File
@@ -21,6 +21,7 @@
> >
<div id="app" :class="vueAppClasses"> <div id="app" :class="vueAppClasses">
<router-view @setAppClasses="setAppClasses" /> <router-view @setAppClasses="setAppClasses" />
<AppUpdateGate />
</div> </div>
</a-config-provider> </a-config-provider>
</template> </template>
@@ -36,6 +37,7 @@ import { notification } from 'ant-design-vue';
import faIR from 'ant-design-vue/es/locale/fa_IR'; import faIR from 'ant-design-vue/es/locale/fa_IR';
import themeConfig from './themeConfig'; import themeConfig from './themeConfig';
import { useLayoutStore } from './stores/layout'; import { useLayoutStore } from './stores/layout';
import AppUpdateGate from './components/common/AppUpdateGate.vue';
import { userName } from './utils'; import { userName } from './utils';
const layout = useLayoutStore(); const layout = useLayoutStore();
@@ -0,0 +1,62 @@
<template>
<!--
The update modal (composables/useAppUpdate.ts). It cannot be dismissed:
the only way out is the update, so every operator runs the live release
(owner ruling 2026-10-10).
-->
<a-modal
:open="Boolean(available)"
:closable="false"
:mask-closable="false"
:keyboard="false"
:z-index="2000"
width="420px"
centered
>
<template #title>
نسخه‌ی تازه‌ی پنل آماده است
<a-tag v-if="available" class="ms-2" color="blue">نسخه‌ی {{ available.version }}</a-tag>
</template>
<template v-if="changes.length">
<p class="lead">در این نسخه:</p>
<ul class="list">
<template v-for="c in changes" :key="c.build">
<li v-for="(n, i) in c.notes" :key="c.build + '-' + i">{{ n }}</li>
</template>
</ul>
</template>
<p v-else class="lead">بهبودها و رفع اشکال‌ها.</p>
<p class="hint">کار ذخیره‌نشده‌ای اگر در صفحه دارید، پیش از به‌روزرسانی ثبتش کنید.</p>
<template #footer>
<a-button type="primary" block :loading="updating" @click="applyUpdate">به‌روزرسانی</a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { useAppUpdate } from '../../composables/useAppUpdate';
const { available, changes, updating, applyUpdate } = useAppUpdate();
</script>
<style scoped>
.lead {
margin: 0 0 6px;
opacity: 0.75;
}
.list {
margin: 0 0 10px;
padding-inline-start: 18px;
line-height: 1.9;
max-height: 50vh;
list-style: disc;
overflow-y: auto;
}
.hint {
margin: 0;
font-size: 12px;
opacity: 0.6;
}
</style>
@@ -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<VersionInfo | null>(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<typeof setInterval> | 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 }
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { appVersion } from '../../scripts/app-version.mjs';
// Ported from legacy henza_admin/vue.config.js. // Ported from legacy henza_admin/vue.config.js.
// The legacy build switched publicPath/outputDir on NODE_ENV=='branch'; in Vite the // 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. // the image only has `/assets/…` → white screen 404.
// Branch mode still mounts under `/branch/` when that mode is used. // Branch mode still mounts under `/branch/` when that mode is used.
base: isBranch ? '/branch/' : '/', base: isBranch ? '/branch/' : '/',
plugins: [vue()], // Build number + «what changed» notes → version.json (scripts/app-version.mjs).
plugins: [vue(), appVersion('admin-panel')],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
+22
View File
@@ -43,6 +43,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+7 -39
View File
@@ -1,6 +1,6 @@
<!-- <!--
Ported from legacy henza_pwa/src/App.vue — layout switching, RTL shell, and Ported from legacy henza_pwa/src/App.vue — layout switching, RTL shell, and
the app-version-check / service-worker update dialog. and the update gate (components/AppUpdateGate.vue).
Not ported (deliberate): Firebase push init and WebOTP autofill. Both were Not ported (deliberate): Firebase push init and WebOTP autofill. Both were
already commented out in legacy's own App.vue, so neither ran there either. already commented out in legacy's own App.vue, so neither ran there either.
@@ -19,15 +19,7 @@
</router-view> </router-view>
</component> </component>
<IslandSheet <AppUpdateGate />
v-model:show="versionDialog"
art="update"
title="نسخه جدید منتزر منتشر شد!"
description="برای استفاده از آخرین امکانات و بهبودها، برنامه را به روز رسانی کنید."
confirm-text="به روز رسانی"
cancel-text="بعداً"
@confirm="update()"
/>
<ProductMorph /> <ProductMorph />
<ToastHost /> <ToastHost />
@@ -35,19 +27,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useRegisterSW } from 'virtual:pwa-register/vue' import { useRegisterSW } from 'virtual:pwa-register/vue'
import MasterLayout from './layout/MasterLayout.vue' import MasterLayout from './layout/MasterLayout.vue'
import FullPageLayout from './layout/FullPageLayout.vue' import FullPageLayout from './layout/FullPageLayout.vue'
import IslandSheet from './components/ui/IslandSheet.vue' import AppUpdateGate from './components/AppUpdateGate.vue'
import ToastHost from './components/ui/ToastHost.vue' import ToastHost from './components/ui/ToastHost.vue'
import ProductMorph from './components/product/ProductMorph.vue' import ProductMorph from './components/product/ProductMorph.vue'
import { useRefresh } from './composables/useRefresh'
import pkg from '../package.json'
const router = useRouter() const router = useRouter()
const { updateServiceWorker } = useRegisterSW() // Registers the service worker (offline, install). Updates are not applied
// here: AppUpdateGate shows what changed and applies them on the user's tap.
useRegisterSW()
const getLayout = computed(() => { const getLayout = computed(() => {
if (router.currentRoute.value.meta && router.currentRoute.value.meta.layout === 'FullPage') { if (router.currentRoute.value.meta && router.currentRoute.value.meta.layout === 'FullPage') {
@@ -55,30 +47,6 @@ const getLayout = computed(() => {
} }
return MasterLayout return MasterLayout
}) })
// Ported from legacy App.vue:52-74 — compare the stored version against the
// bundled package version and offer a one-tap service-worker update.
const versionDialog = ref(false)
function checkVersion() {
const version = localStorage.getItem('version')
if (version) {
if (version !== pkg.version) versionDialog.value = true
} else {
localStorage.setItem('version', pkg.version)
}
}
function update() {
localStorage.setItem('version', pkg.version)
updateServiceWorker()
window.location.reload()
}
onMounted(() => {
useRefresh()
checkVersion()
})
</script> </script>
<style lang="less"> <style lang="less">
@@ -0,0 +1,117 @@
<template>
<!--
The update modal. It cannot be dismissed: the only way out is the update,
so every customer runs the release that is live (owner ruling 2026-10-10).
-->
<Teleport to="body">
<div v-if="available" class="update-gate" role="dialog" aria-modal="true" aria-labelledby="update-gate-title">
<div class="update-gate__card">
<div class="update-gate__badge">نسخه‌ی {{ available.version }}</div>
<h2 id="update-gate-title" class="update-gate__title">نسخه‌ی تازه آماده است</h2>
<div class="update-gate__body">
<template v-if="changes.length">
<p class="update-gate__lead">در این نسخه:</p>
<ul class="update-gate__list">
<template v-for="c in changes" :key="c.build">
<li v-for="(n, i) in c.notes" :key="c.build + '-' + i">{{ n }}</li>
</template>
</ul>
</template>
<p v-else class="update-gate__lead">بهبودها و رفع اشکال‌ها.</p>
</div>
<button type="button" class="update-gate__button" :disabled="updating" @click="applyUpdate">
{{ updating ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی' }}
</button>
<p class="update-gate__hint">ورود و سبد خرید شما سر جایش می‌ماند.</p>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useAppUpdate } from '../composables/useAppUpdate'
const { available, changes, updating, applyUpdate } = useAppUpdate()
</script>
<style scoped>
.update-gate {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.55);
direction: rtl;
}
.update-gate__card {
width: 100%;
max-width: 380px;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px 20px 18px;
border-radius: 18px;
background: var(--c-canvas, #fff);
color: var(--c-ink, #1f1f1f);
border: 1px solid var(--c-hairline, transparent);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
text-align: right;
}
.update-gate__badge {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
background: color-mix(in srgb, var(--c-primary, #b8860b) 14%, transparent);
color: var(--c-primary, #b8860b);
}
.update-gate__title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.update-gate__body {
overflow-y: auto;
min-height: 0;
}
.update-gate__lead {
margin: 0 0 6px;
font-size: 14px;
color: var(--c-inkMuted, inherit);
}
.update-gate__list {
margin: 0;
padding-inline-start: 18px;
list-style: disc;
font-size: 14px;
line-height: 1.9;
}
.update-gate__button {
width: 100%;
padding: 12px;
border: 0;
border-radius: 12px;
font: inherit;
font-size: 15px;
font-weight: 700;
color: #fff;
background: var(--c-primary, #b8860b);
cursor: pointer;
}
.update-gate__button:disabled {
opacity: 0.7;
cursor: progress;
}
.update-gate__hint {
margin: 0;
font-size: 12px;
text-align: center;
color: var(--c-inkMuted, inherit);
}
</style>
@@ -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<VersionInfo | null>(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<typeof setInterval> | 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 }
}
@@ -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()))
}
+3
View File
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { appVersion } from '../../scripts/app-version.mjs';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig'; import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance'; import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
@@ -15,6 +16,8 @@ const apiPort = Number(rootEnv.API_PORT ?? 3000);
export default defineConfig({ export default defineConfig({
envDir: rootDir, envDir: rootDir,
plugins: [ plugins: [
// Build number + «what changed» notes → version.json (scripts/app-version.mjs).
appVersion('user-app-v2'),
vue(), vue(),
// virtual:palette.css — the light/dark values behind the theme tokens. // virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(), palettePlugin(),
+22
View File
@@ -46,6 +46,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+7 -52
View File
@@ -1,6 +1,6 @@
<!-- <!--
Ported from legacy henza_pwa/src/App.vue — layout switching, RTL shell, and Ported from legacy henza_pwa/src/App.vue — layout switching, RTL shell, and
the app-version-check / service-worker update dialog. and the update gate (components/AppUpdateGate.vue).
Not ported (deliberate): Firebase push init and WebOTP autofill. Both were Not ported (deliberate): Firebase push init and WebOTP autofill. Both were
already commented out in legacy's own App.vue, so neither ran there either. already commented out in legacy's own App.vue, so neither ran there either.
@@ -19,25 +19,7 @@
</router-view> </router-view>
</component> </component>
<DialogVantOneButton :timeOut="-1" v-model:show="versionDialog" :closeOnClickOverlay="true"> <AppUpdateGate />
<template v-slot:img>
<!-- Still (first frame) in reduced-motion mode. -->
<DotLottieVue
class="w-full aspect-square"
:autoplay="!appearance.isReducedMotion"
:loop="!appearance.isReducedMotion"
src="https://lottie.host/cb620e8e-c64a-42e8-a6b5-8844146e5999/nmft7oBO8q.json"
/>
</template>
<template v-slot:title>
<div class="p-8 text-justify">نسخه جدید منتزر منتشر شد!</div>
</template>
<template v-slot:button>
<div class="text-center text-[color:var(--c-primary)]" @click="update()">به روز رسانی</div>
</template>
</DialogVantOneButton>
<ProductMorph /> <ProductMorph />
<ToastHost /> <ToastHost />
@@ -45,22 +27,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
import { useRegisterSW } from 'virtual:pwa-register/vue' import { useRegisterSW } from 'virtual:pwa-register/vue'
import MasterLayout from './layout/MasterLayout.vue' import MasterLayout from './layout/MasterLayout.vue'
import FullPageLayout from './layout/FullPageLayout.vue' import FullPageLayout from './layout/FullPageLayout.vue'
import DialogVantOneButton from './components/DialogVantOneButton.vue' import AppUpdateGate from './components/AppUpdateGate.vue'
import ToastHost from './components/ui/ToastHost.vue' import ToastHost from './components/ui/ToastHost.vue'
import ProductMorph from './components/product/ProductMorph.vue' import ProductMorph from './components/product/ProductMorph.vue'
import { useRefresh } from './composables/useRefresh'
import { useAppearanceStore } from './stores/appearanceStore'
import pkg from '../package.json'
const router = useRouter() const router = useRouter()
const appearance = useAppearanceStore() // Registers the service worker (offline, install). Updates are not applied
const { updateServiceWorker } = useRegisterSW() // here: AppUpdateGate shows what changed and applies them on the user's tap.
useRegisterSW()
const getLayout = computed(() => { const getLayout = computed(() => {
if (router.currentRoute.value.meta && router.currentRoute.value.meta.layout === 'FullPage') { if (router.currentRoute.value.meta && router.currentRoute.value.meta.layout === 'FullPage') {
@@ -68,30 +47,6 @@ const getLayout = computed(() => {
} }
return MasterLayout return MasterLayout
}) })
// Ported from legacy App.vue:52-74 — compare the stored version against the
// bundled package version and offer a one-tap service-worker update.
const versionDialog = ref(false)
function checkVersion() {
const version = localStorage.getItem('version')
if (version) {
if (version !== pkg.version) versionDialog.value = true
} else {
localStorage.setItem('version', pkg.version)
}
}
function update() {
localStorage.setItem('version', pkg.version)
updateServiceWorker()
window.location.reload()
}
onMounted(() => {
useRefresh()
checkVersion()
})
</script> </script>
<style lang="less"> <style lang="less">
@@ -0,0 +1,117 @@
<template>
<!--
The update modal. It cannot be dismissed: the only way out is the update,
so every customer runs the release that is live (owner ruling 2026-10-10).
-->
<Teleport to="body">
<div v-if="available" class="update-gate" role="dialog" aria-modal="true" aria-labelledby="update-gate-title">
<div class="update-gate__card">
<div class="update-gate__badge">نسخه‌ی {{ available.version }}</div>
<h2 id="update-gate-title" class="update-gate__title">نسخه‌ی تازه آماده است</h2>
<div class="update-gate__body">
<template v-if="changes.length">
<p class="update-gate__lead">در این نسخه:</p>
<ul class="update-gate__list">
<template v-for="c in changes" :key="c.build">
<li v-for="(n, i) in c.notes" :key="c.build + '-' + i">{{ n }}</li>
</template>
</ul>
</template>
<p v-else class="update-gate__lead">بهبودها و رفع اشکال‌ها.</p>
</div>
<button type="button" class="update-gate__button" :disabled="updating" @click="applyUpdate">
{{ updating ? 'در حال به‌روزرسانی…' : 'به‌روزرسانی' }}
</button>
<p class="update-gate__hint">ورود و سبد خرید شما سر جایش می‌ماند.</p>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useAppUpdate } from '../composables/useAppUpdate'
const { available, changes, updating, applyUpdate } = useAppUpdate()
</script>
<style scoped>
.update-gate {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.55);
direction: rtl;
}
.update-gate__card {
width: 100%;
max-width: 380px;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px 20px 18px;
border-radius: 18px;
background: var(--c-canvas, #fff);
color: var(--c-ink, #1f1f1f);
border: 1px solid var(--c-hairline, transparent);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
text-align: right;
}
.update-gate__badge {
align-self: flex-start;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
background: color-mix(in srgb, var(--c-primary, #b8860b) 14%, transparent);
color: var(--c-primary, #b8860b);
}
.update-gate__title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.update-gate__body {
overflow-y: auto;
min-height: 0;
}
.update-gate__lead {
margin: 0 0 6px;
font-size: 14px;
color: var(--c-inkMuted, inherit);
}
.update-gate__list {
margin: 0;
padding-inline-start: 18px;
list-style: disc;
font-size: 14px;
line-height: 1.9;
}
.update-gate__button {
width: 100%;
padding: 12px;
border: 0;
border-radius: 12px;
font: inherit;
font-size: 15px;
font-weight: 700;
color: #fff;
background: var(--c-primary, #b8860b);
cursor: pointer;
}
.update-gate__button:disabled {
opacity: 0.7;
cursor: progress;
}
.update-gate__hint {
margin: 0;
font-size: 12px;
text-align: center;
color: var(--c-inkMuted, inherit);
}
</style>
@@ -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<VersionInfo | null>(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<typeof setInterval> | 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 }
}
@@ -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()))
}
+3
View File
@@ -1,6 +1,7 @@
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
import { defineConfig, loadEnv } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { appVersion } from '../../scripts/app-version.mjs';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import { $themeLessVars } from './themeConfig/themeConfig'; import { $themeLessVars } from './themeConfig/themeConfig';
import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance'; import { lessAlphaPlugin, lightHex, palettePlugin } from './themeConfig/appearance';
@@ -15,6 +16,8 @@ const apiPort = Number(rootEnv.API_PORT ?? 3000);
export default defineConfig({ export default defineConfig({
envDir: rootDir, envDir: rootDir,
plugins: [ plugins: [
// Build number + «what changed» notes → version.json (scripts/app-version.mjs).
appVersion('user-app'),
vue(), vue(),
// virtual:palette.css — the light/dark values behind the theme tokens. // virtual:palette.css — the light/dark values behind the theme tokens.
palettePlugin(), palettePlugin(),
+52
View File
@@ -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` شش ماه نگه داشته می‌شد و می‌توانست مرورگر را
روی نسخه‌ی قدیمی نگه دارد.
+22
View File
@@ -14,6 +14,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+14
View File
@@ -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;
+89
View File
@@ -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) });
},
};
}