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
+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) });
},
};
}