feat: complete admin panel and backend admin api migration
This commit is contained in:
Vendored
-48
@@ -1,48 +0,0 @@
|
||||
import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types';
|
||||
export interface CustomerSummary {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
goldDebtGrams: string;
|
||||
fiatDebtRial: string;
|
||||
goldCreditLimitGrams: string;
|
||||
}
|
||||
export interface ProductVariantSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
rashWeightGrams: string;
|
||||
defaultWagePercent: string;
|
||||
}
|
||||
export interface ProductSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
variants: ProductVariantSummary[];
|
||||
}
|
||||
export interface OrderDetail {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
totalGoldWeightGrams: string;
|
||||
wageWeightGrams: string;
|
||||
flatRialCharge: string;
|
||||
isSuspendedOverCreditLimit: boolean;
|
||||
deliveredAt: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
computedWeightGrams: string;
|
||||
}>;
|
||||
statusEvents: Array<{
|
||||
toStatus: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
export declare const api: {
|
||||
listCustomers: () => Promise<CustomerSummary[]>;
|
||||
getCustomerBalance: (id: string) => Promise<CustomerBalanceDto>;
|
||||
listProducts: () => Promise<ProductSummary[]>;
|
||||
createOrder: (dto: CreateOrderDto) => Promise<OrderDetail>;
|
||||
getOrder: (id: string) => Promise<OrderDetail>;
|
||||
updateOrderStatus: (id: string, status: string, note?: string) => Promise<OrderDetail>;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
const BASE = '/api';
|
||||
async function request(path, options) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`API ${path} failed (${res.status}): ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
export const api = {
|
||||
listCustomers: () => request('/customers'),
|
||||
getCustomerBalance: (id) => request(`/accounting/customers/${id}/balance`),
|
||||
listProducts: () => request('/catalog/products'),
|
||||
createOrder: (dto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
||||
getOrder: (id) => request(`/orders/${id}`),
|
||||
updateOrderStatus: (id, status, note) => request(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }),
|
||||
};
|
||||
@@ -1,61 +1,74 @@
|
||||
import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types';
|
||||
// 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';
|
||||
|
||||
const BASE = '/api';
|
||||
export const baseURL = import.meta.env.VITE_API_BASE_URL ?? '/api/';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`API ${path} failed (${res.status}): ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CustomerSummary {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
goldDebtGrams: string;
|
||||
fiatDebtRial: string;
|
||||
goldCreditLimitGrams: string;
|
||||
}
|
||||
|
||||
export interface ProductVariantSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
rashWeightGrams: string;
|
||||
defaultWagePercent: string;
|
||||
}
|
||||
|
||||
export interface ProductSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
variants: ProductVariantSummary[];
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
totalGoldWeightGrams: string;
|
||||
wageWeightGrams: string;
|
||||
flatRialCharge: string;
|
||||
isSuspendedOverCreditLimit: boolean;
|
||||
deliveredAt: string | null;
|
||||
items: Array<{ id: string; quantity: number; computedWeightGrams: string }>;
|
||||
statusEvents: Array<{ toStatus: string; note: string | null; createdAt: string }>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listCustomers: () => request<CustomerSummary[]>('/customers'),
|
||||
getCustomerBalance: (id: string) => request<CustomerBalanceDto>(`/accounting/customers/${id}/balance`),
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
createOrder: (dto: CreateOrderDto) =>
|
||||
request<OrderDetail>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
||||
getOrder: (id: string) => request<OrderDetail>(`/orders/${id}`),
|
||||
updateOrderStatus: (id: string, status: string, note?: string) =>
|
||||
request<OrderDetail>(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }),
|
||||
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'];
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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
|
||||
single: 'admin/customers-omde/create',
|
||||
'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',
|
||||
'get-analytics': 'admin/dashboard/omde',
|
||||
'get-analytics-tak': 'admin/dashboard/tak',
|
||||
'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));
|
||||
}
|
||||
Reference in New Issue
Block a user