wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates
This commit is contained in:
+188
-79
@@ -103,9 +103,20 @@ const STORAGE_KEY = "vmall.mock.state.v5";
|
||||
|
||||
type PersistedState = Pick<
|
||||
MockState,
|
||||
"cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" |
|
||||
"aftersales" | "aftersaleMessages" | "orderSeq" | "invoiceSeq" | "addressSeq" |
|
||||
"favoriteSeq" | "aftersaleSeq" | "aftersaleMessageSeq"
|
||||
| "cart"
|
||||
| "orders"
|
||||
| "shipments"
|
||||
| "invoices"
|
||||
| "addresses"
|
||||
| "favorites"
|
||||
| "aftersales"
|
||||
| "aftersaleMessages"
|
||||
| "orderSeq"
|
||||
| "invoiceSeq"
|
||||
| "addressSeq"
|
||||
| "favoriteSeq"
|
||||
| "aftersaleSeq"
|
||||
| "aftersaleMessageSeq"
|
||||
>;
|
||||
|
||||
// Load cart/order session state persisted by a previous page load (client only).
|
||||
@@ -123,7 +134,8 @@ function loadPersisted(): PersistedState | null {
|
||||
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
|
||||
if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null;
|
||||
if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null;
|
||||
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null;
|
||||
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number")
|
||||
return null;
|
||||
return p as PersistedState;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -337,48 +349,100 @@ function seedAftersales(orders: Order[]): Aftersale[] {
|
||||
const approved = item("o1", "o1-it2");
|
||||
const refunded = item("o2", "o2-it1");
|
||||
const rejected = item("o2", "o2-it2");
|
||||
if (pending) rows.push({
|
||||
id: "as-demo-pending", order_id: pending.order.id, order_item_id: pending.line.id,
|
||||
shop_id: pending.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "pending",
|
||||
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
|
||||
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty), currency: pending.order.currency,
|
||||
evidence: ["https://example.com/aftersale/demo-evidence.jpg"], reopened: false,
|
||||
return_carrier: null, return_tracking_no: null, created_at: "2026-09-10T12:00:00.000Z", updated_at: "2026-09-10T12:00:00.000Z",
|
||||
});
|
||||
if (approved) rows.push({
|
||||
id: "as-demo-approved", order_id: approved.order.id, order_item_id: approved.line.id,
|
||||
shop_id: approved.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "approved",
|
||||
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
|
||||
amount_minor: approved.line.unit_price_minor * approved.line.qty, currency: approved.order.currency,
|
||||
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
|
||||
created_at: "2026-09-10T13:00:00.000Z", updated_at: "2026-09-11T09:00:00.000Z",
|
||||
});
|
||||
if (refunded) rows.push({
|
||||
id: "as-demo-refunded", order_id: refunded.order.id, order_item_id: refunded.line.id,
|
||||
shop_id: refunded.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "refunded",
|
||||
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
|
||||
amount_minor: refunded.line.unit_price_minor * refunded.line.qty, currency: refunded.order.currency,
|
||||
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
|
||||
created_at: "2026-09-12T17:00:00.000Z", updated_at: "2026-09-14T09:00:00.000Z",
|
||||
});
|
||||
if (rejected) rows.push({
|
||||
id: "as-demo-rejected", order_id: rejected.order.id, order_item_id: rejected.line.id,
|
||||
shop_id: rejected.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "rejected",
|
||||
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
|
||||
amount_minor: rejected.line.unit_price_minor * rejected.line.qty, currency: rejected.order.currency,
|
||||
evidence: [], reopened: false, return_carrier: null, return_tracking_no: null,
|
||||
created_at: "2026-09-12T18:00:00.000Z", updated_at: "2026-09-13T09:00:00.000Z",
|
||||
});
|
||||
if (pending)
|
||||
rows.push({
|
||||
id: "as-demo-pending",
|
||||
order_id: pending.order.id,
|
||||
order_item_id: pending.line.id,
|
||||
shop_id: pending.order.shop_id,
|
||||
user_id: MOCK_USER.id,
|
||||
kind: "refund_only",
|
||||
status: "pending",
|
||||
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
|
||||
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty),
|
||||
currency: pending.order.currency,
|
||||
evidence: ["https://example.com/aftersale/demo-evidence.jpg"],
|
||||
reopened: false,
|
||||
return_carrier: null,
|
||||
return_tracking_no: null,
|
||||
created_at: "2026-09-10T12:00:00.000Z",
|
||||
updated_at: "2026-09-10T12:00:00.000Z",
|
||||
});
|
||||
if (approved)
|
||||
rows.push({
|
||||
id: "as-demo-approved",
|
||||
order_id: approved.order.id,
|
||||
order_item_id: approved.line.id,
|
||||
shop_id: approved.order.shop_id,
|
||||
user_id: MOCK_USER.id,
|
||||
kind: "return_refund",
|
||||
status: "approved",
|
||||
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
|
||||
amount_minor: approved.line.unit_price_minor * approved.line.qty,
|
||||
currency: approved.order.currency,
|
||||
evidence: [],
|
||||
reopened: false,
|
||||
return_carrier: null,
|
||||
return_tracking_no: null,
|
||||
created_at: "2026-09-10T13:00:00.000Z",
|
||||
updated_at: "2026-09-11T09:00:00.000Z",
|
||||
});
|
||||
if (refunded)
|
||||
rows.push({
|
||||
id: "as-demo-refunded",
|
||||
order_id: refunded.order.id,
|
||||
order_item_id: refunded.line.id,
|
||||
shop_id: refunded.order.shop_id,
|
||||
user_id: MOCK_USER.id,
|
||||
kind: "refund_only",
|
||||
status: "refunded",
|
||||
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
|
||||
amount_minor: refunded.line.unit_price_minor * refunded.line.qty,
|
||||
currency: refunded.order.currency,
|
||||
evidence: [],
|
||||
reopened: false,
|
||||
return_carrier: null,
|
||||
return_tracking_no: null,
|
||||
created_at: "2026-09-12T17:00:00.000Z",
|
||||
updated_at: "2026-09-14T09:00:00.000Z",
|
||||
});
|
||||
if (rejected)
|
||||
rows.push({
|
||||
id: "as-demo-rejected",
|
||||
order_id: rejected.order.id,
|
||||
order_item_id: rejected.line.id,
|
||||
shop_id: rejected.order.shop_id,
|
||||
user_id: MOCK_USER.id,
|
||||
kind: "return_refund",
|
||||
status: "rejected",
|
||||
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
|
||||
amount_minor: rejected.line.unit_price_minor * rejected.line.qty,
|
||||
currency: rejected.order.currency,
|
||||
evidence: [],
|
||||
reopened: false,
|
||||
return_carrier: null,
|
||||
return_tracking_no: null,
|
||||
created_at: "2026-09-12T18:00:00.000Z",
|
||||
updated_at: "2026-09-13T09:00:00.000Z",
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
|
||||
const refunded = aftersales.find((row) => row.id === "as-demo-refunded");
|
||||
return refunded ? [{
|
||||
id: "asm-demo-1", aftersale_id: refunded.id, author_role: "buyer", author_id: MOCK_USER.id,
|
||||
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" }, evidence: [],
|
||||
created_at: "2026-09-12T17:30:00.000Z",
|
||||
}] : [];
|
||||
return refunded
|
||||
? [
|
||||
{
|
||||
id: "asm-demo-1",
|
||||
aftersale_id: refunded.id,
|
||||
author_role: "buyer",
|
||||
author_id: MOCK_USER.id,
|
||||
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" },
|
||||
evidence: [],
|
||||
created_at: "2026-09-12T17:30:00.000Z",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function initialState(): MockState {
|
||||
@@ -438,7 +502,11 @@ function initialState(): MockState {
|
||||
}
|
||||
|
||||
function unsupported(): never {
|
||||
throw new ApiError(501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock.");
|
||||
throw new ApiError(
|
||||
501,
|
||||
"MOCK_UNSUPPORTED",
|
||||
"This admin/shop endpoint is not part of the mall mock.",
|
||||
);
|
||||
}
|
||||
|
||||
type MockStoreRecord = NonNullable<ReturnType<typeof storeById>>;
|
||||
@@ -463,7 +531,6 @@ function toShopProfile(store: MockStoreRecord): ShopProfile {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function skuIndex(): Record<string, { product: Product; skuId: string }> {
|
||||
const index: Record<string, { product: Product; skuId: string }> = {};
|
||||
for (const p of searchMockProducts({ perPage: 1000 }).items) {
|
||||
@@ -518,7 +585,9 @@ export function createMockApi(): ApiClient {
|
||||
return row;
|
||||
}
|
||||
|
||||
function findOrderItem(orderItemId: string): { order: Order; item: Order["items"][number] } | null {
|
||||
function findOrderItem(
|
||||
orderItemId: string,
|
||||
): { order: Order; item: Order["items"][number] } | null {
|
||||
for (const order of state.orders) {
|
||||
const item = order.items.find((entry) => entry.id === orderItemId);
|
||||
if (item) return { order, item };
|
||||
@@ -546,7 +615,8 @@ export function createMockApi(): ApiClient {
|
||||
function aftersaleDetail(id: string): AftersaleDetail {
|
||||
const row = findAftersale(id);
|
||||
const found = findOrderItem(row.order_item_id);
|
||||
if (!found || found.order.id !== row.order_id) throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
|
||||
if (!found || found.order.id !== row.order_id)
|
||||
throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
|
||||
return {
|
||||
...copyAftersale(row),
|
||||
item: {
|
||||
@@ -559,8 +629,15 @@ export function createMockApi(): ApiClient {
|
||||
messages: state.aftersaleMessages
|
||||
.filter((message) => message.aftersale_id === row.id)
|
||||
.sort((a, b) => a.created_at.localeCompare(b.created_at))
|
||||
.map((message) => ({ ...message, content: { ...message.content }, evidence: [...message.evidence] })),
|
||||
remaining_refundable_minor: remainingFor(row.order_item_id, found.item.unit_price_minor * found.item.qty),
|
||||
.map((message) => ({
|
||||
...message,
|
||||
content: { ...message.content },
|
||||
evidence: [...message.evidence],
|
||||
})),
|
||||
remaining_refundable_minor: remainingFor(
|
||||
row.order_item_id,
|
||||
found.item.unit_price_minor * found.item.qty,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,10 +685,12 @@ export function createMockApi(): ApiClient {
|
||||
const entry = skuIndex()[skuId];
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "SKU not found"));
|
||||
const sku = entry.product.skus?.find((s) => s.id === skuId);
|
||||
if (!sku || !sku.active) return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
|
||||
if (!sku || !sku.active)
|
||||
return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
|
||||
const existing = state.cart.find((i) => i.sku_id === skuId);
|
||||
const nextQty = (existing?.qty ?? 0) + qty;
|
||||
if (nextQty > sku.stock) return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
|
||||
if (nextQty > sku.stock)
|
||||
return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
|
||||
if (existing) existing.qty = nextQty;
|
||||
else {
|
||||
state.cart.push({
|
||||
@@ -761,7 +840,9 @@ export function createMockApi(): ApiClient {
|
||||
const order = state.orders.find((o) => o.id === id);
|
||||
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
|
||||
if (order.status !== "pending_payment") {
|
||||
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"));
|
||||
return Promise.reject(
|
||||
new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"),
|
||||
);
|
||||
}
|
||||
order.status = "cancelled";
|
||||
const coupon = state.coupons.find((c) => c.order_id === order.id);
|
||||
@@ -833,15 +914,26 @@ export function createMockApi(): ApiClient {
|
||||
throw new ApiError(400, "BAD_REQUEST", "amount_minor must be positive");
|
||||
}
|
||||
const evidence = (body.evidence ?? []).map((url) => url.trim());
|
||||
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
|
||||
if (evidence.some((url) => !url))
|
||||
throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
|
||||
const found = findOrderItem(body.order_item_id);
|
||||
if (!found || found.order.user_id !== MOCK_USER.id) throw new ApiError(404, "NOT_FOUND", "order item");
|
||||
if (!eligibleOrder(found.order)) throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
|
||||
const active = state.aftersales.some((row) => row.order_item_id === body.order_item_id &&
|
||||
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status));
|
||||
if (active) throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
|
||||
const remaining = remainingFor(body.order_item_id, found.item.unit_price_minor * found.item.qty);
|
||||
if (body.amount_minor > remaining) throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
|
||||
if (!found || found.order.user_id !== MOCK_USER.id)
|
||||
throw new ApiError(404, "NOT_FOUND", "order item");
|
||||
if (!eligibleOrder(found.order))
|
||||
throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
|
||||
const active = state.aftersales.some(
|
||||
(row) =>
|
||||
row.order_item_id === body.order_item_id &&
|
||||
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status),
|
||||
);
|
||||
if (active)
|
||||
throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
|
||||
const remaining = remainingFor(
|
||||
body.order_item_id,
|
||||
found.item.unit_price_minor * found.item.qty,
|
||||
);
|
||||
if (body.amount_minor > remaining)
|
||||
throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
|
||||
state.aftersaleSeq += 1;
|
||||
const now = new Date().toISOString();
|
||||
const row: Aftersale = {
|
||||
@@ -867,10 +959,11 @@ export function createMockApi(): ApiClient {
|
||||
return aftersaleDetail(row.id);
|
||||
},
|
||||
|
||||
listMyAftersales: async (): Promise<Aftersale[]> => state.aftersales
|
||||
.filter((row) => row.user_id === MOCK_USER.id)
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
.map(copyAftersale),
|
||||
listMyAftersales: async (): Promise<Aftersale[]> =>
|
||||
state.aftersales
|
||||
.filter((row) => row.user_id === MOCK_USER.id)
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
.map(copyAftersale),
|
||||
|
||||
getAftersale: async (id: string): Promise<AftersaleDetail> => aftersaleDetail(id),
|
||||
|
||||
@@ -897,13 +990,19 @@ export function createMockApi(): ApiClient {
|
||||
return copyAftersale(row);
|
||||
},
|
||||
|
||||
submitAftersaleReturnTracking: async (id: string, body: AftersaleReturnTrackingBody): Promise<Aftersale> => {
|
||||
submitAftersaleReturnTracking: async (
|
||||
id: string,
|
||||
body: AftersaleReturnTrackingBody,
|
||||
): Promise<Aftersale> => {
|
||||
const carrier = body.carrier.trim();
|
||||
const trackingNo = body.tracking_no.trim();
|
||||
if (!carrier || !trackingNo) throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
|
||||
if (!carrier || !trackingNo)
|
||||
throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
|
||||
const row = findAftersale(id);
|
||||
if (row.kind !== "return_refund") throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
|
||||
if (row.status !== "approved") throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
|
||||
if (row.kind !== "return_refund")
|
||||
throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
|
||||
if (row.status !== "approved")
|
||||
throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
|
||||
row.status = "buyer_shipping";
|
||||
row.return_carrier = carrier;
|
||||
row.return_tracking_no = trackingNo;
|
||||
@@ -912,13 +1011,17 @@ export function createMockApi(): ApiClient {
|
||||
return copyAftersale(row);
|
||||
},
|
||||
|
||||
addAftersaleMessage: async (id: string, body: AftersaleMessageBody): Promise<AftersaleMessage> => {
|
||||
addAftersaleMessage: async (
|
||||
id: string,
|
||||
body: AftersaleMessageBody,
|
||||
): Promise<AftersaleMessage> => {
|
||||
const row = findAftersale(id);
|
||||
const en = body.content.en?.trim() ?? "";
|
||||
const zh = body.content.zh?.trim() ?? "";
|
||||
if (!en && !zh) throw new ApiError(400, "BAD_REQUEST", "content needs text");
|
||||
const evidence = (body.evidence ?? []).map((url) => url.trim());
|
||||
if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
|
||||
if (evidence.some((url) => !url))
|
||||
throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
|
||||
state.aftersaleMessageSeq += 1;
|
||||
const now = new Date().toISOString();
|
||||
const message: AftersaleMessage = {
|
||||
@@ -970,7 +1073,8 @@ export function createMockApi(): ApiClient {
|
||||
})),
|
||||
}),
|
||||
|
||||
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
|
||||
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
|
||||
getShop: (slug) => {
|
||||
const store = storeById(slug);
|
||||
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
|
||||
return Promise.resolve(toShopProfile(store));
|
||||
@@ -979,7 +1083,8 @@ export function createMockApi(): ApiClient {
|
||||
listMyAddresses: () =>
|
||||
Promise.resolve(
|
||||
[...state.addresses].sort(
|
||||
(a, b) => Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
|
||||
(a, b) =>
|
||||
Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1062,7 +1167,9 @@ export function createMockApi(): ApiClient {
|
||||
.filter((row) => row.kind === q.kind)
|
||||
.filter((row) => {
|
||||
if (!q.target_id) return true;
|
||||
return row.kind === "product" ? row.product.id === q.target_id : row.shop.id === q.target_id;
|
||||
return row.kind === "product"
|
||||
? row.product.id === q.target_id
|
||||
: row.shop.id === q.target_id;
|
||||
})
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
const total = visible.length;
|
||||
@@ -1087,7 +1194,11 @@ export function createMockApi(): ApiClient {
|
||||
return Promise.resolve(hydrateProductFavorite(existing.id, existing.created_at, product));
|
||||
}
|
||||
state.favoriteSeq += 1;
|
||||
const row = hydrateProductFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), product);
|
||||
const row = hydrateProductFavorite(
|
||||
`f-${state.favoriteSeq}`,
|
||||
new Date().toISOString(),
|
||||
product,
|
||||
);
|
||||
state.favorites.push(row);
|
||||
persist();
|
||||
return Promise.resolve(row);
|
||||
@@ -1116,7 +1227,9 @@ export function createMockApi(): ApiClient {
|
||||
},
|
||||
|
||||
removeShopFavorite: (shopId: string) => {
|
||||
state.favorites = state.favorites.filter((row) => !(row.kind === "shop" && row.shop.id === shopId));
|
||||
state.favorites = state.favorites.filter(
|
||||
(row) => !(row.kind === "shop" && row.shop.id === shopId),
|
||||
);
|
||||
persist();
|
||||
return Promise.resolve();
|
||||
},
|
||||
@@ -1139,9 +1252,7 @@ export function createMockApi(): ApiClient {
|
||||
},
|
||||
|
||||
listPointsProducts: () =>
|
||||
Promise.resolve(
|
||||
state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })),
|
||||
),
|
||||
Promise.resolve(state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p }))),
|
||||
|
||||
listFlashSales: () => Promise.resolve(mockFlashSales()),
|
||||
|
||||
@@ -1150,9 +1261,7 @@ export function createMockApi(): ApiClient {
|
||||
listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))),
|
||||
|
||||
redeemPoints: (body: RedeemPointsBody) => {
|
||||
const product = state.pointsProducts.find(
|
||||
(p) => p.id === body.product_id && p.published,
|
||||
);
|
||||
const product = state.pointsProducts.find((p) => p.id === body.product_id && p.published);
|
||||
if (!product) {
|
||||
return Promise.reject(new ApiError(404, "NOT_FOUND", "Points product not found"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user