feat(monitoring): add Redis exporter + Sentry docs to deployment guide
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 47m30s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- Add redis-exporter container to docker-compose (oliver006/redis_exporter, 32MB)
- Add Redis scrape target to Prometheus config
- Add 4 Redis alert rules: RedisDown, HighMemory, HighConnections, RejectedConnections
- Document Step 19b (Sentry Error Tracking) in Hetzner deployment guide
- Document Step 19c (Redis Monitoring) in Hetzner deployment guide
- Update resource budget and port reference tables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 23:30:18 +01:00
parent ce822af883
commit 35d1559162
54 changed files with 664 additions and 343 deletions

View File

@@ -191,15 +191,15 @@ Component Structure:
return {
// ✅ CRITICAL: Inherit base layout state
...data(),
// ✅ CRITICAL: Set page identifier
currentPage: 'dashboard',
// Page-specific state
loading: false,
error: null,
stats: [],
// Initialization
async init() {
// Guard against multiple initialization
@@ -208,10 +208,10 @@ Component Structure:
return;
}
window._dashboardInitialized = true;
await this.loadStats();
},
// Data loading
async loadStats() {
this.loading = true;
@@ -294,22 +294,22 @@ init-alpine.js provides:
// Theme state
dark: localStorage.getItem('theme') === 'dark',
toggleTheme() { /* ... */ },
// Side menu state
isSideMenuOpen: false,
toggleSideMenu() { /* ... */ },
closeSideMenu() { /* ... */ },
// Profile menu state
isProfileMenuOpen: false,
toggleProfileMenu() { /* ... */ },
closeProfileMenu() { /* ... */ },
// Notifications menu state
isNotificationsMenuOpen: false,
toggleNotificationsMenu() { /* ... */ },
closeNotificationsMenu() { /* ... */ },
// Page identifier (override in each page)
currentPage: ''
};
@@ -320,7 +320,7 @@ Your page inherits ALL of this:
return {
...data(), // ← Spreads all base functionality
currentPage: 'stores', // ← Override page identifier
// Your page-specific state
stores: [],
loading: false
@@ -446,10 +446,10 @@ Pattern:
dashLog.warn('Already initialized, skipping...');
return; // Exit early
}
// Set flag BEFORE async operations
window._dashboardInitialized = true;
// Safe to proceed
await this.loadData();
}
@@ -485,7 +485,7 @@ Pre-configured Loggers:
Usage:
// Use pre-configured logger
const dashLog = window.LogConfig.loggers.dashboard;
dashLog.info('Dashboard loading...');
dashLog.error('Failed to load stats', error);
dashLog.debug('Stats data:', statsData);
@@ -497,13 +497,13 @@ Advanced Features:
dashLog.info('Fetching stats...');
dashLog.info('Fetching activity...');
dashLog.groupEnd();
// API call logging
window.LogConfig.logApiCall('GET', url, data, 'response');
// Performance logging
window.LogConfig.logPerformance('Load Stats', duration);
// Error logging
window.LogConfig.logError(error, 'Load Stats');
@@ -527,16 +527,16 @@ CRITICAL: Always use lowercase 'apiClient'
Usage:
const data = await apiClient.get('/api/v1/admin/stores');
await apiClient.post('/api/v1/admin/stores', {
name: 'New Store',
code: 'NEWSTORE'
});
await apiClient.put('/api/v1/admin/stores/123', {
name: 'Updated Name'
});
await apiClient.delete('/api/v1/admin/stores/123');
Features:

View File

@@ -60,7 +60,7 @@ app/
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
[Page Name]
</h2>
<!-- Action Buttons -->
<div class="flex items-center space-x-3">
<button
@@ -72,7 +72,7 @@ app/
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
<button
@click="openCreateModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@@ -94,7 +94,7 @@ app/
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- ERROR STATE -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div x-show="error && !loading"
<div x-show="error && !loading"
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start dark:bg-red-900/20 dark:border-red-800 dark:text-red-200">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
@@ -120,7 +120,7 @@ app/
/>
</label>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm">
@@ -136,7 +136,7 @@ app/
</select>
</label>
</div>
<!-- Sort -->
<div>
<label class="block text-sm">
@@ -190,15 +190,15 @@ app/
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="item.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400"
<p class="text-xs text-gray-600 dark:text-gray-400"
x-text="item.description"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="item.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
:class="item.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
x-text="item.is_active ? 'Active' : 'Inactive'"></span>
</td>
@@ -234,12 +234,12 @@ app/
</tbody>
</table>
</div>
<!-- Pagination -->
<div x-show="pagination.totalPages > 1"
class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
Showing
Showing
<span x-text="pagination.from"></span>
-
<span x-text="pagination.to"></span>
@@ -261,20 +261,20 @@ app/
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
</button>
</li>
<template x-for="page in paginationRange" :key="page">
<li>
<button
@click="goToPage(page)"
:class="page === pagination.currentPage
? 'text-white bg-purple-600'
:class="page === pagination.currentPage
? 'text-white bg-purple-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'"
class="px-3 py-1 rounded-md focus:outline-none focus:shadow-outline-purple"
x-text="page"
></button>
</li>
</template>
<li>
<button
@click="nextPage()"
@@ -299,9 +299,9 @@ app/
class="fixed inset-0 z-50 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.away="closeModal()"
@keydown.escape.window="closeModal()">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl">
<!-- Modal header -->
<div class="flex items-center justify-between pb-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
@@ -313,11 +313,11 @@ app/
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<!-- Modal body -->
<div class="mt-4">
<form @submit.prevent="saveItem()">
<!-- Name Field -->
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Name</span>
@@ -327,11 +327,11 @@ app/
:class="errors.name ? 'border-red-600' : ''"
placeholder="Enter name"
/>
<span x-show="errors.name"
class="text-xs text-red-600 dark:text-red-400"
<span x-show="errors.name"
class="text-xs text-red-600 dark:text-red-400"
x-text="errors.name"></span>
</label>
<!-- Description Field -->
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Description</span>
@@ -342,7 +342,7 @@ app/
placeholder="Enter description"
></textarea>
</label>
<!-- Status Field -->
<label class="block text-sm mb-4">
<span class="text-gray-700 dark:text-gray-400">Status</span>
@@ -354,7 +354,7 @@ app/
<option :value="false">Inactive</option>
</select>
</label>
<!-- Modal footer -->
<div class="flex flex-col items-center justify-end px-6 py-3 -mx-6 -mb-4 space-y-4 sm:space-y-0 sm:space-x-6 sm:flex-row bg-gray-50 dark:bg-gray-800">
<button
@@ -413,26 +413,26 @@ function admin[PageName]() {
// ✅ CRITICAL: INHERIT BASE LAYOUT
// ─────────────────────────────────────────────────────
...data(),
// ✅ CRITICAL: SET PAGE IDENTIFIER
currentPage: '[page-name]',
// ─────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────
// Loading states
loading: false,
saving: false,
// Error handling
error: null,
errors: {},
// Data
items: [],
currentItem: null,
// Pagination
pagination: {
currentPage: 1,
@@ -442,14 +442,14 @@ function admin[PageName]() {
from: 0,
to: 0
},
// Filters
filters: {
search: '',
status: '',
sortBy: 'created_at:desc'
},
// Modal
showModal: false,
modalMode: 'create', // 'create' or 'edit'
@@ -458,47 +458,47 @@ function admin[PageName]() {
description: '',
is_active: true
},
// ─────────────────────────────────────────────────────
// LIFECYCLE
// ─────────────────────────────────────────────────────
/**
* ✅ CRITICAL: Initialize component with guard
*/
async init() {
pageLog.info('=== [PAGE NAME] INITIALIZING ===');
// ✅ CRITICAL: Initialization guard
if (window._[pageName]Initialized) {
pageLog.warn('Already initialized, skipping...');
return;
}
window._[pageName]Initialized = true;
// Track performance
const startTime = performance.now();
try {
// Load initial data
await this.loadData();
// Log performance
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('[Page Name] Init', duration);
pageLog.info('=== [PAGE NAME] INITIALIZATION COMPLETE ===');
} catch (error) {
window.LogConfig.logError(error, '[Page Name] Init');
this.error = 'Failed to initialize page';
}
},
// ─────────────────────────────────────────────────────
// DATA LOADING
// ─────────────────────────────────────────────────────
/**
* Load main data from API
*/
@@ -506,10 +506,10 @@ function admin[PageName]() {
pageLog.info('Loading data...');
this.loading = true;
this.error = null;
try {
const startTime = performance.now();
// Build query params
const params = new URLSearchParams({
page: this.pagination.currentPage,
@@ -518,18 +518,18 @@ function admin[PageName]() {
status: this.filters.status,
sort_by: this.filters.sortBy
});
const url = `/api/v1/admin/items?${params}`;
// Log API request
window.LogConfig.logApiCall('GET', url, null, 'request');
// ✅ CRITICAL: Use lowercase apiClient
const response = await apiClient.get(url);
// Log API response
window.LogConfig.logApiCall('GET', url, response, 'response');
// Update state
this.items = response.items || [];
this.pagination.total = response.total || 0;
@@ -541,17 +541,17 @@ function admin[PageName]() {
this.pagination.currentPage * this.pagination.perPage,
this.pagination.total
);
// Log performance
const duration = performance.now() - startTime;
window.LogConfig.logPerformance('Load Data', duration);
pageLog.info(`Data loaded successfully`, {
count: this.items.length,
duration: `${duration}ms`,
page: this.pagination.currentPage
});
} catch (error) {
window.LogConfig.logError(error, 'Load Data');
this.error = error.message || 'Failed to load data';
@@ -560,7 +560,7 @@ function admin[PageName]() {
this.loading = false;
}
},
/**
* Refresh data
*/
@@ -570,11 +570,11 @@ function admin[PageName]() {
await this.loadData();
Utils.showToast('Data refreshed', 'success');
},
// ─────────────────────────────────────────────────────
// FILTERS & SEARCH
// ─────────────────────────────────────────────────────
/**
* Apply filters and reload data
*/
@@ -583,7 +583,7 @@ function admin[PageName]() {
this.pagination.currentPage = 1; // Reset to first page
await this.loadData();
},
/**
* Reset filters to default
*/
@@ -595,11 +595,11 @@ function admin[PageName]() {
};
await this.applyFilters();
},
// ─────────────────────────────────────────────────────
// PAGINATION
// ─────────────────────────────────────────────────────
/**
* Navigate to specific page
*/
@@ -608,21 +608,21 @@ function admin[PageName]() {
this.pagination.currentPage = page;
await this.loadData();
},
/**
* Go to previous page
*/
async previousPage() {
await this.goToPage(this.pagination.currentPage - 1);
},
/**
* Go to next page
*/
async nextPage() {
await this.goToPage(this.pagination.currentPage + 1);
},
/**
* Get pagination range for display
*/
@@ -630,27 +630,27 @@ function admin[PageName]() {
const current = this.pagination.currentPage;
const total = this.pagination.totalPages;
const range = [];
// Show max 5 page numbers
let start = Math.max(1, current - 2);
let end = Math.min(total, start + 4);
// Adjust start if we're near the end
if (end - start < 4) {
start = Math.max(1, end - 4);
}
for (let i = start; i <= end; i++) {
range.push(i);
}
return range;
},
// ─────────────────────────────────────────────────────
// CRUD OPERATIONS
// ─────────────────────────────────────────────────────
/**
* Open create modal
*/
@@ -665,36 +665,36 @@ function admin[PageName]() {
this.errors = {};
this.showModal = true;
},
/**
* Open edit modal
*/
async editItem(itemId) {
pageLog.info('Opening edit modal for item:', itemId);
try {
this.modalMode = 'edit';
// Load item details
const url = `/api/v1/admin/items/${itemId}`;
window.LogConfig.logApiCall('GET', url, null, 'request');
const item = await apiClient.get(url);
window.LogConfig.logApiCall('GET', url, item, 'response');
// Populate form
this.formData = { ...item };
this.currentItem = item;
this.errors = {};
this.showModal = true;
} catch (error) {
window.LogConfig.logError(error, 'Edit Item');
Utils.showToast('Failed to load item', 'error');
}
},
/**
* View item details
*/
@@ -703,57 +703,57 @@ function admin[PageName]() {
// Navigate to detail page or open view modal
window.location.href = `/admin/items/${itemId}`;
},
/**
* Save item (create or update)
*/
async saveItem() {
pageLog.info('Saving item...');
// Validate form
if (!this.validateForm()) {
pageLog.warn('Form validation failed');
return;
}
this.saving = true;
this.errors = {};
try {
let response;
if (this.modalMode === 'create') {
// Create new item
const url = '/api/v1/admin/items';
window.LogConfig.logApiCall('POST', url, this.formData, 'request');
response = await apiClient.post(url, this.formData);
window.LogConfig.logApiCall('POST', url, response, 'response');
pageLog.info('Item created successfully');
Utils.showToast('Item created successfully', 'success');
} else {
// Update existing item
const url = `/api/v1/admin/items/${this.currentItem.id}`;
window.LogConfig.logApiCall('PUT', url, this.formData, 'request');
response = await apiClient.put(url, this.formData);
window.LogConfig.logApiCall('PUT', url, response, 'response');
pageLog.info('Item updated successfully');
Utils.showToast('Item updated successfully', 'success');
}
// Close modal and reload data
this.closeModal();
await this.loadData();
} catch (error) {
window.LogConfig.logError(error, 'Save Item');
// Handle validation errors
if (error.details && error.details.validation_errors) {
this.errors = error.details.validation_errors;
@@ -764,38 +764,38 @@ function admin[PageName]() {
this.saving = false;
}
},
/**
* Delete item
*/
async deleteItem(itemId) {
pageLog.info('Deleting item:', itemId);
// Confirm deletion
if (!confirm('Are you sure you want to delete this item?')) {
return;
}
try {
const url = `/api/v1/admin/items/${itemId}`;
window.LogConfig.logApiCall('DELETE', url, null, 'request');
await apiClient.delete(url);
window.LogConfig.logApiCall('DELETE', url, null, 'response');
pageLog.info('Item deleted successfully');
Utils.showToast('Item deleted successfully', 'success');
// Reload data
await this.loadData();
} catch (error) {
window.LogConfig.logError(error, 'Delete Item');
Utils.showToast('Failed to delete item', 'error');
}
},
/**
* Close modal
*/
@@ -805,30 +805,30 @@ function admin[PageName]() {
this.errors = {};
this.currentItem = null;
},
// ─────────────────────────────────────────────────────
// VALIDATION
// ─────────────────────────────────────────────────────
/**
* Validate form data
*/
validateForm() {
this.errors = {};
if (!this.formData.name || this.formData.name.trim() === '') {
this.errors.name = 'Name is required';
}
// Add more validation rules as needed
return Object.keys(this.errors).length === 0;
},
// ─────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────
/**
* Format date for display
*/
@@ -836,7 +836,7 @@ function admin[PageName]() {
if (!dateString) return '-';
return Utils.formatDate(dateString);
},
/**
* Truncate text
*/
@@ -943,7 +943,7 @@ async loadData() {
async init() {
if (window._dashboardInitialized) return;
window._dashboardInitialized = true;
await Promise.all([
this.loadStats(),
this.loadRecentActivity()
@@ -969,7 +969,7 @@ async loadRecentActivity() {
async init() {
if (window._editPageInitialized) return;
window._editPageInitialized = true;
const itemId = this.getItemIdFromUrl();
await this.loadItem(itemId);
}
@@ -982,12 +982,12 @@ async loadItem(id) {
async saveItem() {
if (!this.validateForm()) return;
await apiClient.put(
`/api/v1/admin/items/${this.item.id}`,
this.formData
);
Utils.showToast('Saved successfully', 'success');
}
```
@@ -1017,11 +1017,11 @@ async loadData() {
per_page: this.pagination.perPage,
...this.filters
});
const response = await apiClient.get(
`/api/v1/admin/items?${params}`
);
this.items = response.items;
this.pagination.total = response.total;
this.pagination.totalPages = Math.ceil(
@@ -1062,7 +1062,7 @@ async init() {
return;
}
window._myPageInitialized = true;
await this.loadData();
}
```
@@ -1345,13 +1345,13 @@ onStoreChange() {
// Quick fill from selected store's settings
quickFill(language) {
if (!this.selectedStore) return;
const urlMap = {
'fr': this.selectedStore.letzshop_csv_url_fr,
'en': this.selectedStore.letzshop_csv_url_en,
'de': this.selectedStore.letzshop_csv_url_de
};
if (urlMap[language]) {
this.importForm.csv_url = urlMap[language];
this.importForm.language = language;
@@ -1367,11 +1367,11 @@ async loadJobs() {
limit: this.limit,
created_by_me: 'true' // Only show jobs I triggered
});
const response = await apiClient.get(
`/admin/marketplace-import-jobs?${params.toString()}`
);
this.jobs = response.items || [];
}
```
@@ -1443,12 +1443,12 @@ filters: {
async applyFilters() {
this.page = 1; // Reset to first page
const params = new URLSearchParams({
page: this.page,
limit: this.limit
});
// Add filters
if (this.filters.store_id) {
params.append('store_id', this.filters.store_id);
@@ -1459,7 +1459,7 @@ async applyFilters() {
if (this.filters.created_by === 'me') {
params.append('created_by_me', 'true');
}
await this.loadJobs();
await this.loadStats(); // Update stats based on filters
}
@@ -1472,12 +1472,12 @@ async applyFilters() {
<select x-model="filters.store_id" @change="applyFilters()">
<option value="">All Stores</option>
<template x-for="store in stores" :key="store.id">
<option :value="store.id"
<option :value="store.id"
x-text="`${store.name} (${store.store_code})`">
</option>
</template>
</select>
<!-- Status Filter -->
<select x-model="filters.status" @change="applyFilters()">
<option value="">All Statuses</option>
@@ -1486,7 +1486,7 @@ async applyFilters() {
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
<!-- Creator Filter -->
<select x-model="filters.created_by" @change="applyFilters()">
<option value="">All Users</option>
@@ -1627,14 +1627,14 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
```html
<!-- Success -->
<div x-show="successMessage" x-transition
<div x-show="successMessage" x-transition
class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg">
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3')"></span>
<p class="font-semibold" x-text="successMessage"></p>
</div>
<!-- Error -->
<div x-show="error" x-transition
<div x-show="error" x-transition
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg dark:bg-red-900/20">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3')"></span>
<div>
@@ -1682,12 +1682,12 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
<span x-html="$icon('close', 'w-5 h-5')"></span>
</button>
</div>
<!-- Modal Content -->
<div x-show="selectedJob">
<!-- Job details grid -->
</div>
<!-- Modal Footer -->
<div class="flex justify-end mt-6">
<button @click="closeJobModal()" class="...">Close</button>
@@ -1704,4 +1704,3 @@ See [Sidebar Navigation](../shared/sidebar.md) for full documentation.
- [Store Page Templates](../store/page-templates.md) - Store page patterns
- [Icons Guide](../../development/icons-guide.md) - Available icons
- [Admin Integration Guide](../../backend/admin-integration-guide.md) - Backend integration

View File

@@ -173,11 +173,11 @@ Example:
return {
loading: false,
stats: {},
async init() {
await this.loadStats();
},
async loadStats() {
this.loading = true;
try {

View File

@@ -55,7 +55,7 @@ app/
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
[Page Name]
</h2>
<!-- Action Buttons -->
<div class="flex items-center space-x-3">
<button
@@ -67,7 +67,7 @@ app/
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
</button>
<button
@click="openCreateModal()"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
@@ -89,7 +89,7 @@ app/
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- ERROR STATE -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div x-show="error && !loading"
<div x-show="error && !loading"
class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
<div>
@@ -115,7 +115,7 @@ app/
/>
</label>
</div>
<!-- Status Filter -->
<div>
<label class="block text-sm">
@@ -131,7 +131,7 @@ app/
</select>
</label>
</div>
<!-- Sort -->
<div>
<label class="block text-sm">
@@ -185,15 +185,15 @@ app/
<div class="flex items-center text-sm">
<div>
<p class="font-semibold" x-text="item.name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400"
<p class="text-xs text-gray-600 dark:text-gray-400"
x-text="item.description"></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-xs">
<span class="px-2 py-1 font-semibold leading-tight rounded-full"
:class="item.status === 'active'
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
:class="item.status === 'active'
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-gray-700 bg-gray-100 dark:text-gray-100 dark:bg-gray-700'"
x-text="item.status"></span>
</td>
@@ -229,7 +229,7 @@ app/
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border-t dark:border-gray-700 bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
<span class="flex items-center col-span-3">
@@ -272,21 +272,21 @@ app/
<!-- MODALS (if needed) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Create/Edit Modal -->
<div x-show="showModal"
<div x-show="showModal"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
@click.self="closeModal()">
<div class="relative w-full max-w-lg p-6 mx-auto bg-white rounded-lg shadow-xl dark:bg-gray-800">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="modalTitle"></h3>
<button @click="closeModal()"
<button @click="closeModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x', 'w-6 h-6')"></span>
</button>
</div>
<!-- Modal Body -->
<form @submit.prevent="saveItem()">
<div class="space-y-4">
@@ -303,7 +303,7 @@ app/
</label>
</div>
</div>
<!-- Modal Footer -->
<div class="flex justify-end mt-6 space-x-3">
<button
@@ -414,7 +414,7 @@ function store[PageName]() {
await this.loadData();
store[PageName]Log.info('[PageName] page initialized');
},
// ═══════════════════════════════════════════════════════════
// DATA LOADING
// ═══════════════════════════════════════════════════════════
@@ -453,11 +453,11 @@ function store[PageName]() {
this.loading = false;
}
},
async refresh() {
await this.loadData();
},
updatePagination(response) {
this.pagination = {
currentPage: response.page || 1,
@@ -470,7 +470,7 @@ function store[PageName]() {
hasNext: response.page < response.pages
};
},
// ═══════════════════════════════════════════════════════════
// FILTERING & PAGINATION
// ═══════════════════════════════════════════════════════════
@@ -478,21 +478,21 @@ function store[PageName]() {
this.pagination.currentPage = 1; // Reset to first page
await this.loadData();
},
async previousPage() {
if (this.pagination.hasPrevious) {
this.pagination.currentPage--;
await this.loadData();
}
},
async nextPage() {
if (this.pagination.hasNext) {
this.pagination.currentPage++;
await this.loadData();
}
},
// ═══════════════════════════════════════════════════════════
// CRUD OPERATIONS
// ═══════════════════════════════════════════════════════════
@@ -506,12 +506,12 @@ function store[PageName]() {
};
this.showModal = true;
},
async viewItem(id) {
// Navigate to detail page or open view modal
window.location.href = `/store/${this.storeCode}/[endpoint]/${id}`;
},
async editItem(id) {
try {
// Load item data
@@ -577,12 +577,12 @@ function store[PageName]() {
alert(error.message || 'Failed to delete item');
}
},
closeModal() {
this.showModal = false;
this.formData = {};
},
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
@@ -595,7 +595,7 @@ function store[PageName]() {
day: 'numeric'
});
},
formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -1024,7 +1024,7 @@ async startImport() {
this.error = 'Please enter a CSV URL';
return;
}
this.importing = true;
try {
const response = await apiClient.post('/store/marketplace/import', {
@@ -1032,7 +1032,7 @@ async startImport() {
marketplace: this.importForm.marketplace,
batch_size: this.importForm.batch_size
});
this.successMessage = `Import job #${response.job_id} started!`;
await this.loadJobs(); // Refresh list
} catch (error) {
@@ -1050,7 +1050,7 @@ startAutoRefresh() {
const hasActiveJobs = this.jobs.some(job =>
job.status === 'pending' || job.status === 'processing'
);
if (hasActiveJobs) {
await this.loadJobs();
}
@@ -1077,7 +1077,7 @@ quickFill(language) {
'en': this.storeSettings.letzshop_csv_url_en,
'de': this.storeSettings.letzshop_csv_url_de
};
if (urlMap[language]) {
this.importForm.csv_url = urlMap[language];
this.importForm.language = language;
@@ -1124,15 +1124,15 @@ formatDate(dateString) {
calculateDuration(job) {
if (!job.started_at) return 'Not started';
const start = new Date(job.started_at);
const end = job.completed_at ? new Date(job.completed_at) : new Date();
const durationMs = end - start;
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
@@ -1190,4 +1190,3 @@ calculateDuration(job) {
- [Marketplace Integration Guide](../../guides/marketplace-integration.md) - Complete marketplace system documentation
- [Admin Page Templates](../admin/page-templates.md) - Admin page patterns
- [Icons Guide](../../development/icons-guide.md) - Available icons