@isaiahiroko/tilmart-ngx-apps-admin 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-bulk-upload.module-BGTbsYUP.mjs +370 -0
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-bulk-upload.module-BGTbsYUP.mjs.map +1 -0
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-catalog.module-BwjfFXDU.mjs +562 -0
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-catalog.module-BwjfFXDU.mjs.map +1 -0
- package/fesm2022/{isaiahiroko-tilmart-ngx-apps-admin-orders.module-Cfeb0QjH.mjs → isaiahiroko-tilmart-ngx-apps-admin-orders.module-vldWwcAr.mjs} +26 -3
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-orders.module-vldWwcAr.mjs.map +1 -0
- package/fesm2022/{isaiahiroko-tilmart-ngx-apps-admin-settings.module-Cz6xPNwB.mjs → isaiahiroko-tilmart-ngx-apps-admin-settings.module-CFVgAQ2f.mjs} +48 -4
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-settings.module-CFVgAQ2f.mjs.map +1 -0
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin.mjs +14 -6
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin.mjs.map +1 -1
- package/package.json +1 -1
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-catalog.module-DoxDNaNn.mjs +0 -538
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-catalog.module-DoxDNaNn.mjs.map +0 -1
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-orders.module-Cfeb0QjH.mjs.map +0 -1
- package/fesm2022/isaiahiroko-tilmart-ngx-apps-admin-settings.module-Cz6xPNwB.mjs.map +0 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, Component, NgModule } from '@angular/core';
|
|
3
|
+
import * as i1$1 from '@angular/router';
|
|
4
|
+
import { RouterModule } from '@angular/router';
|
|
5
|
+
import { getEnv, BaseDirective, using, notify, SharedModule } from '@thescaffold/ngx-core';
|
|
6
|
+
import * as i2 from '@thescaffold/ngx-ui';
|
|
7
|
+
import { ElementsModule, WebgetsModule } from '@thescaffold/ngx-ui';
|
|
8
|
+
import { HttpClient } from '@angular/common/http';
|
|
9
|
+
import { firstValueFrom, takeUntil } from 'rxjs';
|
|
10
|
+
import * as i1 from '@angular/forms';
|
|
11
|
+
|
|
12
|
+
// `ProductMedia.blobId` (P1.3) resolves to `GET {API_BASE_URL}/apps/blobs/
|
|
13
|
+
// download/:id` — same helper/reasoning as the buyer app's
|
|
14
|
+
// `lib/data/media.ts#blobUrl` (confirmed against the real
|
|
15
|
+
// `ntx-apps/libs/blobs` controller, not guessed).
|
|
16
|
+
function resolveBlobUrl(blobId) {
|
|
17
|
+
if (!blobId)
|
|
18
|
+
return '';
|
|
19
|
+
return `${getEnv('API_BASE_URL')}/apps/blobs/download/${blobId}`;
|
|
20
|
+
}
|
|
21
|
+
// `@thescaffold/jsx-blobs`'s own `upload()` helper is broken against this
|
|
22
|
+
// platform's response envelope: its internal `init()`/`verify()` return
|
|
23
|
+
// the raw `{status, title, message, data}` body but `upload()` then reads
|
|
24
|
+
// `file.id` (not `file.data.id`) — so it always reports "Failed to init
|
|
25
|
+
// file upload" even on a 201. Confirmed by driving the real upload flow
|
|
26
|
+
// in a browser and reading the network log: `init` returns 201 with a
|
|
27
|
+
// real file id, `upload()` still fails. Rather than depend on a broken
|
|
28
|
+
// vendor convenience function, this reimplements the same three-call
|
|
29
|
+
// contract (init → chunked batch → verify) directly against
|
|
30
|
+
// `apiService`, which is also how P1's demo-media seed script validated
|
|
31
|
+
// the contract in the first place, and gets Angular's existing
|
|
32
|
+
// `JwtInterceptor` auth for free instead of hand-managing SDK config.
|
|
33
|
+
async function uploadFileToBlobs(apiService, file, parentId, tags) {
|
|
34
|
+
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
|
35
|
+
const mime = file.type || 'application/octet-stream';
|
|
36
|
+
const meta = { type: ext, parentId, name: file.name, tags, size: file.size, mime };
|
|
37
|
+
const initRes = await firstValueFrom(apiService.post('apps/blobs/upload/init', meta));
|
|
38
|
+
const fileId = initRes?.data?.id;
|
|
39
|
+
if (!fileId)
|
|
40
|
+
throw new Error('Failed to init file upload');
|
|
41
|
+
const chunkSize = 512 * 1024;
|
|
42
|
+
const reader = file.stream().getReader();
|
|
43
|
+
let index = 0;
|
|
44
|
+
let buffer = new Uint8Array();
|
|
45
|
+
const flushChunk = async (bytes) => {
|
|
46
|
+
await firstValueFrom(apiService.post('apps/blobs/upload/batch', {
|
|
47
|
+
pages: [{ fileId, index: index++, raw: uint8ToBase64(bytes) }],
|
|
48
|
+
}));
|
|
49
|
+
};
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done)
|
|
53
|
+
break;
|
|
54
|
+
const combined = new Uint8Array(buffer.length + value.length);
|
|
55
|
+
combined.set(buffer);
|
|
56
|
+
combined.set(value, buffer.length);
|
|
57
|
+
buffer = combined;
|
|
58
|
+
while (buffer.length >= chunkSize) {
|
|
59
|
+
await flushChunk(buffer.slice(0, chunkSize));
|
|
60
|
+
buffer = buffer.slice(chunkSize);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (buffer.length > 0)
|
|
64
|
+
await flushChunk(buffer);
|
|
65
|
+
const verifyRes = await firstValueFrom(apiService.post('apps/blobs/upload/verify', meta));
|
|
66
|
+
const blobId = verifyRes?.data?.id;
|
|
67
|
+
if (!blobId)
|
|
68
|
+
throw new Error('Failed to verify file upload');
|
|
69
|
+
return blobId;
|
|
70
|
+
}
|
|
71
|
+
function uint8ToBase64(bytes) {
|
|
72
|
+
let binary = '';
|
|
73
|
+
const chunkSize = 0x8000;
|
|
74
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
75
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
76
|
+
}
|
|
77
|
+
return btoa(binary);
|
|
78
|
+
}
|
|
79
|
+
const PRODUCT_TYPES = ['simple', 'variant', 'kit'];
|
|
80
|
+
const PRODUCT_STATUSES = ['draft', 'active', 'archived', 'discontinued'];
|
|
81
|
+
// A fixed "folder" id for every admin-uploaded product image.
|
|
82
|
+
// `BlobsFiles.parentId` is NOT NULL in the DB despite the entity/DTO
|
|
83
|
+
// marking it optional (`@IsOptional()`, `nullable: true`) — a genuine
|
|
84
|
+
// vendor-package mismatch, found by running the demo-catalogue media seed
|
|
85
|
+
// script against a live server and reading the resulting
|
|
86
|
+
// QueryFailedError (see the commerce migrations' own notes). No FK is
|
|
87
|
+
// declared on the column, so any UUID satisfies it.
|
|
88
|
+
// Product photo guidance — mirrored in the bulk importer and the admin hint.
|
|
89
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
90
|
+
const MIN_IMAGE_SIDE = 800;
|
|
91
|
+
async function readImageSize(file) {
|
|
92
|
+
try {
|
|
93
|
+
const bitmap = await createImageBitmap(file);
|
|
94
|
+
const size = { width: bitmap.width, height: bitmap.height };
|
|
95
|
+
bitmap.close();
|
|
96
|
+
return size;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const PRODUCT_MEDIA_PARENT_ID = '00000000-0000-4000-8000-000000000d30';
|
|
103
|
+
// P1.15 (original slice) + R1 production-readiness pass (this rebuild).
|
|
104
|
+
// The original version of this page was read-only: a product list plus
|
|
105
|
+
// the CAT-03 publish-validation check, with no create/edit/media/
|
|
106
|
+
// attribute/variant/pricing UI anywhere — "no way to add items to
|
|
107
|
+
// catalogue" was the single most direct gap this session set out to
|
|
108
|
+
// close. This keeps the publish-check list and adds a full create/edit
|
|
109
|
+
// flow: basic info → category/brand → the category's resolved attribute
|
|
110
|
+
// grid → one implicit variant (simple products; a per-variant grid for
|
|
111
|
+
// `variant`-type products is real, isolated follow-up work, same
|
|
112
|
+
// reasoning P1.15 originally gave for deferring it) → media upload → a
|
|
113
|
+
// single default price + stock level. Search/refresh on the list are
|
|
114
|
+
// real (not the generic `x-webget-collection` — this table needs a
|
|
115
|
+
// bespoke "Check" action + inline errors the generic collection can't
|
|
116
|
+
// render, so pagination/search are hand-wired here instead).
|
|
117
|
+
class CatalogComponent extends BaseDirective {
|
|
118
|
+
constructor() {
|
|
119
|
+
super(...arguments);
|
|
120
|
+
this.http = inject(HttpClient);
|
|
121
|
+
this.productSource = this.apiService.on('apps/commerce/product');
|
|
122
|
+
this.categorySource = this.apiService.on('apps/commerce/category');
|
|
123
|
+
this.brandSource = this.apiService.on('apps/commerce/brand');
|
|
124
|
+
this.variantSource = this.apiService.on('apps/commerce/product-variant');
|
|
125
|
+
this.attrValueSource = this.apiService.on('apps/commerce/product-attribute-value');
|
|
126
|
+
this.mediaSource = this.apiService.on('apps/commerce/product-media');
|
|
127
|
+
this.priceSource = this.apiService.on('apps/commerce/price');
|
|
128
|
+
this.stockLevelSource = this.apiService.on('apps/commerce/stock-level');
|
|
129
|
+
this.stockLocationSource = this.apiService.on('apps/commerce/stock-location');
|
|
130
|
+
this.types = PRODUCT_TYPES;
|
|
131
|
+
this.statuses = PRODUCT_STATUSES;
|
|
132
|
+
this.loading = true;
|
|
133
|
+
this.products = [];
|
|
134
|
+
this.categories = [];
|
|
135
|
+
this.brands = [];
|
|
136
|
+
this.stockLocations = [];
|
|
137
|
+
this.query = '';
|
|
138
|
+
this.page = 1;
|
|
139
|
+
this.perPage = 25;
|
|
140
|
+
this.total = 0;
|
|
141
|
+
this.editing = false;
|
|
142
|
+
this.saving = false;
|
|
143
|
+
this.uploadingMedia = false;
|
|
144
|
+
this.draft = null;
|
|
145
|
+
this.variant = null;
|
|
146
|
+
this.attributeDefs = [];
|
|
147
|
+
this.attributeValues = {};
|
|
148
|
+
this.media = [];
|
|
149
|
+
this.price = null;
|
|
150
|
+
this.stock = null;
|
|
151
|
+
this.init = () => {
|
|
152
|
+
this.loadProducts();
|
|
153
|
+
this.loadCategories();
|
|
154
|
+
this.loadBrands();
|
|
155
|
+
this.loadStockLocations();
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
loadProducts(page = this.page) {
|
|
159
|
+
this.loading = true;
|
|
160
|
+
this.page = page;
|
|
161
|
+
const params = { page, perPage: this.perPage };
|
|
162
|
+
if (this.query.trim())
|
|
163
|
+
params.query = this.query.trim();
|
|
164
|
+
this.productSource
|
|
165
|
+
.getMany(params)
|
|
166
|
+
.pipe(takeUntil(this.$))
|
|
167
|
+
.subscribe(using((response) => {
|
|
168
|
+
this.products = (response?.data ?? []).map((p) => ({
|
|
169
|
+
id: p.id,
|
|
170
|
+
name: p.name,
|
|
171
|
+
model: p.model,
|
|
172
|
+
type: p.type,
|
|
173
|
+
status: p.status,
|
|
174
|
+
updatedAt: p.updatedAt,
|
|
175
|
+
checking: false,
|
|
176
|
+
checked: false,
|
|
177
|
+
}));
|
|
178
|
+
this.total = response?.meta?.total ?? this.products.length;
|
|
179
|
+
this.loading = false;
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
search() {
|
|
183
|
+
this.loadProducts(1);
|
|
184
|
+
}
|
|
185
|
+
refresh() {
|
|
186
|
+
this.loadProducts(this.page);
|
|
187
|
+
}
|
|
188
|
+
prevPage() {
|
|
189
|
+
if (this.page > 1)
|
|
190
|
+
this.loadProducts(this.page - 1);
|
|
191
|
+
}
|
|
192
|
+
nextPage() {
|
|
193
|
+
if (this.page * this.perPage < this.total)
|
|
194
|
+
this.loadProducts(this.page + 1);
|
|
195
|
+
}
|
|
196
|
+
loadCategories() {
|
|
197
|
+
this.categorySource
|
|
198
|
+
.getMany({ page: 1, perPage: 300 })
|
|
199
|
+
.pipe(takeUntil(this.$))
|
|
200
|
+
.subscribe(using((response) => {
|
|
201
|
+
this.categories = (response?.data ?? []).sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
loadBrands() {
|
|
205
|
+
this.brandSource
|
|
206
|
+
.getMany({ page: 1, perPage: 200 })
|
|
207
|
+
.pipe(takeUntil(this.$))
|
|
208
|
+
.subscribe(using((response) => {
|
|
209
|
+
this.brands = (response?.data ?? []).sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
loadStockLocations() {
|
|
213
|
+
this.stockLocationSource
|
|
214
|
+
.getMany({ page: 1, perPage: 50 })
|
|
215
|
+
.pipe(takeUntil(this.$))
|
|
216
|
+
.subscribe(using((response) => (this.stockLocations = response?.data ?? [])));
|
|
217
|
+
}
|
|
218
|
+
checkPublish(product) {
|
|
219
|
+
product.checking = true;
|
|
220
|
+
this.productSource
|
|
221
|
+
.post(`apps/commerce/product/${product.id}/validate-publish`)
|
|
222
|
+
.pipe(takeUntil(this.$))
|
|
223
|
+
.subscribe(using((response) => {
|
|
224
|
+
product.checking = false;
|
|
225
|
+
product.checked = true;
|
|
226
|
+
product.canPublish = !!response?.data?.canPublish;
|
|
227
|
+
product.errors = response?.data?.errors ?? [];
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
startCreate() {
|
|
231
|
+
this.editing = true;
|
|
232
|
+
this.draft = {
|
|
233
|
+
name: '',
|
|
234
|
+
slug: '',
|
|
235
|
+
model: '',
|
|
236
|
+
brandId: '',
|
|
237
|
+
primaryCategoryId: '',
|
|
238
|
+
shortDesc: '',
|
|
239
|
+
type: 'simple',
|
|
240
|
+
status: 'draft',
|
|
241
|
+
};
|
|
242
|
+
this.variant = { sku: '', weight: null, handlingClass: 'standard' };
|
|
243
|
+
this.attributeDefs = [];
|
|
244
|
+
this.attributeValues = {};
|
|
245
|
+
this.media = [];
|
|
246
|
+
this.price = { amount: null };
|
|
247
|
+
this.stock = { locationId: this.stockLocations[0]?.id ?? '', available: 0 };
|
|
248
|
+
}
|
|
249
|
+
async edit(row) {
|
|
250
|
+
this.editing = true;
|
|
251
|
+
const productRes = await firstValueFrom(this.productSource.getOne(row.id));
|
|
252
|
+
const p = productRes?.data;
|
|
253
|
+
this.draft = {
|
|
254
|
+
id: p.id,
|
|
255
|
+
name: p.name ?? '',
|
|
256
|
+
slug: p.slug ?? '',
|
|
257
|
+
model: p.model ?? '',
|
|
258
|
+
brandId: p.brandId ?? '',
|
|
259
|
+
primaryCategoryId: p.primaryCategoryId ?? '',
|
|
260
|
+
shortDesc: p.shortDesc ?? '',
|
|
261
|
+
type: p.type,
|
|
262
|
+
status: p.status,
|
|
263
|
+
};
|
|
264
|
+
this.attributeValues = {};
|
|
265
|
+
if (this.draft.primaryCategoryId) {
|
|
266
|
+
await this.loadAttributeTemplate(this.draft.primaryCategoryId);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
this.attributeDefs = [];
|
|
270
|
+
}
|
|
271
|
+
const [variantsRes, attrsRes, mediaRes] = await Promise.all([
|
|
272
|
+
firstValueFrom(this.variantSource.getMany({ productId: p.id, page: 1, perPage: 50 })),
|
|
273
|
+
firstValueFrom(this.attrValueSource.getMany({ productId: p.id, page: 1, perPage: 200 })),
|
|
274
|
+
firstValueFrom(this.mediaSource.getMany({ productId: p.id, page: 1, perPage: 50 })),
|
|
275
|
+
]);
|
|
276
|
+
const firstVariant = variantsRes?.data?.[0];
|
|
277
|
+
this.variant = firstVariant
|
|
278
|
+
? {
|
|
279
|
+
id: firstVariant.id,
|
|
280
|
+
sku: firstVariant.sku,
|
|
281
|
+
weight: firstVariant.weight ?? null,
|
|
282
|
+
handlingClass: firstVariant.handlingClass ?? 'standard',
|
|
283
|
+
}
|
|
284
|
+
: { sku: '', weight: null, handlingClass: 'standard' };
|
|
285
|
+
for (const v of attrsRes?.data ?? []) {
|
|
286
|
+
this.attributeValues[v.attributeDefinitionId] = {
|
|
287
|
+
id: v.id,
|
|
288
|
+
valueNumber: v.valueNumber,
|
|
289
|
+
valueNumberHigh: v.valueNumberHigh,
|
|
290
|
+
valueText: v.valueText,
|
|
291
|
+
valueBool: v.valueBool,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
this.media = (mediaRes?.data ?? []).map((m) => ({
|
|
295
|
+
id: m.id,
|
|
296
|
+
blobId: m.blobId,
|
|
297
|
+
altText: m.altText,
|
|
298
|
+
type: m.type,
|
|
299
|
+
}));
|
|
300
|
+
for (const m of this.media) {
|
|
301
|
+
m.objectUrl = await this.resolveMediaObjectUrl(m.blobId);
|
|
302
|
+
}
|
|
303
|
+
if (this.variant.id) {
|
|
304
|
+
const priceRes = await firstValueFrom(this.priceSource.getMany({ variantId: this.variant.id, page: 1, perPage: 1 }));
|
|
305
|
+
const priceRow = priceRes?.data?.[0];
|
|
306
|
+
this.price = priceRow ? { id: priceRow.id, amount: priceRow.amount } : { amount: null };
|
|
307
|
+
const stockRes = await firstValueFrom(this.stockLevelSource.getMany({ variantId: this.variant.id, page: 1, perPage: 1 }));
|
|
308
|
+
const stockRow = stockRes?.data?.[0];
|
|
309
|
+
this.stock = stockRow
|
|
310
|
+
? { id: stockRow.id, locationId: stockRow.locationId, available: stockRow.available }
|
|
311
|
+
: { locationId: this.stockLocations[0]?.id ?? '', available: 0 };
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
this.price = { amount: null };
|
|
315
|
+
this.stock = { locationId: this.stockLocations[0]?.id ?? '', available: 0 };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async onCategoryChange() {
|
|
319
|
+
if (!this.draft?.primaryCategoryId) {
|
|
320
|
+
this.attributeDefs = [];
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await this.loadAttributeTemplate(this.draft.primaryCategoryId);
|
|
324
|
+
}
|
|
325
|
+
async loadAttributeTemplate(categoryId) {
|
|
326
|
+
try {
|
|
327
|
+
const res = await firstValueFrom(this.apiService.get(`apps/commerce/category/${categoryId}/attribute-template`));
|
|
328
|
+
this.attributeDefs = (res?.data?.definitions ?? []);
|
|
329
|
+
for (const def of this.attributeDefs) {
|
|
330
|
+
if (!this.attributeValues[def.id])
|
|
331
|
+
this.attributeValues[def.id] = {};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
this.attributeDefs = [];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async saveProduct() {
|
|
339
|
+
if (!this.draft || !this.draft.name.trim()) {
|
|
340
|
+
notify('Name is required.', undefined, 'error');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.saving = true;
|
|
344
|
+
try {
|
|
345
|
+
const payload = {
|
|
346
|
+
name: this.draft.name.trim(),
|
|
347
|
+
slug: this.draft.slug.trim() || undefined,
|
|
348
|
+
brandId: this.draft.brandId || undefined,
|
|
349
|
+
model: this.draft.model.trim() || undefined,
|
|
350
|
+
primaryCategoryId: this.draft.primaryCategoryId || undefined,
|
|
351
|
+
shortDesc: this.draft.shortDesc.trim() || undefined,
|
|
352
|
+
type: this.draft.type,
|
|
353
|
+
status: this.draft.status,
|
|
354
|
+
};
|
|
355
|
+
if (this.draft.id) {
|
|
356
|
+
await firstValueFrom(this.productSource.update(this.draft.id, payload));
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const res = await firstValueFrom(this.productSource.create(payload));
|
|
360
|
+
this.draft.id = res?.data?.id;
|
|
361
|
+
}
|
|
362
|
+
const productId = this.draft.id;
|
|
363
|
+
if (this.variant && this.variant.sku.trim()) {
|
|
364
|
+
const variantPayload = {
|
|
365
|
+
productId,
|
|
366
|
+
sku: this.variant.sku.trim(),
|
|
367
|
+
weight: this.variant.weight != null ? Number(this.variant.weight) : undefined,
|
|
368
|
+
handlingClass: this.variant.handlingClass || 'standard',
|
|
369
|
+
};
|
|
370
|
+
if (this.variant.id) {
|
|
371
|
+
await firstValueFrom(this.variantSource.update(this.variant.id, variantPayload));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const vRes = await firstValueFrom(this.variantSource.create(variantPayload));
|
|
375
|
+
this.variant.id = vRes?.data?.id;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const def of this.attributeDefs) {
|
|
379
|
+
const v = this.attributeValues[def.id];
|
|
380
|
+
if (!v)
|
|
381
|
+
continue;
|
|
382
|
+
const hasValue = (v.valueNumber !== null && v.valueNumber !== undefined && `${v.valueNumber}` !== '') ||
|
|
383
|
+
!!v.valueText ||
|
|
384
|
+
v.valueBool !== null && v.valueBool !== undefined;
|
|
385
|
+
if (!hasValue)
|
|
386
|
+
continue;
|
|
387
|
+
const attrPayload = {
|
|
388
|
+
productId,
|
|
389
|
+
attributeDefinitionId: def.id,
|
|
390
|
+
valueNumber: def.dataType === 'number' || def.dataType === 'range'
|
|
391
|
+
? v.valueNumber != null && `${v.valueNumber}` !== ''
|
|
392
|
+
? Number(v.valueNumber)
|
|
393
|
+
: undefined
|
|
394
|
+
: undefined,
|
|
395
|
+
valueNumberHigh: def.dataType === 'range' && v.valueNumberHigh != null && `${v.valueNumberHigh}` !== ''
|
|
396
|
+
? Number(v.valueNumberHigh)
|
|
397
|
+
: undefined,
|
|
398
|
+
valueText: def.dataType === 'text' || def.dataType === 'enum' ? v.valueText || undefined : undefined,
|
|
399
|
+
valueBool: def.dataType === 'boolean' ? !!v.valueBool : undefined,
|
|
400
|
+
};
|
|
401
|
+
if (v.id) {
|
|
402
|
+
await firstValueFrom(this.attrValueSource.update(v.id, attrPayload));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const aRes = await firstValueFrom(this.attrValueSource.create(attrPayload));
|
|
406
|
+
v.id = aRes?.data?.id;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (this.variant?.id) {
|
|
410
|
+
if (this.price && this.price.amount != null && `${this.price.amount}` !== '') {
|
|
411
|
+
const pricePayload = {
|
|
412
|
+
variantId: this.variant.id,
|
|
413
|
+
amount: Number(this.price.amount),
|
|
414
|
+
minQuantity: 1,
|
|
415
|
+
};
|
|
416
|
+
if (this.price.id) {
|
|
417
|
+
await firstValueFrom(this.priceSource.update(this.price.id, pricePayload));
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const prRes = await firstValueFrom(this.priceSource.create(pricePayload));
|
|
421
|
+
this.price.id = prRes?.data?.id;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (this.stock?.locationId) {
|
|
425
|
+
const stockPayload = {
|
|
426
|
+
variantId: this.variant.id,
|
|
427
|
+
locationId: this.stock.locationId,
|
|
428
|
+
available: Number(this.stock.available ?? 0),
|
|
429
|
+
};
|
|
430
|
+
if (this.stock.id) {
|
|
431
|
+
await firstValueFrom(this.stockLevelSource.update(this.stock.id, stockPayload));
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
const stRes = await firstValueFrom(this.stockLevelSource.create(stockPayload));
|
|
435
|
+
this.stock.id = stRes?.data?.id;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
notify('Product saved.', undefined, 'success');
|
|
440
|
+
this.loadProducts(this.page);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
notify('Could not save the product (check required fields).', undefined, 'error');
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
this.saving = false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async onMediaSelected(event) {
|
|
450
|
+
const input = event.target;
|
|
451
|
+
const files = input.files;
|
|
452
|
+
if (!files || !files.length || !this.draft?.id)
|
|
453
|
+
return;
|
|
454
|
+
const file = files[0];
|
|
455
|
+
if (file.size > MAX_IMAGE_BYTES) {
|
|
456
|
+
notify('That image is over 8 MB — export it at 1200–2000 px (JPG/WebP) and try again.', undefined, 'error');
|
|
457
|
+
input.value = '';
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const size = await readImageSize(file);
|
|
461
|
+
if (size && Math.min(size.width, size.height) < MIN_IMAGE_SIDE) {
|
|
462
|
+
notify(`That image is only ${size.width} × ${size.height}px — it will look soft on large screens. 1200 × 1200 px or more is recommended.`, undefined, 'warning');
|
|
463
|
+
}
|
|
464
|
+
this.uploadingMedia = true;
|
|
465
|
+
try {
|
|
466
|
+
const blobId = await uploadFileToBlobs(this.apiService, file, PRODUCT_MEDIA_PARENT_ID, [
|
|
467
|
+
'product-media',
|
|
468
|
+
]);
|
|
469
|
+
const altText = `${this.draft.name} — product photo`;
|
|
470
|
+
const res = await firstValueFrom(this.mediaSource.create({
|
|
471
|
+
productId: this.draft.id,
|
|
472
|
+
blobId,
|
|
473
|
+
type: 'gallery',
|
|
474
|
+
altText,
|
|
475
|
+
position: this.media.length,
|
|
476
|
+
}));
|
|
477
|
+
const objectUrl = await this.resolveMediaObjectUrl(blobId);
|
|
478
|
+
this.media.push({ id: res?.data?.id, blobId, altText, type: 'gallery', objectUrl });
|
|
479
|
+
notify('Image uploaded.', undefined, 'success');
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
console.error('media upload failed', e);
|
|
483
|
+
notify('Could not upload the image.', undefined, 'error');
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
this.uploadingMedia = false;
|
|
487
|
+
input.value = '';
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async removeMedia(item) {
|
|
491
|
+
try {
|
|
492
|
+
await firstValueFrom(this.mediaSource.remove(item.id));
|
|
493
|
+
this.media = this.media.filter((m) => m.id !== item.id);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
notify('Could not remove the image.', undefined, 'error');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// `download/:id` needs the bearer header only `HttpClient` (via the
|
|
500
|
+
// app's existing `JwtInterceptor`) can attach — see the `MediaVm.
|
|
501
|
+
// objectUrl` comment. Empty string on failure just leaves the
|
|
502
|
+
// thumbnail blank rather than breaking the editor.
|
|
503
|
+
async resolveMediaObjectUrl(blobId) {
|
|
504
|
+
try {
|
|
505
|
+
const blob = await firstValueFrom(this.http.get(resolveBlobUrl(blobId), { responseType: 'blob' }));
|
|
506
|
+
// Not `URL.createObjectURL` — the app's CSP `img-src` allows `data:`
|
|
507
|
+
// but not `blob:`, so an object URL renders as a CSP violation
|
|
508
|
+
// (confirmed by driving this in a browser). A data URI sidesteps it.
|
|
509
|
+
return await new Promise((resolve, reject) => {
|
|
510
|
+
const reader = new FileReader();
|
|
511
|
+
reader.onload = () => resolve(reader.result);
|
|
512
|
+
reader.onerror = reject;
|
|
513
|
+
reader.readAsDataURL(blob);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return '';
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
cancelEdit() {
|
|
521
|
+
this.editing = false;
|
|
522
|
+
this.draft = null;
|
|
523
|
+
this.variant = null;
|
|
524
|
+
this.attributeDefs = [];
|
|
525
|
+
this.attributeValues = {};
|
|
526
|
+
this.media = [];
|
|
527
|
+
this.price = null;
|
|
528
|
+
this.stock = null;
|
|
529
|
+
}
|
|
530
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: CatalogComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); }
|
|
531
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.16", type: CatalogComponent, isStandalone: false, selector: "x-catalog", usesInheritance: true, ngImport: i0, template: "<div class=\"x-page-head\">\n <div class=\"x-page-title\">Catalogue</div>\n <div class=\"x-page-subtitle\">Products, variants, attributes, media, pricing and stock.</div>\n</div>\n\n@if (!editing) {\n <div class=\"x-cat-toolbar\">\n <input\n type=\"text\"\n class=\"x-cat-search\"\n placeholder=\"Search products\u2026\"\n [(ngModel)]=\"query\"\n (keyup.enter)=\"search()\"\n />\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"search()\">Search</button>\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"refresh()\">Refresh</button>\n <div class=\"x-cat-spacer\"></div>\n <a class=\"x-cat-btn\" routerLink=\"/admin/bulk-upload\">Bulk upload (Excel)</a>\n <button type=\"button\" class=\"x-cat-btn x-cat-btn-primary\" (click)=\"startCreate()\">+ New product</button>\n </div>\n\n @if (loading) {\n <x-element-loader></x-element-loader>\n } @else {\n <div class=\"x-catalog-table\">\n <div class=\"x-catalog-row x-catalog-head\">\n <div class=\"x-catalog-col-name\">Product</div>\n <div class=\"x-catalog-col-type\">Type</div>\n <div class=\"x-catalog-col-status\">Status</div>\n <div class=\"x-catalog-col-check\">Publish check</div>\n <div class=\"x-catalog-col-action\"></div>\n </div>\n @for (product of products; track product.id) {\n <div class=\"x-catalog-row\">\n <div class=\"x-catalog-col-name\">\n {{ product.name }}\n @if (product.model) {\n <span class=\"x-catalog-model\">{{ product.model }}</span>\n }\n </div>\n <div class=\"x-catalog-col-type\">{{ product.type }}</div>\n <div class=\"x-catalog-col-status\">\n <span class=\"x-status-badge\" [class]=\"'x-status-' + product.status\">{{\n product.status\n }}</span>\n </div>\n <div class=\"x-catalog-col-check\">\n @if (product.checking) {\n <span class=\"x-check-pending\">Checking\u2026</span>\n } @else if (product.checked && product.canPublish) {\n <span class=\"x-check-ok\">\u2713 Ready to publish</span>\n } @else if (product.checked && product.errors?.length) {\n <ul class=\"x-check-errors\">\n @for (err of product.errors; track err.field) {\n <li>{{ err.message }}</li>\n }\n </ul>\n }\n </div>\n <div class=\"x-catalog-col-action\">\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"edit(product)\">Edit</button>\n <button\n type=\"button\"\n class=\"x-check-btn\"\n [disabled]=\"product.checking\"\n (click)=\"checkPublish(product)\"\n >\n Check\n </button>\n </div>\n </div>\n }\n @if (!products.length) {\n <div class=\"x-catalog-empty\">No products yet \u2014 use \"+ New product\" to add one.</div>\n }\n </div>\n\n <div class=\"x-cat-pager\">\n <button type=\"button\" class=\"x-cat-btn\" [disabled]=\"page <= 1\" (click)=\"prevPage()\">\u2190 Prev</button>\n <span class=\"x-cat-pager-label\">Page {{ page }} \u00B7 {{ total }} total</span>\n <button type=\"button\" class=\"x-cat-btn\" [disabled]=\"page * perPage >= total\" (click)=\"nextPage()\">\n Next \u2192\n </button>\n </div>\n }\n} @else if (draft) {\n <div class=\"x-cat-editor\">\n <div class=\"x-cat-editor-head\">\n <div class=\"x-cat-editor-title\">{{ draft.id ? 'Edit product' : 'New product' }}</div>\n <div class=\"x-cat-editor-actions\">\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"cancelEdit()\">Close</button>\n <button type=\"button\" class=\"x-cat-btn x-cat-btn-primary\" [disabled]=\"saving\" (click)=\"saveProduct()\">\n {{ saving ? 'Saving\u2026' : 'Save' }}\n </button>\n </div>\n </div>\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Basic info</div>\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Name\n <input type=\"text\" [(ngModel)]=\"draft.name\" />\n </label>\n <label class=\"x-cat-f\">\n Slug (optional)\n <input type=\"text\" [(ngModel)]=\"draft.slug\" placeholder=\"auto from name if blank\" />\n </label>\n </div>\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Model\n <input type=\"text\" [(ngModel)]=\"draft.model\" />\n </label>\n <label class=\"x-cat-f\">\n Type\n <select [(ngModel)]=\"draft.type\">\n @for (t of types; track t) { <option [value]=\"t\">{{ t }}</option> }\n </select>\n </label>\n <label class=\"x-cat-f\">\n Status\n <select [(ngModel)]=\"draft.status\">\n @for (s of statuses; track s) { <option [value]=\"s\">{{ s }}</option> }\n </select>\n </label>\n </div>\n @if (draft.type !== 'simple') {\n <div class=\"x-cat-hint\">\n \"{{ draft.type }}\" products still save through this one-variant form for now \u2014 a full\n per-variant / per-component editor is a follow-up.\n </div>\n }\n <label class=\"x-cat-f\">\n Short description\n <textarea rows=\"2\" [(ngModel)]=\"draft.shortDesc\"></textarea>\n </label>\n </section>\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Category & brand</div>\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Category\n <select [(ngModel)]=\"draft.primaryCategoryId\" (ngModelChange)=\"onCategoryChange()\">\n <option value=\"\">\u2014 none \u2014</option>\n @for (c of categories; track c.id) { <option [value]=\"c.id\">{{ c.name }}</option> }\n </select>\n </label>\n <label class=\"x-cat-f\">\n Brand\n <select [(ngModel)]=\"draft.brandId\">\n <option value=\"\">\u2014 none \u2014</option>\n @for (b of brands; track b.id) { <option [value]=\"b.id\">{{ b.name }}</option> }\n </select>\n </label>\n </div>\n </section>\n\n @if (attributeDefs.length) {\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Attributes</div>\n <div class=\"x-cat-attr-grid\">\n @for (def of attributeDefs; track def.id) {\n <label class=\"x-cat-f x-cat-attr\">\n {{ def.label }}{{ def.unit ? ' (' + def.unit + ')' : '' }}\n @if (def.required) {\n <span class=\"x-cat-required\">*</span>\n }\n @switch (def.dataType) {\n @case ('boolean') {\n <input type=\"checkbox\" [(ngModel)]=\"attributeValues[def.id].valueBool\" />\n }\n @case ('enum') {\n <select [(ngModel)]=\"attributeValues[def.id].valueText\">\n <option value=\"\">\u2014 choose \u2014</option>\n @for (opt of def.allowedValues; track opt) { <option [value]=\"opt\">{{ opt }}</option> }\n </select>\n }\n @case ('range') {\n <div class=\"x-cat-range\">\n <input type=\"number\" placeholder=\"min\" [(ngModel)]=\"attributeValues[def.id].valueNumber\" />\n <span>\u2013</span>\n <input\n type=\"number\"\n placeholder=\"max\"\n [(ngModel)]=\"attributeValues[def.id].valueNumberHigh\"\n />\n </div>\n }\n @case ('number') {\n <input type=\"number\" [(ngModel)]=\"attributeValues[def.id].valueNumber\" />\n }\n @default {\n <input type=\"text\" [(ngModel)]=\"attributeValues[def.id].valueText\" />\n }\n }\n </label>\n }\n </div>\n </section>\n }\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Variant, price & stock</div>\n @if (variant) {\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n SKU\n <input type=\"text\" [(ngModel)]=\"variant.sku\" />\n </label>\n <label class=\"x-cat-f\">\n Weight (kg)\n <input type=\"number\" [(ngModel)]=\"variant.weight\" />\n </label>\n <label class=\"x-cat-f\">\n Handling class\n <select [(ngModel)]=\"variant.handlingClass\">\n <option value=\"standard\">standard</option>\n <option value=\"bulky\">bulky</option>\n <option value=\"freight\">freight</option>\n <option value=\"hazmat\">hazmat</option>\n </select>\n </label>\n </div>\n }\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Price (\u20A6)\n <input type=\"number\" [(ngModel)]=\"price!.amount\" [disabled]=\"!variant?.sku\" />\n </label>\n <label class=\"x-cat-f\">\n Stock location\n <select [(ngModel)]=\"stock!.locationId\" [disabled]=\"!variant?.sku\">\n @for (loc of stockLocations; track loc.id) { <option [value]=\"loc.id\">{{ loc.name }}</option> }\n </select>\n </label>\n <label class=\"x-cat-f\">\n Available\n <input type=\"number\" [(ngModel)]=\"stock!.available\" [disabled]=\"!variant?.sku\" />\n </label>\n </div>\n @if (!variant?.sku) {\n <div class=\"x-cat-hint\">Enter a SKU above, then price/stock can be saved.</div>\n }\n </section>\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Media</div>\n @if (!draft.id) {\n <div class=\"x-cat-hint\">Save the product once first \u2014 media needs a real product to attach to.</div>\n } @else {\n <div class=\"x-cat-media-grid\">\n @for (m of media; track m.id) {\n <div class=\"x-cat-media-item\">\n <img [src]=\"m.objectUrl\" [alt]=\"m.altText\" />\n <button type=\"button\" class=\"x-cat-media-remove\" (click)=\"removeMedia(m)\">Remove</button>\n </div>\n }\n </div>\n <label class=\"x-cat-btn x-cat-upload\">\n {{ uploadingMedia ? 'Uploading\u2026' : '+ Add image' }}\n <input type=\"file\" accept=\"image/*\" hidden [disabled]=\"uploadingMedia\" (change)=\"onMediaSelected($event)\" />\n </label>\n <div class=\"x-cat-hint\">\n Best results: <strong>1200 \u00D7 1200 px</strong> or larger, square (4:3 also works), product centred on a\n plain white/light background, JPG or WebP, under 8 MB. The first image is the listing thumbnail \u2014\n use a clean front-on shot; add the rating plate, ports/back and box contents as extra images.\n The site downsizes photos for phones automatically, so upload the sharp original.\n </div>\n }\n </section>\n </div>\n}\n", styles: [".x-page-head{margin-bottom:20px}.x-page-title{font-size:24px;font-weight:800;color:var(--color-text)}.x-page-subtitle{font-size:13.5px;color:var(--color-text-muted);margin-top:2px}.x-cat-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:14px}.x-cat-search{border:1px solid var(--color-border);border-radius:var(--radius-md);padding:6px 10px;font-size:13px;background:var(--color-surface);color:var(--color-text);min-width:220px}.x-cat-spacer{flex:1}.x-cat-btn{border:1px solid var(--color-border);background:transparent;border-radius:var(--radius-md);padding:6px 14px;font-size:12.5px;font-weight:700;cursor:pointer;color:var(--color-text-muted);display:inline-flex;align-items:center}.x-cat-btn:disabled{opacity:.5;cursor:default}.x-cat-btn-primary{border-color:var(--color-accent);color:var(--color-accent-hover)}.x-catalog-table{display:flex;flex-direction:column;border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden}.x-catalog-row{display:grid;grid-template-columns:2fr 100px 120px 2fr 160px;gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--color-border);font-size:13px}.x-catalog-row:last-child{border-bottom:none}.x-catalog-head{font-weight:700;font-size:11.5px;text-transform:uppercase;letter-spacing:.02em;color:var(--color-text-muted);background:var(--color-surface-alt, rgba(0, 0, 0, .02))}.x-catalog-model{display:block;font-size:11.5px;color:var(--color-text-muted)}.x-catalog-col-action{display:flex;gap:6px;justify-content:flex-end}.x-catalog-empty{padding:24px;text-align:center;color:var(--color-text-muted);font-size:13px}.x-status-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;border:1px solid var(--color-border);text-transform:capitalize}.x-check-pending{color:var(--color-text-muted);font-size:12px}.x-check-ok{color:var(--color-success, #2a7a3b);font-size:12px;font-weight:700}.x-check-errors{margin:0;padding-left:16px;font-size:11.5px;color:var(--color-danger, #c0362c)}.x-check-btn{border:1px solid var(--color-border);background:transparent;border-radius:var(--radius-md);padding:4px 10px;font-size:11.5px;font-weight:700;cursor:pointer;color:var(--color-text-muted)}.x-check-btn:disabled{opacity:.5;cursor:default}.x-cat-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:12px}.x-cat-pager-label{font-size:12px;color:var(--color-text-muted)}.x-cat-editor{display:flex;flex-direction:column;gap:16px}.x-cat-editor-head{display:flex;align-items:center;justify-content:space-between}.x-cat-editor-title{font-size:18px;font-weight:800;color:var(--color-text)}.x-cat-editor-actions{display:flex;gap:8px}.x-cat-section{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;display:flex;flex-direction:column;gap:10px}.x-cat-section-title{font-size:13px;font-weight:800;color:var(--color-text);text-transform:uppercase;letter-spacing:.02em}.x-cat-f{display:flex;flex-direction:column;gap:4px;flex:1;font-size:11.5px;color:var(--color-text-muted);font-weight:700}.x-cat-f input,.x-cat-f select,.x-cat-f textarea{border:1px solid var(--color-border);border-radius:var(--radius-md);padding:6px 10px;font-size:13px;background:var(--color-surface);color:var(--color-text);font-weight:400}.x-cat-f-row{display:flex;gap:10px;flex-wrap:wrap}.x-cat-hint{font-size:12px;color:var(--color-text-muted);font-style:italic}.x-cat-required{color:var(--color-danger, #c0362c)}.x-cat-attr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}.x-cat-attr input[type=checkbox]{width:18px;height:18px}.x-cat-range{display:flex;align-items:center;gap:6px}.x-cat-range input{width:0;flex:1}.x-cat-media-grid{display:flex;flex-wrap:wrap;gap:12px}.x-cat-media-item{position:relative;width:100px;height:100px;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden}.x-cat-media-item img{width:100%;height:100%;object-fit:cover;display:block}.x-cat-media-remove{position:absolute;bottom:0;left:0;right:0;border:none;background:#0009;color:#fff;font-size:10px;padding:3px 0;cursor:pointer}.x-cat-upload{align-self:flex-start;cursor:pointer}\n"], dependencies: [{ kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "component", type: i2.LoaderComponent, selector: "x-element-loader", inputs: ["data"] }] }); }
|
|
532
|
+
}
|
|
533
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: CatalogComponent, decorators: [{
|
|
534
|
+
type: Component,
|
|
535
|
+
args: [{ standalone: false, selector: 'x-catalog', template: "<div class=\"x-page-head\">\n <div class=\"x-page-title\">Catalogue</div>\n <div class=\"x-page-subtitle\">Products, variants, attributes, media, pricing and stock.</div>\n</div>\n\n@if (!editing) {\n <div class=\"x-cat-toolbar\">\n <input\n type=\"text\"\n class=\"x-cat-search\"\n placeholder=\"Search products\u2026\"\n [(ngModel)]=\"query\"\n (keyup.enter)=\"search()\"\n />\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"search()\">Search</button>\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"refresh()\">Refresh</button>\n <div class=\"x-cat-spacer\"></div>\n <a class=\"x-cat-btn\" routerLink=\"/admin/bulk-upload\">Bulk upload (Excel)</a>\n <button type=\"button\" class=\"x-cat-btn x-cat-btn-primary\" (click)=\"startCreate()\">+ New product</button>\n </div>\n\n @if (loading) {\n <x-element-loader></x-element-loader>\n } @else {\n <div class=\"x-catalog-table\">\n <div class=\"x-catalog-row x-catalog-head\">\n <div class=\"x-catalog-col-name\">Product</div>\n <div class=\"x-catalog-col-type\">Type</div>\n <div class=\"x-catalog-col-status\">Status</div>\n <div class=\"x-catalog-col-check\">Publish check</div>\n <div class=\"x-catalog-col-action\"></div>\n </div>\n @for (product of products; track product.id) {\n <div class=\"x-catalog-row\">\n <div class=\"x-catalog-col-name\">\n {{ product.name }}\n @if (product.model) {\n <span class=\"x-catalog-model\">{{ product.model }}</span>\n }\n </div>\n <div class=\"x-catalog-col-type\">{{ product.type }}</div>\n <div class=\"x-catalog-col-status\">\n <span class=\"x-status-badge\" [class]=\"'x-status-' + product.status\">{{\n product.status\n }}</span>\n </div>\n <div class=\"x-catalog-col-check\">\n @if (product.checking) {\n <span class=\"x-check-pending\">Checking\u2026</span>\n } @else if (product.checked && product.canPublish) {\n <span class=\"x-check-ok\">\u2713 Ready to publish</span>\n } @else if (product.checked && product.errors?.length) {\n <ul class=\"x-check-errors\">\n @for (err of product.errors; track err.field) {\n <li>{{ err.message }}</li>\n }\n </ul>\n }\n </div>\n <div class=\"x-catalog-col-action\">\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"edit(product)\">Edit</button>\n <button\n type=\"button\"\n class=\"x-check-btn\"\n [disabled]=\"product.checking\"\n (click)=\"checkPublish(product)\"\n >\n Check\n </button>\n </div>\n </div>\n }\n @if (!products.length) {\n <div class=\"x-catalog-empty\">No products yet \u2014 use \"+ New product\" to add one.</div>\n }\n </div>\n\n <div class=\"x-cat-pager\">\n <button type=\"button\" class=\"x-cat-btn\" [disabled]=\"page <= 1\" (click)=\"prevPage()\">\u2190 Prev</button>\n <span class=\"x-cat-pager-label\">Page {{ page }} \u00B7 {{ total }} total</span>\n <button type=\"button\" class=\"x-cat-btn\" [disabled]=\"page * perPage >= total\" (click)=\"nextPage()\">\n Next \u2192\n </button>\n </div>\n }\n} @else if (draft) {\n <div class=\"x-cat-editor\">\n <div class=\"x-cat-editor-head\">\n <div class=\"x-cat-editor-title\">{{ draft.id ? 'Edit product' : 'New product' }}</div>\n <div class=\"x-cat-editor-actions\">\n <button type=\"button\" class=\"x-cat-btn\" (click)=\"cancelEdit()\">Close</button>\n <button type=\"button\" class=\"x-cat-btn x-cat-btn-primary\" [disabled]=\"saving\" (click)=\"saveProduct()\">\n {{ saving ? 'Saving\u2026' : 'Save' }}\n </button>\n </div>\n </div>\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Basic info</div>\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Name\n <input type=\"text\" [(ngModel)]=\"draft.name\" />\n </label>\n <label class=\"x-cat-f\">\n Slug (optional)\n <input type=\"text\" [(ngModel)]=\"draft.slug\" placeholder=\"auto from name if blank\" />\n </label>\n </div>\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Model\n <input type=\"text\" [(ngModel)]=\"draft.model\" />\n </label>\n <label class=\"x-cat-f\">\n Type\n <select [(ngModel)]=\"draft.type\">\n @for (t of types; track t) { <option [value]=\"t\">{{ t }}</option> }\n </select>\n </label>\n <label class=\"x-cat-f\">\n Status\n <select [(ngModel)]=\"draft.status\">\n @for (s of statuses; track s) { <option [value]=\"s\">{{ s }}</option> }\n </select>\n </label>\n </div>\n @if (draft.type !== 'simple') {\n <div class=\"x-cat-hint\">\n \"{{ draft.type }}\" products still save through this one-variant form for now \u2014 a full\n per-variant / per-component editor is a follow-up.\n </div>\n }\n <label class=\"x-cat-f\">\n Short description\n <textarea rows=\"2\" [(ngModel)]=\"draft.shortDesc\"></textarea>\n </label>\n </section>\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Category & brand</div>\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Category\n <select [(ngModel)]=\"draft.primaryCategoryId\" (ngModelChange)=\"onCategoryChange()\">\n <option value=\"\">\u2014 none \u2014</option>\n @for (c of categories; track c.id) { <option [value]=\"c.id\">{{ c.name }}</option> }\n </select>\n </label>\n <label class=\"x-cat-f\">\n Brand\n <select [(ngModel)]=\"draft.brandId\">\n <option value=\"\">\u2014 none \u2014</option>\n @for (b of brands; track b.id) { <option [value]=\"b.id\">{{ b.name }}</option> }\n </select>\n </label>\n </div>\n </section>\n\n @if (attributeDefs.length) {\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Attributes</div>\n <div class=\"x-cat-attr-grid\">\n @for (def of attributeDefs; track def.id) {\n <label class=\"x-cat-f x-cat-attr\">\n {{ def.label }}{{ def.unit ? ' (' + def.unit + ')' : '' }}\n @if (def.required) {\n <span class=\"x-cat-required\">*</span>\n }\n @switch (def.dataType) {\n @case ('boolean') {\n <input type=\"checkbox\" [(ngModel)]=\"attributeValues[def.id].valueBool\" />\n }\n @case ('enum') {\n <select [(ngModel)]=\"attributeValues[def.id].valueText\">\n <option value=\"\">\u2014 choose \u2014</option>\n @for (opt of def.allowedValues; track opt) { <option [value]=\"opt\">{{ opt }}</option> }\n </select>\n }\n @case ('range') {\n <div class=\"x-cat-range\">\n <input type=\"number\" placeholder=\"min\" [(ngModel)]=\"attributeValues[def.id].valueNumber\" />\n <span>\u2013</span>\n <input\n type=\"number\"\n placeholder=\"max\"\n [(ngModel)]=\"attributeValues[def.id].valueNumberHigh\"\n />\n </div>\n }\n @case ('number') {\n <input type=\"number\" [(ngModel)]=\"attributeValues[def.id].valueNumber\" />\n }\n @default {\n <input type=\"text\" [(ngModel)]=\"attributeValues[def.id].valueText\" />\n }\n }\n </label>\n }\n </div>\n </section>\n }\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Variant, price & stock</div>\n @if (variant) {\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n SKU\n <input type=\"text\" [(ngModel)]=\"variant.sku\" />\n </label>\n <label class=\"x-cat-f\">\n Weight (kg)\n <input type=\"number\" [(ngModel)]=\"variant.weight\" />\n </label>\n <label class=\"x-cat-f\">\n Handling class\n <select [(ngModel)]=\"variant.handlingClass\">\n <option value=\"standard\">standard</option>\n <option value=\"bulky\">bulky</option>\n <option value=\"freight\">freight</option>\n <option value=\"hazmat\">hazmat</option>\n </select>\n </label>\n </div>\n }\n <div class=\"x-cat-f-row\">\n <label class=\"x-cat-f\">\n Price (\u20A6)\n <input type=\"number\" [(ngModel)]=\"price!.amount\" [disabled]=\"!variant?.sku\" />\n </label>\n <label class=\"x-cat-f\">\n Stock location\n <select [(ngModel)]=\"stock!.locationId\" [disabled]=\"!variant?.sku\">\n @for (loc of stockLocations; track loc.id) { <option [value]=\"loc.id\">{{ loc.name }}</option> }\n </select>\n </label>\n <label class=\"x-cat-f\">\n Available\n <input type=\"number\" [(ngModel)]=\"stock!.available\" [disabled]=\"!variant?.sku\" />\n </label>\n </div>\n @if (!variant?.sku) {\n <div class=\"x-cat-hint\">Enter a SKU above, then price/stock can be saved.</div>\n }\n </section>\n\n <section class=\"x-cat-section\">\n <div class=\"x-cat-section-title\">Media</div>\n @if (!draft.id) {\n <div class=\"x-cat-hint\">Save the product once first \u2014 media needs a real product to attach to.</div>\n } @else {\n <div class=\"x-cat-media-grid\">\n @for (m of media; track m.id) {\n <div class=\"x-cat-media-item\">\n <img [src]=\"m.objectUrl\" [alt]=\"m.altText\" />\n <button type=\"button\" class=\"x-cat-media-remove\" (click)=\"removeMedia(m)\">Remove</button>\n </div>\n }\n </div>\n <label class=\"x-cat-btn x-cat-upload\">\n {{ uploadingMedia ? 'Uploading\u2026' : '+ Add image' }}\n <input type=\"file\" accept=\"image/*\" hidden [disabled]=\"uploadingMedia\" (change)=\"onMediaSelected($event)\" />\n </label>\n <div class=\"x-cat-hint\">\n Best results: <strong>1200 \u00D7 1200 px</strong> or larger, square (4:3 also works), product centred on a\n plain white/light background, JPG or WebP, under 8 MB. The first image is the listing thumbnail \u2014\n use a clean front-on shot; add the rating plate, ports/back and box contents as extra images.\n The site downsizes photos for phones automatically, so upload the sharp original.\n </div>\n }\n </section>\n </div>\n}\n", styles: [".x-page-head{margin-bottom:20px}.x-page-title{font-size:24px;font-weight:800;color:var(--color-text)}.x-page-subtitle{font-size:13.5px;color:var(--color-text-muted);margin-top:2px}.x-cat-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:14px}.x-cat-search{border:1px solid var(--color-border);border-radius:var(--radius-md);padding:6px 10px;font-size:13px;background:var(--color-surface);color:var(--color-text);min-width:220px}.x-cat-spacer{flex:1}.x-cat-btn{border:1px solid var(--color-border);background:transparent;border-radius:var(--radius-md);padding:6px 14px;font-size:12.5px;font-weight:700;cursor:pointer;color:var(--color-text-muted);display:inline-flex;align-items:center}.x-cat-btn:disabled{opacity:.5;cursor:default}.x-cat-btn-primary{border-color:var(--color-accent);color:var(--color-accent-hover)}.x-catalog-table{display:flex;flex-direction:column;border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden}.x-catalog-row{display:grid;grid-template-columns:2fr 100px 120px 2fr 160px;gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--color-border);font-size:13px}.x-catalog-row:last-child{border-bottom:none}.x-catalog-head{font-weight:700;font-size:11.5px;text-transform:uppercase;letter-spacing:.02em;color:var(--color-text-muted);background:var(--color-surface-alt, rgba(0, 0, 0, .02))}.x-catalog-model{display:block;font-size:11.5px;color:var(--color-text-muted)}.x-catalog-col-action{display:flex;gap:6px;justify-content:flex-end}.x-catalog-empty{padding:24px;text-align:center;color:var(--color-text-muted);font-size:13px}.x-status-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;border:1px solid var(--color-border);text-transform:capitalize}.x-check-pending{color:var(--color-text-muted);font-size:12px}.x-check-ok{color:var(--color-success, #2a7a3b);font-size:12px;font-weight:700}.x-check-errors{margin:0;padding-left:16px;font-size:11.5px;color:var(--color-danger, #c0362c)}.x-check-btn{border:1px solid var(--color-border);background:transparent;border-radius:var(--radius-md);padding:4px 10px;font-size:11.5px;font-weight:700;cursor:pointer;color:var(--color-text-muted)}.x-check-btn:disabled{opacity:.5;cursor:default}.x-cat-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:12px}.x-cat-pager-label{font-size:12px;color:var(--color-text-muted)}.x-cat-editor{display:flex;flex-direction:column;gap:16px}.x-cat-editor-head{display:flex;align-items:center;justify-content:space-between}.x-cat-editor-title{font-size:18px;font-weight:800;color:var(--color-text)}.x-cat-editor-actions{display:flex;gap:8px}.x-cat-section{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;display:flex;flex-direction:column;gap:10px}.x-cat-section-title{font-size:13px;font-weight:800;color:var(--color-text);text-transform:uppercase;letter-spacing:.02em}.x-cat-f{display:flex;flex-direction:column;gap:4px;flex:1;font-size:11.5px;color:var(--color-text-muted);font-weight:700}.x-cat-f input,.x-cat-f select,.x-cat-f textarea{border:1px solid var(--color-border);border-radius:var(--radius-md);padding:6px 10px;font-size:13px;background:var(--color-surface);color:var(--color-text);font-weight:400}.x-cat-f-row{display:flex;gap:10px;flex-wrap:wrap}.x-cat-hint{font-size:12px;color:var(--color-text-muted);font-style:italic}.x-cat-required{color:var(--color-danger, #c0362c)}.x-cat-attr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}.x-cat-attr input[type=checkbox]{width:18px;height:18px}.x-cat-range{display:flex;align-items:center;gap:6px}.x-cat-range input{width:0;flex:1}.x-cat-media-grid{display:flex;flex-wrap:wrap;gap:12px}.x-cat-media-item{position:relative;width:100px;height:100px;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden}.x-cat-media-item img{width:100%;height:100%;object-fit:cover;display:block}.x-cat-media-remove{position:absolute;bottom:0;left:0;right:0;border:none;background:#0009;color:#fff;font-size:10px;padding:3px 0;cursor:pointer}.x-cat-upload{align-self:flex-start;cursor:pointer}\n"] }]
|
|
536
|
+
}] });
|
|
537
|
+
|
|
538
|
+
class CatalogModule {
|
|
539
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: CatalogModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); }
|
|
540
|
+
static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.16", ngImport: i0, type: CatalogModule, declarations: [CatalogComponent], imports: [SharedModule,
|
|
541
|
+
ElementsModule,
|
|
542
|
+
WebgetsModule, i1$1.RouterModule] }); }
|
|
543
|
+
static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: CatalogModule, imports: [SharedModule,
|
|
544
|
+
ElementsModule,
|
|
545
|
+
WebgetsModule,
|
|
546
|
+
RouterModule.forChild([{ path: '', component: CatalogComponent }])] }); }
|
|
547
|
+
}
|
|
548
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: CatalogModule, decorators: [{
|
|
549
|
+
type: NgModule,
|
|
550
|
+
args: [{
|
|
551
|
+
declarations: [CatalogComponent],
|
|
552
|
+
imports: [
|
|
553
|
+
SharedModule,
|
|
554
|
+
ElementsModule,
|
|
555
|
+
WebgetsModule,
|
|
556
|
+
RouterModule.forChild([{ path: '', component: CatalogComponent }]),
|
|
557
|
+
],
|
|
558
|
+
}]
|
|
559
|
+
}] });
|
|
560
|
+
|
|
561
|
+
export { CatalogModule };
|
|
562
|
+
//# sourceMappingURL=isaiahiroko-tilmart-ngx-apps-admin-catalog.module-BwjfFXDU.mjs.map
|