feat: make Product fully independent from MarketplaceProduct
- Add is_digital and product_type columns to Product model - Remove is_digital/product_type properties that derived from MarketplaceProduct - Update Create form with translation tabs, GTIN type, sale price, VAT rate, image - Update Edit form to allow editing is_digital (remove disabled state) - Add Availability field to Edit form - Fix Detail page for directly created products (no marketplace source) - Update vendor_product_service to handle new fields in create/update - Add VendorProductCreate/Update schema fields for translations and is_digital - Add unit tests for is_digital column and direct product creation - Add integration tests for create/update API with new fields - Create product-architecture.md documenting the independent copy pattern - Add migration y3d4e5f6g7h8 for is_digital and product_type columns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// static/admin/js/vendor-product-create.js
|
||||
/**
|
||||
* Admin vendor product create page logic
|
||||
* Create new vendor product entries
|
||||
* Create new vendor product entries with translations
|
||||
*/
|
||||
|
||||
const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate ||
|
||||
@@ -12,6 +12,14 @@ 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(),
|
||||
@@ -26,20 +34,31 @@ function adminVendorProductCreate() {
|
||||
// Tom Select instance
|
||||
vendorSelectInstance: null,
|
||||
|
||||
// Active language tab
|
||||
activeLanguage: 'en',
|
||||
|
||||
// Form data
|
||||
form: {
|
||||
vendor_id: null,
|
||||
title: '',
|
||||
brand: '',
|
||||
// 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: '',
|
||||
// Image
|
||||
primary_image_url: '',
|
||||
// Status
|
||||
is_active: true,
|
||||
is_featured: false,
|
||||
is_digital: false,
|
||||
description: ''
|
||||
is_digital: false
|
||||
},
|
||||
|
||||
async init() {
|
||||
@@ -111,6 +130,33 @@ function adminVendorProductCreate() {
|
||||
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
|
||||
*/
|
||||
@@ -120,30 +166,54 @@ function adminVendorProductCreate() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.form.title) {
|
||||
Utils.showToast('Please enter a product title', 'error');
|
||||
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,
|
||||
title: this.form.title,
|
||||
brand: this.form.brand || null,
|
||||
vendor_sku: this.form.vendor_sku || null,
|
||||
gtin: this.form.gtin || null,
|
||||
price: this.form.price ? parseFloat(this.form.price) : null,
|
||||
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,
|
||||
// Image
|
||||
primary_image_url: this.form.primary_image_url?.trim() || null,
|
||||
// Status
|
||||
is_active: this.form.is_active,
|
||||
is_featured: this.form.is_featured,
|
||||
is_digital: this.form.is_digital,
|
||||
description: this.form.description || null
|
||||
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);
|
||||
|
||||
@@ -59,6 +59,7 @@ function adminVendorProductEdit() {
|
||||
sale_price: null,
|
||||
currency: 'EUR',
|
||||
tax_rate_percent: 17,
|
||||
availability: '',
|
||||
// Image
|
||||
primary_image_url: '',
|
||||
// Product type & status
|
||||
@@ -125,6 +126,7 @@ function adminVendorProductEdit() {
|
||||
sale_price: response.sale_price || null,
|
||||
currency: response.currency || 'EUR',
|
||||
tax_rate_percent: response.tax_rate_percent ?? 17,
|
||||
availability: response.availability || '',
|
||||
// Image
|
||||
primary_image_url: response.primary_image_url || '',
|
||||
// Product type & status
|
||||
@@ -238,9 +240,11 @@ function adminVendorProductEdit() {
|
||||
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,
|
||||
// Image
|
||||
primary_image_url: this.form.primary_image_url?.trim() || null,
|
||||
// Status (is_digital is derived from marketplace product, not editable)
|
||||
// Status
|
||||
is_digital: this.form.is_digital,
|
||||
is_active: this.form.is_active,
|
||||
is_featured: this.form.is_featured,
|
||||
// Optional supplier info
|
||||
|
||||
114
static/vendor/js/product-create.js
vendored
Normal file
114
static/vendor/js/product-create.js
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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');
|
||||
Reference in New Issue
Block a user