Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
// Ported from legacy henza_admin/src/axios.js.
//
// Legacy pointed at the live PHP backend:
// baseURL = 'https://henzagold.com/oapi/admin/'
// The new backend is the local NestJS API. Legacy endpoint names are translated
// to clean REST paths by ./endpoints.ts — see the mapping-layer note there.
import axios from 'axios';
import { tokenName } from '../utils';
import { mapEndpoint } from './endpoints';
export const baseURL = import.meta.env.VITE_API_BASE_URL ?? '/api/';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
const initialToken = localStorage.getItem(tokenName);
if (initialToken) {
headers['Authorization'] = 'Bearer ' + initialToken;
}
const ax = axios.create({
baseURL,
headers,
});
/**
* Legacy re-set the auth header on every request so a token acquired after boot
* was picked up without recreating the instance. Kept — plus the endpoint
* rewrite, and without legacy's habit of sending a literal "Bearer null" when
* no token existed.
*/
ax.interceptors.request.use(
(config) => {
if (config.url) {
config.url = mapEndpoint(config.url);
}
const token = localStorage.getItem(tokenName);
if (token) {
config.headers['Authorization'] = 'Bearer ' + token;
} else {
delete config.headers['Authorization'];
}
// The instance-level default Content-Type is 'application/json', and axios
// only auto-sets multipart/form-data + boundary when no Content-Type is
// already present — an existing default is NOT replaced. Posting a
// FormData body (file uploads) with the JSON default left in place sends
// the raw FormData object as a JSON-labelled body, so multer/NestJS's
// FileInterceptor sees no file at all and 400s. Let axios/the browser set
// the correct multipart boundary for FormData bodies instead.
if (config.data instanceof FormData) {
delete config.headers['Content-Type'];
}
return config;
},
(error) => Promise.reject(error),
);
/**
* NOT IN LEGACY: legacy had no response interceptor at all, so an expired token
* produced a silent failure on every subsequent call with no path back to login.
* A 401 now clears the stale session and redirects once.
*
* The redirect is a hard location assignment rather than a router push, to avoid
* importing the router here (circular: router -> stores -> client -> router).
*/
let redirecting = false;
ax.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401 && !redirecting) {
const onLoginPage = window.location.pathname.endsWith('/auth/login');
if (!onLoginPage) {
redirecting = true;
localStorage.removeItem(tokenName);
window.location.assign(`${import.meta.env.BASE_URL}auth/login`);
}
}
return Promise.reject(error);
},
);
export default ax;
+276
View File
@@ -0,0 +1,276 @@
/**
* Legacy → new endpoint mapping layer.
*
* This is the "لایه‌ی نگاشت" half of the agreed architecture: the NestJS API
* exposes clean REST, while ported views keep calling the endpoint names they
* were written against. Rather than rewriting ~114 call sites (and risking a
* silent typo in each), views keep their legacy path and this layer rewrites it.
*
* The legacy paths are irregular in ways worth preserving exactly:
* - inconsistent leading slash: 'brand' and '/brand' both appear
* - verbs in the path with the wrong HTTP method: POST 'delete/product/:id'
* - pseudo-namespaced constants: '/const/__list__ut'
* - reads done over POST: POST 'get/variants'
*
* Anything not listed here passes through untouched, so a view calling an
* already-clean path keeps working. Add entries as each batch of views lands.
*/
/** Strips the leading slash so 'brand' and '/brand' resolve identically. */
function normalize(url: string): string {
return url.replace(/^\/+/, '');
}
/**
* Exact-match rewrites: legacy path -> new path.
* Only endpoints whose backend counterpart exists today are listed; the rest
* arrive with their view batch.
*/
const EXACT: Record<string, string> = {
// ── file upload ───────────────────────────────────────────────────
// Legacy posted multipart to 'file/create' from MyUpload.vue,
// SingleUploadInput.vue, UploadInput.vue and PodcastUploadInput.vue.
'file/create': 'admin/files/create',
// ── auth (Phase 1) ────────────────────────────────────────────────
'admin-login': 'admin/auth/login',
'users/show': 'admin/auth/me',
// ── orders (Batch 3) ──────────────────────────────────────────────
// Legacy posted the grid request to an absolute URL ending in 'single-order'.
'single-order': 'admin/orders/list',
'single/order-sts/edit': 'admin/orders/status',
'major/order/table': 'admin/orders/wholesale/list',
'order-tamin': 'admin/orders/tamin/list',
// Legacy used a DIFFERENT path for the wholesale status edit than the single
// one ('order-sts/edit' vs 'single/order-sts/edit'); both land on the same
// endpoint here because the new schema has a single Order model.
'order-sts/edit': 'admin/orders/status',
'delete-order': 'admin/orders/delete',
// ── custom / made-to-order requests (PreOrder) ────────────────────
'get-custom-orders': 'admin/pre-orders/list',
// ── wholesale customers (Batch 4) ─────────────────────────────────
// Five legacy endpoints, one per near-identical list view.
customer: 'admin/customers-omde/list',
banker: 'admin/customers-omde/banker/list',
shopkeeper: 'admin/customers-omde/shopkeeper/list',
'major/verify/table': 'admin/customers-omde/non-verified/list',
'major/archive/table': 'admin/customers-omde/archived/list',
reagent: 'admin/customers-omde/reagent/list',
'customer/toggle-active': 'admin/customers-omde/toggle-active',
'customer/toggle-sts': 'admin/customers-omde/toggle-sts',
'major/verify': 'admin/customers-omde/verify',
// ── single (retail) customers + comments (Batch 5) ────────────────
'single-customer': 'admin/customers-omde/single/list',
// legacy views/apps/customer/singleCustomer.vue posts the retail form here.
// FIXED: this used to alias to the same admin/customers-omde/create as the
// wholesale form, which has no notion of "retail" and defaults kind to
// APP_SHOPKEEPER — every "تک" signup was silently created as a wholesale
// app-shopkeeper row, which is why "لیست مشتریان تک" never showed genuinely
// retail data even after that list gained a kind filter. Routed at its own
// endpoint now, which sets kind=RETAIL explicitly.
single: 'admin/customers-omde/single',
'customer/create': 'admin/customers-omde/create',
'query/reagent': 'admin/customers-omde/query/reagent',
'wallet/charge': 'admin/customers-omde/wallet/charge',
'comment/table': 'admin/comments/list',
'comment/sts': 'admin/comments/sts',
comment: 'admin/comments',
// ── admin order builder (views/order/create.vue) ──────────────────
order: 'admin/orders/create',
'order/type': 'admin/orders/type-options',
'add/variant': 'admin/products/variants/create',
'product/variant': 'admin/products/by-ids',
// ── products (Batch 6) ────────────────────────────────────────────
products: 'admin/products/list',
'product/create': 'admin/products/create',
'get/variants': 'admin/products/variants/list',
'change/percent/count': 'admin/products/change-percent/count',
'change/all': 'admin/products/change-percent/apply',
// ── financial documents + wallet (Batch 8) ────────────────────────
'cash/table': 'admin/documents/list',
cash: 'admin/documents',
'query/customer': 'admin/documents/query/customer',
'wallet/table': 'admin/documents/wallet/list',
'wallet/config': 'admin/documents/wallet/config',
'wallet/birthday/config': 'admin/documents/wallet/birthday/config',
// ── promotions + exclusions (Batch 9) ─────────────────────────────
'product/variant/promotion': 'admin/promotions',
'major/promotion': 'admin/promotions/list',
autopromotion: 'admin/promotions/auto',
ignore: 'admin/promotions/exclusions',
// ── admin users + permission matrix (Batch 10) ────────────────────
// NOTE 'users/show' is mapped above to admin/auth/me and, being an exact
// match, wins over the users/{id} pattern below.
users: 'admin/users',
'users/list': 'admin/users/list',
'const/__list__ut': 'admin/users/types',
const: 'admin/users/types',
'roles/create': 'admin/users/roles',
// ── branch allocations / "فاکتور" (Batch 12) ──────────────────────
'branch/product': 'admin/invoices',
'branch/product/table': 'admin/invoices/list',
'branch/list': 'admin/invoices/branch-list',
// ── branches + workshops (Batch 11) ───────────────────────────────
branch: 'admin/branches',
'branch/table': 'admin/branches/list',
workshop: 'admin/workshops',
'user/list': 'admin/branches/user-list',
// ── cities, sms, notify, newsletter, statics, consts (Batch 13) ───
cities: 'admin/misc/cities',
sms: 'admin/misc/sms',
'sms/table': 'admin/misc/sms/list',
notify: 'admin/misc/notify',
'notify/table': 'admin/misc/notify/list',
'notify/customer': 'admin/misc/notify/customer',
// legacy's own opaque name for the newsletter list
ddf: 'admin/misc/newsletter/list',
'fashion/table': 'admin/misc/newsletter/list',
'log/list/customer': 'admin/misc/log/customers',
'dashboard/link': 'admin/misc/dashboard-links',
// The two legacy dashboards are merged into one screen; both old names
// keep working (aliased server-side) but the new dashboard calls this.
'get-analytics': 'admin/dashboard/omde',
'get-analytics-tak': 'admin/dashboard/tak',
'dashboard/overview': 'admin/dashboard/overview',
'brand/create': 'admin/misc/brands',
// NOTE 'ciny' itself is already mapped in the categories block below.
'ciny/create': 'admin/categories/collections',
// ── lookups ───────────────────────────────────────────────────────
'cities/list': 'admin/cities/list',
// ── reports (both screens rendered permanently empty without these) ──
'report/order-item': 'admin/dashboard/report/order-item',
'report/product': 'admin/dashboard/report/product',
'workshop/table': 'admin/workshops/list',
brand: 'admin/products/brands',
/**
* Legacy `POST category` is ONE endpoint serving two callers: the category
* grid (CategoryTbl's url) and the product form's picker, which reads
* `data.rows` off the same response. Both map here, so the `{rows, query}`
* envelope satisfies each.
*/
category: 'admin/categories/list',
'category/': 'admin/categories',
ciny: 'admin/categories/collections/list',
};
/**
* Pattern rewrites for paths carrying an id. Evaluated in order, first match wins.
*/
const PATTERNS: { test: RegExp; to: (m: RegExpMatchArray) => string }[] = [
// ────────────────────────────────────────────────────────────────────
// Id/key-carrying paths whose EXACT siblings were mapped but whose own
// shapes were not, so they fell through to the pass-through branch and
// 404'd against the API root. Every one of these has a real backend route
// — these were mapping omissions, not missing features.
//
// NOTE all of them are safe against their EXACT counterparts because
// mapEndpoint consults EXACT in full before reaching PATTERNS.
// ────────────────────────────────────────────────────────────────────
// Cities: the two percent lookups are two-segment, so they precede cities/:id.
{ test: /^cities\/get-percent-aux\/(.+)$/, to: (m) => `admin/misc/cities/percent-aux/${m[1]}` },
{ test: /^cities\/get-percent\/(.+)$/, to: (m) => `admin/misc/cities/percent/${m[1]}` },
{ test: /^cities\/(.+)$/, to: (m) => `admin/misc/cities/${m[1]}` },
// Settings constants by key — drives the برچسب ها and اسلایدر موبایل tabs.
{ test: /^const\/(.+)$/, to: (m) => `admin/misc/const/${m[1]}` },
// Brand edit/delete ('brand/create' stays an EXACT match).
{ test: /^brand\/(.+)$/, to: (m) => `admin/misc/brands/${m[1]}` },
// Customer activity log. Legacy's path is 'log/ago/{id}'.
{ test: /^log\/ago\/(.+)$/, to: (m) => `admin/misc/log/customer/${m[1]}` },
// Notifications: mark-read is two segments, so it precedes notify/:id.
{ test: /^notify\/read\/(.+)$/, to: (m) => `admin/misc/notify/read/${m[1]}` },
{ test: /^notify\/(.+)$/, to: (m) => `admin/misc/notify/${m[1]}` },
// Retail customer edit. 'single/order-sts/edit' is EXACT and still wins.
{ test: /^single\/(.+)$/, to: (m) => `admin/customers-omde/${m[1]}` },
// Custom order by id: legacy PUT/DELETE custom-order/{id}
{ test: /^custom-order\/(.+)$/, to: (m) => `admin/pre-orders/${m[1]}` },
{ test: /^get-custom-order\/(.+)$/, to: (m) => `admin/pre-orders/${m[1]}` },
// Wholesale customers: archive toggle + per-customer dashboard
{ test: /^major\/archive\/(.+)$/, to: (m) => `admin/customers-omde/archive/${m[1]}` },
{ test: /^major\/dashboard\/(.+)$/, to: (m) => `admin/customers-omde/dashboard/${m[1]}` },
// Comment by id (DELETE comment/{id}); must precede the customer pattern below
// since both are single-segment-with-id shapes.
{ test: /^comment\/(.+)$/, to: (m) => `admin/comments/${m[1]}` },
// Customer by id: legacy GET/PUT customer/{id}
{ test: /^customer\/(.+)$/, to: (m) => `admin/customers-omde/${m[1]}` },
// Products — note legacy's irregular shapes: a POST that deletes, and
// 'product/show/{id}' / 'product/update/{id}' rather than REST verbs.
{ test: /^delete\/product\/(.+)$/, to: (m) => `admin/products/delete/${m[1]}` },
{ test: /^product\/show\/(.+)$/, to: (m) => `admin/products/${m[1]}` },
{ test: /^product\/update\/(.+)$/, to: (m) => `admin/products/${m[1]}` },
{ test: /^product\/(.+)\/variant$/, to: (m) => `admin/products/${m[1]}/variants` },
// Category by id: legacy GET/PUT/DELETE category/{id}
{ test: /^category\/(.+)$/, to: (m) => `admin/categories/${m[1]}` },
{ test: /^ciny\/(.+)$/, to: (m) => `admin/categories/collections/${m[1]}` },
// Financial documents. NOTE the confirm path keeps legacy's misspelling
// ("confirem") on the client side; only the server path is corrected.
{ test: /^cash\/confirem\/(.+)$/, to: (m) => `admin/documents/${m[1]}/confirm` },
{ test: /^cash\/(.+)$/, to: (m) => `admin/documents/${m[1]}` },
// Promotions. These MUST precede the generic product/ patterns above them in
// intent, but they are more specific so they match first regardless.
{ test: /^product\/variant\/promotion\/(.+)$/, to: (m) => `admin/promotions/${m[1]}` },
{ test: /^major\/promotion\/(.+)$/, to: (m) => `admin/promotions/${m[1]}` },
{ test: /^ignore\/(.+)$/, to: (m) => `admin/promotions/exclusions/${m[1]}` },
// Admin users + roles. The two-segment roles path must precede the one-segment
// one, or 'roles/85/some.rid' would be read as a user-type id of '85/some.rid'.
{ test: /^roles\/(\d+)\/(.+)$/, to: (m) => `admin/users/types/${m[1]}/roles/${m[2]}` },
{ test: /^roles\/(.+)$/, to: (m) => `admin/users/types/${m[1]}/roles` },
{ test: /^users\/(.+)$/, to: (m) => `admin/users/${m[1]}` },
// Branches + workshops. The 'active' paths are two segments, so they must be
// tried before the plain id patterns.
// Allocation by id — must precede branch/:id, or 'branch/product/xyz' would be
// read as a branch whose id is 'product/xyz'.
{ test: /^branch\/product\/(.+)$/, to: (m) => `admin/invoices/${m[1]}` },
{ test: /^branch\/active\/(.+)$/, to: (m) => `admin/branches/active/${m[1]}` },
{ test: /^workshop\/active\/(.+)$/, to: (m) => `admin/workshops/active/${m[1]}` },
{ test: /^branch\/(.+)$/, to: (m) => `admin/branches/${m[1]}` },
{ test: /^workshop\/(.+)$/, to: (m) => `admin/workshops/${m[1]}` },
];
/** Resolves a legacy endpoint path to its current path. */
export function mapEndpoint(url: string): string {
// Absolute URLs bypass the mapping entirely.
if (/^https?:\/\//i.test(url)) return url;
const [pathOnly, query] = normalize(url).split('?');
const exact = EXACT[pathOnly];
if (exact) return query ? `${exact}?${query}` : exact;
for (const { test, to } of PATTERNS) {
const m = pathOnly.match(test);
if (m) {
const mapped = to(m);
return query ? `${mapped}?${query}` : mapped;
}
}
// Unmapped: pass through normalized, so it still resolves against baseURL.
return query ? `${pathOnly}?${query}` : pathOnly;
}
/** True when the path has an explicit rewrite — used by dev-time diagnostics. */
export function isMapped(url: string): boolean {
const pathOnly = normalize(url).split('?')[0];
return pathOnly in EXACT || PATTERNS.some((p) => p.test.test(pathOnly));
}