refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -1,307 +0,0 @@
// static/shared/js/media-picker.js
/**
* Media Picker Helper Functions
*
* Provides Alpine.js mixin for media library picker functionality.
* Used in product create/edit forms to select images from vendor's media library.
*
* Usage:
* In your Alpine component:
* return {
* ...mediaPickerMixin(vendorIdGetter, multiSelect),
* // your other data/methods
* }
*/
// Use centralized logger
const mediaPickerLog = window.LogConfig.loggers.mediaPicker ||
window.LogConfig.createLogger('mediaPicker', false);
/**
* Create media picker mixin for Alpine.js components
*
* @param {Function} vendorIdGetter - Function that returns the current vendor ID
* @param {boolean} multiSelect - Allow selecting multiple images
* @returns {Object} Alpine.js mixin object
*/
function mediaPickerMixin(vendorIdGetter, multiSelect = false) {
return {
// Modal visibility
showMediaPicker: false,
showMediaPickerAdditional: false,
// Picker state
mediaPickerState: {
loading: false,
uploading: false,
media: [],
selected: [],
total: 0,
skip: 0,
limit: 24,
search: '',
},
// Which picker is active (main or additional)
activePickerTarget: 'main',
/**
* Open media picker for main image
*/
openMediaPickerMain() {
this.activePickerTarget = 'main';
this.mediaPickerState.selected = [];
this.showMediaPicker = true;
},
/**
* Open media picker for additional images
*/
openMediaPickerAdditional() {
this.activePickerTarget = 'additional';
this.mediaPickerState.selected = [];
this.showMediaPickerAdditional = true;
},
/**
* Load media library from API
*/
async loadMediaLibrary() {
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
if (!vendorId) {
mediaPickerLog.warn('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) {
mediaPickerLog.error('Failed to load media library:', error);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Failed to load media library', type: 'error' }
}));
} finally {
this.mediaPickerState.loading = false;
}
},
/**
* Load more media (pagination)
*/
async loadMoreMedia() {
const vendorId = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
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) {
mediaPickerLog.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 = typeof vendorIdGetter === 'function' ? vendorIdGetter() : vendorIdGetter;
if (!vendorId) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Please select a vendor first', type: 'error' }
}));
return;
}
// Validate file type
if (!file.type.startsWith('image/')) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Please select an image file', type: 'error' }
}));
return;
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Image must be less than 10MB', type: '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) {
// Add to beginning of media list
this.mediaPickerState.media.unshift(response.media);
this.mediaPickerState.total++;
// Auto-select the uploaded image
this.toggleMediaSelection(response.media);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Image uploaded successfully', type: 'success' }
}));
}
} catch (error) {
mediaPickerLog.error('Failed to upload image:', error);
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: error.message || 'Failed to upload image', type: 'error' }
}));
} finally {
this.mediaPickerState.uploading = false;
// Clear the file input
event.target.value = '';
}
},
/**
* Toggle media selection
*/
toggleMediaSelection(media) {
const index = this.mediaPickerState.selected.findIndex(m => m.id === media.id);
if (index > -1) {
// Deselect
this.mediaPickerState.selected.splice(index, 1);
} else {
if (multiSelect) {
// Multi-select: add to selection
this.mediaPickerState.selected.push(media);
} else {
// Single-select: replace selection
this.mediaPickerState.selected = [media];
}
}
},
/**
* Check if media is selected
*/
isMediaSelected(mediaId) {
return this.mediaPickerState.selected.some(m => m.id === mediaId);
},
/**
* Confirm selection and call the appropriate callback
*/
confirmMediaSelection() {
const selected = this.mediaPickerState.selected;
if (selected.length === 0) return;
if (this.activePickerTarget === 'main') {
// Main image: use first selected
this.setMainImage(selected[0]);
this.showMediaPicker = false;
} else {
// Additional images: add all selected
this.addAdditionalImages(selected);
this.showMediaPickerAdditional = false;
}
// Clear selection
this.mediaPickerState.selected = [];
},
/**
* Set the main image (override in your component)
*/
setMainImage(media) {
if (this.form) {
this.form.primary_image_url = media.url;
}
mediaPickerLog.info('Main image set:', media.url);
},
/**
* Add additional images (override in your component)
*/
addAdditionalImages(mediaList) {
if (this.form && Array.isArray(this.form.additional_images)) {
const newUrls = mediaList.map(m => m.url);
this.form.additional_images = [
...this.form.additional_images,
...newUrls
];
}
mediaPickerLog.info('Additional images added:', mediaList.map(m => m.url));
},
/**
* Remove an additional image by index
*/
removeAdditionalImage(index) {
if (this.form && Array.isArray(this.form.additional_images)) {
this.form.additional_images.splice(index, 1);
}
},
/**
* Clear the main image
*/
clearMainImage() {
if (this.form) {
this.form.primary_image_url = '';
}
},
};
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = { mediaPickerMixin };
}

View File

@@ -1,243 +0,0 @@
// static/shared/js/vendor-selector.js
/**
* Shared Vendor Selector Module
* =============================
* Provides a reusable Tom Select-based vendor autocomplete component.
*
* Features:
* - Async search with debouncing (150ms)
* - Searches by vendor name and code
* - Dark mode support
* - Caches recent searches
* - Graceful fallback if Tom Select not available
*
* Usage:
* // In Alpine.js component init():
* this.$nextTick(() => {
* this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
* onSelect: (vendor) => this.handleVendorSelect(vendor),
* onClear: () => this.handleVendorClear(),
* minChars: 2,
* maxOptions: 50
* });
* });
*
* // To programmatically set a value:
* this.vendorSelector.setValue(vendorId);
*
* // To clear:
* this.vendorSelector.clear();
*/
const vendorSelectorLog = window.LogConfig?.loggers?.vendorSelector ||
window.LogConfig?.createLogger?.('vendorSelector', false) ||
{ info: console.log, warn: console.warn, error: console.error }; // noqa: js-001 - fallback if logger not ready
/**
* Check if Tom Select is available, with retry logic
* @param {Function} callback - Called when Tom Select is available
* @param {number} maxRetries - Maximum retry attempts
* @param {number} retryDelay - Delay between retries in ms
*/
function waitForTomSelect(callback, maxRetries = 20, retryDelay = 100) {
let retries = 0;
function check() {
if (typeof TomSelect !== 'undefined') {
callback();
} else if (retries < maxRetries) {
retries++;
vendorSelectorLog.info(`Waiting for TomSelect... (attempt ${retries}/${maxRetries})`);
setTimeout(check, retryDelay);
} else {
vendorSelectorLog.error('TomSelect not available after maximum retries');
}
}
check();
}
/**
* Initialize a vendor selector on the given element
* @param {HTMLElement} selectElement - The select element to enhance
* @param {Object} options - Configuration options
* @param {Function} options.onSelect - Callback when vendor is selected (receives vendor object)
* @param {Function} options.onClear - Callback when selection is cleared
* @param {number} options.minChars - Minimum characters before search (default: 2)
* @param {number} options.maxOptions - Maximum options to show (default: 50)
* @param {string} options.placeholder - Placeholder text
* @param {string} options.apiEndpoint - API endpoint for search (default: '/admin/vendors')
* @returns {Object} Controller object with setValue() and clear() methods
*/
function initVendorSelector(selectElement, options = {}) {
if (!selectElement) {
vendorSelectorLog.error('Vendor selector element not provided');
return null;
}
const config = {
minChars: options.minChars || 2,
maxOptions: options.maxOptions || 50,
placeholder: options.placeholder || selectElement.getAttribute('placeholder') || 'Search vendor by name or code...',
apiEndpoint: options.apiEndpoint || '/admin/vendors', // Note: apiClient adds /api/v1 prefix
onSelect: options.onSelect || (() => {}),
onClear: options.onClear || (() => {})
};
let tomSelectInstance = null;
// Controller object returned to caller
const controller = {
/**
* Set the selected vendor by ID
* @param {number} vendorId - Vendor ID to select
* @param {Object} vendorData - Optional vendor data to avoid API call
*/
setValue: async function(vendorId, vendorData = null) {
if (!tomSelectInstance) return;
if (vendorData) {
// Add option and set value
tomSelectInstance.addOption({
id: vendorData.id,
name: vendorData.name,
vendor_code: vendorData.vendor_code
});
tomSelectInstance.setValue(vendorData.id, true);
} else if (vendorId) {
// Fetch vendor data and set
try {
const response = await apiClient.get(`${config.apiEndpoint}/${vendorId}`);
tomSelectInstance.addOption({
id: response.id,
name: response.name,
vendor_code: response.vendor_code
});
tomSelectInstance.setValue(response.id, true);
} catch (error) {
vendorSelectorLog.error('Failed to load vendor:', error);
}
}
},
/**
* Clear the selection
*/
clear: function() {
if (tomSelectInstance) {
tomSelectInstance.clear();
}
},
/**
* Get the Tom Select instance
*/
getInstance: function() {
return tomSelectInstance;
},
/**
* Destroy the Tom Select instance
*/
destroy: function() {
if (tomSelectInstance) {
tomSelectInstance.destroy();
tomSelectInstance = null;
}
}
};
// Initialize Tom Select when available
waitForTomSelect(() => {
vendorSelectorLog.info('Initializing vendor selector');
tomSelectInstance = new TomSelect(selectElement, {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
maxOptions: config.maxOptions,
placeholder: config.placeholder,
// Async search with debouncing
load: async function(query, callback) {
if (query.length < config.minChars) {
callback([]);
return;
}
try {
const response = await apiClient.get(
`${config.apiEndpoint}?search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
);
const vendors = (response.vendors || []).map(v => ({
id: v.id,
name: v.name,
vendor_code: v.vendor_code
}));
vendorSelectorLog.info(`Found ${vendors.length} vendors for "${query}"`);
callback(vendors);
} catch (error) {
vendorSelectorLog.error('Vendor search failed:', error);
callback([]);
}
},
// Custom rendering
render: {
option: function(data, escape) {
return `<div class="flex justify-between items-center py-1">
<span class="font-medium">${escape(data.name)}</span>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">${escape(data.vendor_code)}</span>
</div>`;
},
item: function(data, escape) {
return `<div class="flex items-center gap-2">
<span>${escape(data.name)}</span>
<span class="text-xs text-gray-400 font-mono">(${escape(data.vendor_code)})</span>
</div>`;
},
no_results: function() {
return '<div class="no-results py-2 px-3 text-gray-500 dark:text-gray-400">No vendors found</div>';
},
loading: function() {
return '<div class="loading py-2 px-3 text-gray-500 dark:text-gray-400">Searching...</div>';
}
},
// Event handlers
onChange: function(value) {
if (value) {
const selectedOption = this.options[value];
if (selectedOption) {
vendorSelectorLog.info('Vendor selected:', selectedOption);
config.onSelect({
id: parseInt(value),
name: selectedOption.name,
vendor_code: selectedOption.vendor_code
});
}
} else {
vendorSelectorLog.info('Vendor selection cleared');
config.onClear();
}
},
// Performance settings
loadThrottle: 150, // Debounce search requests
closeAfterSelect: true,
hideSelected: false,
persist: true, // Cache options
createOnBlur: false,
create: false
});
vendorSelectorLog.info('Vendor selector initialized');
});
return controller;
}
// Export to window for global access
window.initVendorSelector = initVendorSelector;