refactor(js): migrate JavaScript files to module directories

Move 47 JS files from static/{admin,vendor,shared}/js/ to their
respective module directories app/modules/*/static/*/js/:

- Orders: orders.js, order-detail.js
- Catalog: products.js (renamed from vendor-products.js), product-*.js
- Inventory: inventory.js (admin & vendor)
- Customers: customers.js, users.js, user-*.js
- Billing: billing-history.js, subscriptions.js, subscription-tiers.js,
  billing.js, invoices.js, feature-store.js, upgrade-prompts.js
- Messaging: messages.js, notifications.js, email-templates.js
- Marketplace: marketplace*.js, letzshop*.js, onboarding.js
- Monitoring: monitoring.js, background-tasks.js, imports.js, logs.js
- Dev Tools: testing-*.js, code-quality-*.js

Update 39 templates to reference new module static paths using
url_for('{module}_static', path='...') pattern.

Files staying in static/ (platform core):
- admin: dashboard, login, platforms, vendors, companies, admin-users,
  settings, components, init-alpine, module-config
- vendor: dashboard, login, profile, settings, team, media, init-alpine
- shared: api-client, utils, money, icons, log-config, vendor-selector,
  media-picker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 22:08:20 +01:00
parent 434db1560a
commit 0b4291d893
86 changed files with 63 additions and 63 deletions

View File

@@ -0,0 +1,403 @@
// app/modules/catalog/static/admin/js/product-create.js
/**
* Admin vendor product create page logic
* Create new vendor product entries with translations
*/
const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate ||
window.LogConfig.createLogger('adminVendorProductCreate', false);
adminVendorProductCreateLog.info('Loading...');
function adminVendorProductCreate() {
adminVendorProductCreateLog.info('adminVendorProductCreate() called');
// Default translations structure
const defaultTranslations = () => ({
en: { title: '', description: '' },
fr: { title: '', description: '' },
de: { title: '', description: '' },
lu: { title: '', description: '' }
});
return {
// Inherit base layout state
...data(),
// Include media picker functionality (vendor ID getter will be bound via loadMediaLibrary override)
...mediaPickerMixin(() => null, false),
// Set page identifier
currentPage: 'vendor-products',
// Loading states
loading: false,
saving: false,
// Tom Select instance
vendorSelectInstance: null,
// Active language tab
activeLanguage: 'en',
// Form data
form: {
vendor_id: null,
// Translations by language
translations: defaultTranslations(),
// Product identifiers
vendor_sku: '',
brand: '',
gtin: '',
gtin_type: '',
// Pricing
price: null,
sale_price: null,
currency: 'EUR',
tax_rate_percent: 17,
availability: '',
// Images
primary_image_url: '',
additional_images: [],
// Status
is_active: true,
is_featured: false,
is_digital: false
},
async init() {
adminVendorProductCreateLog.info('Vendor Product Create init() called');
// Guard against multiple initialization
if (window._adminVendorProductCreateInitialized) {
adminVendorProductCreateLog.warn('Already initialized, skipping');
return;
}
window._adminVendorProductCreateInitialized = true;
// Initialize Tom Select
this.initVendorSelect();
adminVendorProductCreateLog.info('Vendor Product Create initialization complete');
},
/**
* Initialize Tom Select for vendor autocomplete
*/
initVendorSelect() {
const selectEl = this.$refs.vendorSelect;
if (!selectEl) {
adminVendorProductCreateLog.warn('Vendor select element not found');
return;
}
// Wait for Tom Select to be available
if (typeof TomSelect === 'undefined') {
adminVendorProductCreateLog.warn('TomSelect not loaded, retrying in 100ms');
setTimeout(() => this.initVendorSelect(), 100);
return;
}
this.vendorSelectInstance = new TomSelect(selectEl, {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
placeholder: 'Search vendor...',
load: async (query, callback) => {
try {
const response = await apiClient.get('/admin/vendors', {
search: query,
limit: 50
});
callback(response.vendors || []);
} catch (error) {
adminVendorProductCreateLog.error('Failed to search vendors:', error);
callback([]);
}
},
render: {
option: (data, escape) => {
return `<div class="flex items-center justify-between py-1">
<span>${escape(data.name)}</span>
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
</div>`;
},
item: (data, escape) => {
return `<div>${escape(data.name)}</div>`;
}
},
onChange: (value) => {
this.form.vendor_id = value ? parseInt(value) : null;
}
});
adminVendorProductCreateLog.info('Vendor select initialized');
},
/**
* Generate a unique vendor SKU
* Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness)
*/
generateSku() {
const vendorId = this.form.vendor_id || 0;
// Generate random alphanumeric segments
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const generateSegment = (length) => {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
// First segment includes vendor ID (padded)
const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4);
// Generate SKU: VID + random + random
const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`;
this.form.vendor_sku = sku;
adminVendorProductCreateLog.info('Generated SKU:', sku);
},
/**
* Create the product
*/
async createProduct() {
if (!this.form.vendor_id) {
Utils.showToast('Please select a vendor', 'error');
return;
}
if (!this.form.translations.en.title?.trim()) {
Utils.showToast('Please enter a product title (English)', 'error');
return;
}
this.saving = true;
try {
// Build translations object for API (only include non-empty)
const translations = {};
for (const lang of ['en', 'fr', 'de', 'lu']) {
const t = this.form.translations[lang];
if (t.title?.trim() || t.description?.trim()) {
translations[lang] = {
title: t.title?.trim() || null,
description: t.description?.trim() || null
};
}
}
// Build create payload
const payload = {
vendor_id: this.form.vendor_id,
translations: Object.keys(translations).length > 0 ? translations : null,
// Product identifiers
brand: this.form.brand?.trim() || null,
vendor_sku: this.form.vendor_sku?.trim() || null,
gtin: this.form.gtin?.trim() || null,
gtin_type: this.form.gtin_type || null,
// Pricing
price: this.form.price !== null && this.form.price !== ''
? parseFloat(this.form.price) : null,
sale_price: this.form.sale_price !== null && this.form.sale_price !== ''
? parseFloat(this.form.sale_price) : null,
currency: this.form.currency || 'EUR',
tax_rate_percent: this.form.tax_rate_percent !== null
? parseInt(this.form.tax_rate_percent) : 17,
availability: this.form.availability || null,
// Images
primary_image_url: this.form.primary_image_url?.trim() || null,
additional_images: this.form.additional_images?.length > 0
? this.form.additional_images : null,
// Status
is_active: this.form.is_active,
is_featured: this.form.is_featured,
is_digital: this.form.is_digital
};
adminVendorProductCreateLog.info('Creating product with payload:', payload);
const response = await apiClient.post('/admin/vendor-products', payload);
adminVendorProductCreateLog.info('Product created:', response.id);
Utils.showToast('Product created successfully', 'success');
// Redirect to the new product's detail page
setTimeout(() => {
window.location.href = `/admin/vendor-products/${response.id}`;
}, 1000);
} catch (error) {
adminVendorProductCreateLog.error('Failed to create product:', error);
Utils.showToast(error.message || 'Failed to create product', 'error');
} finally {
this.saving = false;
}
},
// === Media Picker Overrides ===
// These override the mixin methods to use proper form context
/**
* Load media library for the selected vendor
*/
async loadMediaLibrary() {
const vendorId = this.form?.vendor_id;
if (!vendorId) {
adminVendorProductCreateLog.warn('Media picker: No vendor ID selected');
return;
}
this.mediaPickerState.loading = true;
this.mediaPickerState.skip = 0;
try {
const params = new URLSearchParams({
skip: '0',
limit: this.mediaPickerState.limit.toString(),
media_type: 'image',
});
if (this.mediaPickerState.search) {
params.append('search', this.mediaPickerState.search);
}
const response = await apiClient.get(
`/admin/media/vendors/${vendorId}?${params.toString()}`
);
this.mediaPickerState.media = response.media || [];
this.mediaPickerState.total = response.total || 0;
} catch (error) {
adminVendorProductCreateLog.error('Failed to load media library:', error);
Utils.showToast('Failed to load media library', 'error');
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Load more media (pagination)
*/
async loadMoreMedia() {
const vendorId = this.form?.vendor_id;
if (!vendorId) return;
this.mediaPickerState.loading = true;
this.mediaPickerState.skip += this.mediaPickerState.limit;
try {
const params = new URLSearchParams({
skip: this.mediaPickerState.skip.toString(),
limit: this.mediaPickerState.limit.toString(),
media_type: 'image',
});
if (this.mediaPickerState.search) {
params.append('search', this.mediaPickerState.search);
}
const response = await apiClient.get(
`/admin/media/vendors/${vendorId}?${params.toString()}`
);
this.mediaPickerState.media = [
...this.mediaPickerState.media,
...(response.media || [])
];
} catch (error) {
adminVendorProductCreateLog.error('Failed to load more media:', error);
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Upload a new media file
*/
async uploadMediaFile(event) {
const file = event.target.files?.[0];
if (!file) return;
const vendorId = this.form?.vendor_id;
if (!vendorId) {
Utils.showToast('Please select a vendor first', 'error');
return;
}
if (!file.type.startsWith('image/')) {
Utils.showToast('Please select an image file', 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
Utils.showToast('Image must be less than 10MB', 'error');
return;
}
this.mediaPickerState.uploading = true;
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.postFormData(
`/admin/media/vendors/${vendorId}/upload?folder=products`,
formData
);
if (response.success && response.media) {
this.mediaPickerState.media.unshift(response.media);
this.mediaPickerState.total++;
this.toggleMediaSelection(response.media);
Utils.showToast('Image uploaded successfully', 'success');
}
} catch (error) {
adminVendorProductCreateLog.error('Failed to upload image:', error);
Utils.showToast(error.message || 'Failed to upload image', 'error');
} finally {
this.mediaPickerState.uploading = false;
event.target.value = '';
}
},
/**
* Set main image from media picker
*/
setMainImage(media) {
this.form.primary_image_url = media.url;
adminVendorProductCreateLog.info('Main image set:', media.url);
},
/**
* Add additional images from media picker
*/
addAdditionalImages(mediaList) {
const newUrls = mediaList.map(m => m.url);
this.form.additional_images = [
...this.form.additional_images,
...newUrls
];
adminVendorProductCreateLog.info('Additional images added:', newUrls);
},
/**
* Remove an additional image by index
*/
removeAdditionalImage(index) {
this.form.additional_images.splice(index, 1);
},
/**
* Clear the main image
*/
clearMainImage() {
this.form.primary_image_url = '';
}
};
}

View File

@@ -0,0 +1,170 @@
// app/modules/catalog/static/admin/js/product-detail.js
/**
* Admin vendor product detail page logic
* View and manage individual vendor catalog products
*/
const adminVendorProductDetailLog = window.LogConfig.loggers.adminVendorProductDetail ||
window.LogConfig.createLogger('adminVendorProductDetail', false);
adminVendorProductDetailLog.info('Loading...');
function adminVendorProductDetail() {
adminVendorProductDetailLog.info('adminVendorProductDetail() called');
// Extract product ID from URL
const pathParts = window.location.pathname.split('/');
const productId = parseInt(pathParts[pathParts.length - 1]);
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'vendor-products',
// Product ID from URL
productId: productId,
// Loading states
loading: true,
error: '',
// Product data
product: null,
// Modals
showRemoveModal: false,
removing: false,
async init() {
adminVendorProductDetailLog.info('Vendor Product Detail init() called, ID:', this.productId);
// Guard against multiple initialization
if (window._adminVendorProductDetailInitialized) {
adminVendorProductDetailLog.warn('Already initialized, skipping');
return;
}
window._adminVendorProductDetailInitialized = true;
// Load product data
await this.loadProduct();
adminVendorProductDetailLog.info('Vendor Product Detail initialization complete');
},
/**
* Load product details
*/
async loadProduct() {
this.loading = true;
this.error = '';
try {
const response = await apiClient.get(`/admin/vendor-products/${this.productId}`);
this.product = response;
adminVendorProductDetailLog.info('Loaded product:', this.product.id);
} catch (error) {
adminVendorProductDetailLog.error('Failed to load product:', error);
this.error = error.message || 'Failed to load product details';
} finally {
this.loading = false;
}
},
/**
* Open edit modal (placeholder for future implementation)
*/
openEditModal() {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Edit functionality coming soon', type: 'info' }
}));
},
/**
* Toggle active status
*/
async toggleActive() {
// TODO: Implement PATCH endpoint for status update
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: 'Status toggle functionality coming soon',
type: 'info'
}
}));
},
/**
* Confirm remove
*/
confirmRemove() {
this.showRemoveModal = true;
},
/**
* Execute remove
*/
async executeRemove() {
this.removing = true;
try {
await apiClient.delete(`/admin/vendor-products/${this.productId}`);
adminVendorProductDetailLog.info('Product removed:', this.productId);
window.dispatchEvent(new CustomEvent('toast', {
detail: {
message: 'Product removed from catalog successfully',
type: 'success'
}
}));
// Redirect to vendor products list
setTimeout(() => {
window.location.href = '/admin/vendor-products';
}, 1000);
} catch (error) {
adminVendorProductDetailLog.error('Failed to remove product:', error);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: error.message || 'Failed to remove product', type: 'error' }
}));
} finally {
this.removing = false;
this.showRemoveModal = false;
}
},
/**
* Format price for display
*/
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return '-';
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
if (isNaN(numPrice)) return price;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currency || 'EUR'
}).format(numPrice);
},
/**
* Format date for display
*/
formatDate(dateString) {
if (!dateString) return '-';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
return dateString;
}
}
};
}

View File

@@ -0,0 +1,445 @@
// app/modules/catalog/static/admin/js/product-edit.js
/**
* Admin vendor product edit page logic
* Edit vendor product information with translations
*/
const adminVendorProductEditLog = window.LogConfig.loggers.adminVendorProductEdit ||
window.LogConfig.createLogger('adminVendorProductEdit', false);
adminVendorProductEditLog.info('Loading...');
function adminVendorProductEdit() {
adminVendorProductEditLog.info('adminVendorProductEdit() called');
// Extract product ID from URL
const pathParts = window.location.pathname.split('/');
const productId = parseInt(pathParts[pathParts.length - 2]); // /vendor-products/{id}/edit
// Default translations structure
const defaultTranslations = () => ({
en: { title: '', description: '' },
fr: { title: '', description: '' },
de: { title: '', description: '' },
lu: { title: '', description: '' }
});
return {
// Inherit base layout state
...data(),
// Include media picker functionality (vendor ID comes from loaded product)
...mediaPickerMixin(() => null, false),
// Set page identifier
currentPage: 'vendor-products',
// Product ID from URL
productId: productId,
// Loading states
loading: true,
saving: false,
error: '',
// Product data from API
product: null,
// Active language tab
activeLanguage: 'en',
// Form data
form: {
// Translations by language
translations: defaultTranslations(),
// Product identifiers
vendor_sku: '',
brand: '',
gtin: '',
gtin_type: 'ean13',
// Pricing
price: null,
sale_price: null,
currency: 'EUR',
tax_rate_percent: 17,
availability: '',
// Images
primary_image_url: '',
additional_images: [],
// Product type & status
is_digital: false,
is_active: true,
is_featured: false,
// Optional supplier info
supplier: '',
cost: null
},
async init() {
adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId);
// Guard against multiple initialization
if (window._adminVendorProductEditInitialized) {
adminVendorProductEditLog.warn('Already initialized, skipping');
return;
}
window._adminVendorProductEditInitialized = true;
// Load product data
await this.loadProduct();
adminVendorProductEditLog.info('Vendor Product Edit initialization complete');
},
/**
* Load product details and populate form
*/
async loadProduct() {
this.loading = true;
this.error = '';
try {
const response = await apiClient.get(`/admin/vendor-products/${this.productId}`);
this.product = response;
adminVendorProductEditLog.info('Loaded product:', response);
// Populate translations from vendor_translations
const translations = defaultTranslations();
if (response.vendor_translations) {
for (const lang of ['en', 'fr', 'de', 'lu']) {
if (response.vendor_translations[lang]) {
translations[lang] = {
title: response.vendor_translations[lang].title || '',
description: response.vendor_translations[lang].description || ''
};
}
}
}
// Populate form with current values
this.form = {
translations: translations,
// Product identifiers
vendor_sku: response.vendor_sku || '',
brand: response.brand || '',
gtin: response.gtin || '',
gtin_type: response.gtin_type || 'ean13',
// Pricing (convert cents to euros if stored as cents)
price: response.price || null,
sale_price: response.sale_price || null,
currency: response.currency || 'EUR',
tax_rate_percent: response.tax_rate_percent ?? 17,
availability: response.availability || '',
// Images
primary_image_url: response.primary_image_url || '',
additional_images: response.additional_images || [],
// Product type & status
is_digital: response.is_digital ?? false,
is_active: response.is_active ?? true,
is_featured: response.is_featured ?? false,
// Optional supplier info
supplier: response.supplier || '',
cost: response.cost || null
};
adminVendorProductEditLog.info('Form initialized:', this.form);
} catch (error) {
adminVendorProductEditLog.error('Failed to load product:', error);
this.error = error.message || 'Failed to load product details';
} finally {
this.loading = false;
}
},
/**
* Check if form is valid (all mandatory fields filled)
*/
isFormValid() {
// English title and description are required
if (!this.form.translations.en.title?.trim()) return false;
if (!this.form.translations.en.description?.trim()) return false;
// Product identifiers
if (!this.form.vendor_sku?.trim()) return false;
if (!this.form.brand?.trim()) return false;
if (!this.form.gtin?.trim()) return false;
if (!this.form.gtin_type) return false;
// Pricing
if (this.form.price === null || this.form.price === '' || this.form.price < 0) return false;
if (!this.form.currency) return false;
if (this.form.tax_rate_percent === null || this.form.tax_rate_percent === '') return false;
// Image
if (!this.form.primary_image_url?.trim()) return false;
return true;
},
/**
* Generate a unique vendor SKU
* Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness)
*/
generateSku() {
const vendorId = this.product?.vendor_id || 0;
// Generate random alphanumeric segments
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const generateSegment = (length) => {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
// First segment includes vendor ID (padded)
const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4);
// Generate SKU: VID + random + random
const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`;
this.form.vendor_sku = sku;
adminVendorProductEditLog.info('Generated SKU:', sku);
},
/**
* Save product changes
*/
async saveProduct() {
if (!this.isFormValid()) {
Utils.showToast('Please fill in all required fields', 'error');
return;
}
this.saving = true;
try {
// Build translations object for API
const translations = {};
for (const lang of ['en', 'fr', 'de', 'lu']) {
const t = this.form.translations[lang];
// Only include if there's actual content
if (t.title?.trim() || t.description?.trim()) {
translations[lang] = {
title: t.title?.trim() || null,
description: t.description?.trim() || null
};
}
}
// Build update payload
const payload = {
translations: Object.keys(translations).length > 0 ? translations : null,
// Product identifiers
vendor_sku: this.form.vendor_sku?.trim() || null,
brand: this.form.brand?.trim() || null,
gtin: this.form.gtin?.trim() || null,
gtin_type: this.form.gtin_type || null,
// Pricing
price: this.form.price !== null && this.form.price !== ''
? parseFloat(this.form.price) : null,
sale_price: this.form.sale_price !== null && this.form.sale_price !== ''
? parseFloat(this.form.sale_price) : null,
currency: this.form.currency || null,
tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== ''
? parseInt(this.form.tax_rate_percent) : null,
availability: this.form.availability || null,
// Images
primary_image_url: this.form.primary_image_url?.trim() || null,
additional_images: this.form.additional_images?.length > 0
? this.form.additional_images : null,
// Status
is_digital: this.form.is_digital,
is_active: this.form.is_active,
is_featured: this.form.is_featured,
// Optional supplier info
supplier: this.form.supplier?.trim() || null,
cost: this.form.cost !== null && this.form.cost !== ''
? parseFloat(this.form.cost) : null
};
adminVendorProductEditLog.info('Saving payload:', payload);
await apiClient.patch(`/admin/vendor-products/${this.productId}`, payload);
adminVendorProductEditLog.info('Product saved:', this.productId);
Utils.showToast('Product updated successfully', 'success');
// Redirect to detail page
setTimeout(() => {
window.location.href = `/admin/vendor-products/${this.productId}`;
}, 1000);
} catch (error) {
adminVendorProductEditLog.error('Failed to save product:', error);
Utils.showToast(error.message || 'Failed to save product', 'error');
} finally {
this.saving = false;
}
},
// === Media Picker Overrides ===
// These override the mixin methods to use proper form/product context
/**
* Load media library for the product's vendor
*/
async loadMediaLibrary() {
const vendorId = this.product?.vendor_id;
if (!vendorId) {
adminVendorProductEditLog.warn('Media picker: No vendor ID available');
return;
}
this.mediaPickerState.loading = true;
this.mediaPickerState.skip = 0;
try {
const params = new URLSearchParams({
skip: '0',
limit: this.mediaPickerState.limit.toString(),
media_type: 'image',
});
if (this.mediaPickerState.search) {
params.append('search', this.mediaPickerState.search);
}
const response = await apiClient.get(
`/admin/media/vendors/${vendorId}?${params.toString()}`
);
this.mediaPickerState.media = response.media || [];
this.mediaPickerState.total = response.total || 0;
} catch (error) {
adminVendorProductEditLog.error('Failed to load media library:', error);
Utils.showToast('Failed to load media library', 'error');
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Load more media (pagination)
*/
async loadMoreMedia() {
const vendorId = this.product?.vendor_id;
if (!vendorId) return;
this.mediaPickerState.loading = true;
this.mediaPickerState.skip += this.mediaPickerState.limit;
try {
const params = new URLSearchParams({
skip: this.mediaPickerState.skip.toString(),
limit: this.mediaPickerState.limit.toString(),
media_type: 'image',
});
if (this.mediaPickerState.search) {
params.append('search', this.mediaPickerState.search);
}
const response = await apiClient.get(
`/admin/media/vendors/${vendorId}?${params.toString()}`
);
this.mediaPickerState.media = [
...this.mediaPickerState.media,
...(response.media || [])
];
} catch (error) {
adminVendorProductEditLog.error('Failed to load more media:', error);
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Upload a new media file
*/
async uploadMediaFile(event) {
const file = event.target.files?.[0];
if (!file) return;
const vendorId = this.product?.vendor_id;
if (!vendorId) {
Utils.showToast('No vendor associated with this product', 'error');
return;
}
if (!file.type.startsWith('image/')) {
Utils.showToast('Please select an image file', 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
Utils.showToast('Image must be less than 10MB', 'error');
return;
}
this.mediaPickerState.uploading = true;
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.postFormData(
`/admin/media/vendors/${vendorId}/upload?folder=products`,
formData
);
if (response.success && response.media) {
this.mediaPickerState.media.unshift(response.media);
this.mediaPickerState.total++;
this.toggleMediaSelection(response.media);
Utils.showToast('Image uploaded successfully', 'success');
}
} catch (error) {
adminVendorProductEditLog.error('Failed to upload image:', error);
Utils.showToast(error.message || 'Failed to upload image', 'error');
} finally {
this.mediaPickerState.uploading = false;
event.target.value = '';
}
},
/**
* Set main image from media picker
*/
setMainImage(media) {
this.form.primary_image_url = media.url;
adminVendorProductEditLog.info('Main image set:', media.url);
},
/**
* Add additional images from media picker
*/
addAdditionalImages(mediaList) {
const newUrls = mediaList.map(m => m.url);
this.form.additional_images = [
...this.form.additional_images,
...newUrls
];
adminVendorProductEditLog.info('Additional images added:', newUrls);
},
/**
* Remove an additional image by index
*/
removeAdditionalImage(index) {
this.form.additional_images.splice(index, 1);
},
/**
* Clear the main image
*/
clearMainImage() {
this.form.primary_image_url = '';
}
};
}

View File

@@ -0,0 +1,441 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/vendor-products.js
/**
* Admin vendor products page logic
* Browse vendor-specific product catalogs with override capability
*/
const adminVendorProductsLog = window.LogConfig.loggers.adminVendorProducts ||
window.LogConfig.createLogger('adminVendorProducts', false);
adminVendorProductsLog.info('Loading...');
function adminVendorProducts() {
adminVendorProductsLog.info('adminVendorProducts() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'vendor-products',
// Loading states
loading: true,
error: '',
// Products data
products: [],
stats: {
total: 0,
active: 0,
inactive: 0,
featured: 0,
digital: 0,
physical: 0,
by_vendor: {}
},
// Filters
filters: {
search: '',
vendor_id: '',
is_active: '',
is_featured: ''
},
// Selected vendor (for prominent display and filtering)
selectedVendor: null,
// Tom Select instance
vendorSelectInstance: null,
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// Product detail modal state
showProductModal: false,
selectedProduct: null,
// Remove confirmation modal state
showRemoveModal: false,
productToRemove: null,
removing: false,
// Debounce timer
searchTimeout: null,
// Computed: Total pages
get totalPages() {
return this.pagination.pages;
},
// Computed: Start index for pagination display
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
// Computed: End index for pagination display
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
// Computed: Page numbers for pagination
get pageNumbers() {
const pages = [];
const totalPages = this.totalPages;
const current = this.pagination.page;
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (current > 3) {
pages.push('...');
}
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 2) {
pages.push('...');
}
pages.push(totalPages);
}
return pages;
},
async init() {
adminVendorProductsLog.info('Vendor Products init() called');
// Guard against multiple initialization
if (window._adminVendorProductsInitialized) {
adminVendorProductsLog.warn('Already initialized, skipping');
return;
}
window._adminVendorProductsInitialized = true;
// Load platform settings for rows per page
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
// Initialize Tom Select for vendor filter
this.initVendorSelect();
// Check localStorage for saved vendor
const savedVendorId = localStorage.getItem('vendor_products_selected_vendor_id');
if (savedVendorId) {
adminVendorProductsLog.info('Restoring saved vendor:', savedVendorId);
// Restore vendor after a short delay to ensure TomSelect is ready
setTimeout(async () => {
await this.restoreSavedVendor(parseInt(savedVendorId));
}, 200);
// Load stats but not products (restoreSavedVendor will do that)
await this.loadStats();
} else {
// No saved vendor - load all data including unfiltered products
await Promise.all([
this.loadStats(),
this.loadProducts()
]);
}
adminVendorProductsLog.info('Vendor Products initialization complete');
},
/**
* Restore saved vendor from localStorage
*/
async restoreSavedVendor(vendorId) {
try {
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
if (this.vendorSelectInstance && vendor) {
// Add the vendor as an option and select it
this.vendorSelectInstance.addOption({
id: vendor.id,
name: vendor.name,
vendor_code: vendor.vendor_code
});
this.vendorSelectInstance.setValue(vendor.id, true);
// Set the filter state
this.selectedVendor = vendor;
this.filters.vendor_id = vendor.id;
adminVendorProductsLog.info('Restored vendor:', vendor.name);
// Load products with the vendor filter applied
await this.loadProducts();
}
} catch (error) {
adminVendorProductsLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
localStorage.removeItem('vendor_products_selected_vendor_id');
// Load unfiltered products as fallback
await this.loadProducts();
}
},
/**
* Initialize Tom Select for vendor autocomplete
*/
initVendorSelect() {
const selectEl = this.$refs.vendorSelect;
if (!selectEl) {
adminVendorProductsLog.warn('Vendor select element not found');
return;
}
// Wait for Tom Select to be available
if (typeof TomSelect === 'undefined') {
adminVendorProductsLog.warn('TomSelect not loaded, retrying in 100ms');
setTimeout(() => this.initVendorSelect(), 100);
return;
}
this.vendorSelectInstance = new TomSelect(selectEl, {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
placeholder: 'Filter by vendor...',
allowEmptyOption: true,
load: async (query, callback) => {
try {
const response = await apiClient.get('/admin/vendors', {
search: query,
limit: 50
});
callback(response.vendors || []);
} catch (error) {
adminVendorProductsLog.error('Failed to search vendors:', error);
callback([]);
}
},
render: {
option: (data, escape) => {
return `<div class="flex items-center justify-between py-1">
<span>${escape(data.name)}</span>
<span class="text-xs text-gray-400 font-mono">${escape(data.vendor_code || '')}</span>
</div>`;
},
item: (data, escape) => {
return `<div>${escape(data.name)}</div>`;
}
},
onChange: (value) => {
if (value) {
const vendor = this.vendorSelectInstance.options[value];
this.selectedVendor = vendor;
this.filters.vendor_id = value;
// Save to localStorage
localStorage.setItem('vendor_products_selected_vendor_id', value.toString());
} else {
this.selectedVendor = null;
this.filters.vendor_id = '';
// Clear from localStorage
localStorage.removeItem('vendor_products_selected_vendor_id');
}
this.pagination.page = 1;
this.loadProducts();
this.loadStats();
}
});
adminVendorProductsLog.info('Vendor select initialized');
},
/**
* Clear vendor filter
*/
clearVendorFilter() {
if (this.vendorSelectInstance) {
this.vendorSelectInstance.clear();
}
this.selectedVendor = null;
this.filters.vendor_id = '';
// Clear from localStorage
localStorage.removeItem('vendor_products_selected_vendor_id');
this.pagination.page = 1;
this.loadProducts();
this.loadStats();
},
/**
* Load product statistics
*/
async loadStats() {
try {
const params = new URLSearchParams();
if (this.filters.vendor_id) {
params.append('vendor_id', this.filters.vendor_id);
}
const url = params.toString() ? `/admin/vendor-products/stats?${params}` : '/admin/vendor-products/stats';
const response = await apiClient.get(url);
this.stats = response;
adminVendorProductsLog.info('Loaded stats:', this.stats);
} catch (error) {
adminVendorProductsLog.error('Failed to load stats:', error);
}
},
/**
* Load products with filtering and pagination
*/
async loadProducts() {
this.loading = true;
this.error = '';
try {
const params = new URLSearchParams({
skip: (this.pagination.page - 1) * this.pagination.per_page,
limit: this.pagination.per_page
});
// Add filters
if (this.filters.search) {
params.append('search', this.filters.search);
}
if (this.filters.vendor_id) {
params.append('vendor_id', this.filters.vendor_id);
}
if (this.filters.is_active !== '') {
params.append('is_active', this.filters.is_active);
}
if (this.filters.is_featured !== '') {
params.append('is_featured', this.filters.is_featured);
}
const response = await apiClient.get(`/admin/vendor-products?${params.toString()}`);
this.products = response.products || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
adminVendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total);
} catch (error) {
adminVendorProductsLog.error('Failed to load products:', error);
this.error = error.message || 'Failed to load products';
} finally {
this.loading = false;
}
},
/**
* Debounced search handler
*/
debouncedSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadProducts();
}, 300);
},
/**
* Refresh products list
*/
async refresh() {
await Promise.all([
this.loadStats(),
this.loadVendors(),
this.loadProducts()
]);
},
/**
* View product details - navigate to detail page
*/
viewProduct(productId) {
adminVendorProductsLog.info('Navigating to product detail:', productId);
window.location.href = `/admin/vendor-products/${productId}`;
},
/**
* Show remove confirmation modal
*/
confirmRemove(product) {
this.productToRemove = product;
this.showRemoveModal = true;
},
/**
* Execute product removal from catalog
*/
async executeRemove() {
if (!this.productToRemove) return;
this.removing = true;
try {
await apiClient.delete(`/admin/vendor-products/${this.productToRemove.id}`);
adminVendorProductsLog.info('Removed product:', this.productToRemove.id);
// Close modal and refresh
this.showRemoveModal = false;
this.productToRemove = null;
// Show success notification
Utils.showToast('Product removed from vendor catalog.', 'success');
// Refresh the list
await this.refresh();
} catch (error) {
adminVendorProductsLog.error('Failed to remove product:', error);
this.error = error.message || 'Failed to remove product';
} finally {
this.removing = false;
}
},
/**
* Format price for display
*/
formatPrice(price, currency = 'EUR') {
if (price === null || price === undefined) return '-';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'EUR'
}).format(price);
},
/**
* Pagination: Previous page
*/
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
this.loadProducts();
}
},
/**
* Pagination: Next page
*/
nextPage() {
if (this.pagination.page < this.totalPages) {
this.pagination.page++;
this.loadProducts();
}
},
/**
* Pagination: Go to specific page
*/
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
this.pagination.page = pageNum;
this.loadProducts();
}
}
};
}

View File

@@ -0,0 +1,114 @@
// app/modules/catalog/static/vendor/js/product-create.js
/**
* Vendor product creation page logic
*/
const vendorProductCreateLog = window.LogConfig.loggers.vendorProductCreate ||
window.LogConfig.createLogger('vendorProductCreate', false);
vendorProductCreateLog.info('Loading...');
function vendorProductCreate() {
vendorProductCreateLog.info('vendorProductCreate() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'products',
// Back URL
get backUrl() {
return `/vendor/${this.vendorCode}/products`;
},
// Loading states
loading: false,
saving: false,
error: '',
// Form data
form: {
title: '',
brand: '',
vendor_sku: '',
gtin: '',
price: '',
currency: 'EUR',
availability: 'in_stock',
is_active: true,
is_featured: false,
is_digital: false,
description: ''
},
async init() {
// Guard against duplicate initialization
if (window._vendorProductCreateInitialized) return;
window._vendorProductCreateInitialized = true;
vendorProductCreateLog.info('Initializing product create page...');
try {
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
vendorProductCreateLog.info('Product create page initialized');
} catch (err) {
vendorProductCreateLog.error('Failed to initialize:', err);
this.error = err.message || 'Failed to initialize';
}
},
async createProduct() {
if (!this.form.title || !this.form.price) {
this.showToast('Title and price are required', 'error');
return;
}
this.saving = true;
this.error = '';
try {
// Create product directly (vendor_id from JWT token)
const response = await apiClient.post('/vendor/products/create', {
title: this.form.title,
brand: this.form.brand || null,
vendor_sku: this.form.vendor_sku || null,
gtin: this.form.gtin || null,
price: parseFloat(this.form.price),
currency: this.form.currency,
availability: this.form.availability,
is_active: this.form.is_active,
is_featured: this.form.is_featured,
description: this.form.description || null
});
if (!response.ok) {
throw new Error(response.message || 'Failed to create product');
}
vendorProductCreateLog.info('Product created:', response.data);
this.showToast('Product created successfully', 'success');
// Navigate back to products list
setTimeout(() => {
window.location.href = this.backUrl;
}, 1000);
} catch (err) {
vendorProductCreateLog.error('Failed to create product:', err);
this.error = err.message || 'Failed to create product';
this.showToast(this.error, 'error');
} finally {
this.saving = false;
}
}
};
}
vendorProductCreateLog.info('Loaded successfully');

View File

@@ -0,0 +1,548 @@
// app/modules/catalog/static/vendor/js/products.js
/**
* Vendor products management page logic
* View, edit, and manage vendor's product catalog
*/
const vendorProductsLog = window.LogConfig.loggers.vendorProducts ||
window.LogConfig.createLogger('vendorProducts', false);
vendorProductsLog.info('Loading...');
function vendorProducts() {
vendorProductsLog.info('vendorProducts() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'products',
// Loading states
loading: true,
error: '',
saving: false,
// Products data
products: [],
stats: {
total: 0,
active: 0,
inactive: 0,
featured: 0
},
// Filters
filters: {
search: '',
status: '', // 'active', 'inactive', ''
featured: '' // 'true', 'false', ''
},
// Pagination
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
// Modal states
showDeleteModal: false,
showDetailModal: false,
showBulkDeleteModal: false,
selectedProduct: null,
// Bulk selection
selectedProducts: [],
// Debounce timer
searchTimeout: null,
// Computed: Total pages
get totalPages() {
return this.pagination.pages;
},
// Computed: Start index for pagination display
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
// Computed: End index for pagination display
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
// Computed: Page numbers for pagination
get pageNumbers() {
const pages = [];
const totalPages = this.totalPages;
const current = this.pagination.page;
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (current > 3) pages.push('...');
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
return pages;
},
// Computed: Check if all visible products are selected
get allSelected() {
return this.products.length > 0 && this.selectedProducts.length === this.products.length;
},
// Computed: Check if some but not all products are selected
get someSelected() {
return this.selectedProducts.length > 0 && this.selectedProducts.length < this.products.length;
},
async init() {
vendorProductsLog.info('Products init() called');
// Guard against multiple initialization
if (window._vendorProductsInitialized) {
vendorProductsLog.warn('Already initialized, skipping');
return;
}
window._vendorProductsInitialized = true;
// IMPORTANT: Call parent init first to set vendorCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Load platform settings for rows per page
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
try {
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Init failed:', error);
this.error = 'Failed to initialize products page';
}
vendorProductsLog.info('Products initialization complete');
},
/**
* Load products with filtering and pagination
*/
async loadProducts() {
this.loading = true;
this.error = '';
try {
const params = new URLSearchParams({
skip: (this.pagination.page - 1) * this.pagination.per_page,
limit: this.pagination.per_page
});
// Add filters
if (this.filters.search) {
params.append('search', this.filters.search);
}
if (this.filters.status) {
params.append('is_active', this.filters.status === 'active');
}
if (this.filters.featured) {
params.append('is_featured', this.filters.featured === 'true');
}
const response = await apiClient.get(`/vendor/products?${params.toString()}`);
this.products = response.products || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
// Calculate stats from response or products
this.stats = {
total: response.total || this.products.length,
active: this.products.filter(p => p.is_active).length,
inactive: this.products.filter(p => !p.is_active).length,
featured: this.products.filter(p => p.is_featured).length
};
vendorProductsLog.info('Loaded products:', this.products.length, 'of', this.pagination.total);
} catch (error) {
vendorProductsLog.error('Failed to load products:', error);
this.error = error.message || 'Failed to load products';
} finally {
this.loading = false;
}
},
/**
* Debounced search handler
*/
debouncedSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadProducts();
}, 300);
},
/**
* Apply filter and reload
*/
applyFilter() {
this.pagination.page = 1;
this.loadProducts();
},
/**
* Clear all filters
*/
clearFilters() {
this.filters = {
search: '',
status: '',
featured: ''
};
this.pagination.page = 1;
this.loadProducts();
},
/**
* Toggle product active status
*/
async toggleActive(product) {
this.saving = true;
try {
await apiClient.put(`/vendor/products/${product.id}/toggle-active`);
product.is_active = !product.is_active;
Utils.showToast(
product.is_active ? 'Product activated' : 'Product deactivated',
'success'
);
vendorProductsLog.info('Toggled product active:', product.id, product.is_active);
} catch (error) {
vendorProductsLog.error('Failed to toggle active:', error);
Utils.showToast(error.message || 'Failed to update product', 'error');
} finally {
this.saving = false;
}
},
/**
* Toggle product featured status
*/
async toggleFeatured(product) {
this.saving = true;
try {
await apiClient.put(`/vendor/products/${product.id}/toggle-featured`);
product.is_featured = !product.is_featured;
Utils.showToast(
product.is_featured ? 'Product marked as featured' : 'Product unmarked as featured',
'success'
);
vendorProductsLog.info('Toggled product featured:', product.id, product.is_featured);
} catch (error) {
vendorProductsLog.error('Failed to toggle featured:', error);
Utils.showToast(error.message || 'Failed to update product', 'error');
} finally {
this.saving = false;
}
},
/**
* View product details
*/
viewProduct(product) {
this.selectedProduct = product;
this.showDetailModal = true;
},
/**
* Confirm delete product
*/
confirmDelete(product) {
this.selectedProduct = product;
this.showDeleteModal = true;
},
/**
* Execute delete product
*/
async deleteProduct() {
if (!this.selectedProduct) return;
this.saving = true;
try {
await apiClient.delete(`/vendor/products/${this.selectedProduct.id}`);
Utils.showToast('Product deleted successfully', 'success');
vendorProductsLog.info('Deleted product:', this.selectedProduct.id);
this.showDeleteModal = false;
this.selectedProduct = null;
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Failed to delete product:', error);
Utils.showToast(error.message || 'Failed to delete product', 'error');
} finally {
this.saving = false;
}
},
/**
* Navigate to edit product page
*/
editProduct(product) {
window.location.href = `/vendor/${this.vendorCode}/products/${product.id}/edit`;
},
/**
* Navigate to create product page
*/
createProduct() {
window.location.href = `/vendor/${this.vendorCode}/products/create`;
},
/**
* Format price for display
*/
formatPrice(cents) {
if (!cents && cents !== 0) return '-';
const locale = window.VENDOR_CONFIG?.locale || 'en-GB';
const currency = window.VENDOR_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(cents / 100);
},
/**
* Pagination: Previous page
*/
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
this.loadProducts();
}
},
/**
* Pagination: Next page
*/
nextPage() {
if (this.pagination.page < this.totalPages) {
this.pagination.page++;
this.loadProducts();
}
},
/**
* Pagination: Go to specific page
*/
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
this.pagination.page = pageNum;
this.loadProducts();
}
},
// ============================================================================
// BULK OPERATIONS
// ============================================================================
/**
* Toggle select all products on current page
*/
toggleSelectAll() {
if (this.allSelected) {
this.selectedProducts = [];
} else {
this.selectedProducts = this.products.map(p => p.id);
}
},
/**
* Toggle selection of a single product
*/
toggleSelect(productId) {
const index = this.selectedProducts.indexOf(productId);
if (index === -1) {
this.selectedProducts.push(productId);
} else {
this.selectedProducts.splice(index, 1);
}
},
/**
* Check if product is selected
*/
isSelected(productId) {
return this.selectedProducts.includes(productId);
},
/**
* Clear all selections
*/
clearSelection() {
this.selectedProducts = [];
},
/**
* Bulk activate selected products
*/
async bulkActivate() {
if (this.selectedProducts.length === 0) return;
this.saving = true;
try {
let successCount = 0;
for (const productId of this.selectedProducts) {
const product = this.products.find(p => p.id === productId);
if (product && !product.is_active) {
await apiClient.put(`/vendor/products/${productId}/toggle-active`);
product.is_active = true;
successCount++;
}
}
Utils.showToast(`${successCount} product(s) activated`, 'success');
this.clearSelection();
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Bulk activate failed:', error);
Utils.showToast(error.message || 'Failed to activate products', 'error');
} finally {
this.saving = false;
}
},
/**
* Bulk deactivate selected products
*/
async bulkDeactivate() {
if (this.selectedProducts.length === 0) return;
this.saving = true;
try {
let successCount = 0;
for (const productId of this.selectedProducts) {
const product = this.products.find(p => p.id === productId);
if (product && product.is_active) {
await apiClient.put(`/vendor/products/${productId}/toggle-active`);
product.is_active = false;
successCount++;
}
}
Utils.showToast(`${successCount} product(s) deactivated`, 'success');
this.clearSelection();
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Bulk deactivate failed:', error);
Utils.showToast(error.message || 'Failed to deactivate products', 'error');
} finally {
this.saving = false;
}
},
/**
* Bulk set featured on selected products
*/
async bulkSetFeatured() {
if (this.selectedProducts.length === 0) return;
this.saving = true;
try {
let successCount = 0;
for (const productId of this.selectedProducts) {
const product = this.products.find(p => p.id === productId);
if (product && !product.is_featured) {
await apiClient.put(`/vendor/products/${productId}/toggle-featured`);
product.is_featured = true;
successCount++;
}
}
Utils.showToast(`${successCount} product(s) marked as featured`, 'success');
this.clearSelection();
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Bulk set featured failed:', error);
Utils.showToast(error.message || 'Failed to update products', 'error');
} finally {
this.saving = false;
}
},
/**
* Bulk remove featured from selected products
*/
async bulkRemoveFeatured() {
if (this.selectedProducts.length === 0) return;
this.saving = true;
try {
let successCount = 0;
for (const productId of this.selectedProducts) {
const product = this.products.find(p => p.id === productId);
if (product && product.is_featured) {
await apiClient.put(`/vendor/products/${productId}/toggle-featured`);
product.is_featured = false;
successCount++;
}
}
Utils.showToast(`${successCount} product(s) unmarked as featured`, 'success');
this.clearSelection();
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Bulk remove featured failed:', error);
Utils.showToast(error.message || 'Failed to update products', 'error');
} finally {
this.saving = false;
}
},
/**
* Confirm bulk delete
*/
confirmBulkDelete() {
if (this.selectedProducts.length === 0) return;
this.showBulkDeleteModal = true;
},
/**
* Execute bulk delete
*/
async bulkDelete() {
if (this.selectedProducts.length === 0) return;
this.saving = true;
try {
let successCount = 0;
for (const productId of this.selectedProducts) {
await apiClient.delete(`/vendor/products/${productId}`);
successCount++;
}
Utils.showToast(`${successCount} product(s) deleted`, 'success');
this.showBulkDeleteModal = false;
this.clearSelection();
await this.loadProducts();
} catch (error) {
vendorProductsLog.error('Bulk delete failed:', error);
Utils.showToast(error.message || 'Failed to delete products', 'error');
} finally {
this.saving = false;
}
}
};
}