feat(merchant): extract merchant portal as first-class frontend with auth, Tailwind fixes, and Gitea CI
Some checks failed
CI / ruff (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / architecture (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / audit (push) Has been cancelled
CI / docs (push) Has been cancelled

- Extract login/dashboard from billing module into core (matching admin pattern)
- Add merchant auth API with path-isolated cookies (path=/merchants)
- Add merchant base layout with sidebar/header partials and Alpine.js init
- Add frontend detection and login redirect for MERCHANT type
- Wire merchant token in shared api-client.js (get/clear)
- Migrate billing templates to merchant base with dark mode support
- Fix Tailwind: rename shop→storefront in sources and config
- DRY Makefile tailwind targets with TAILWIND_FRONTENDS loop
- Rebuild all Tailwind outputs (production minified)
- Add Gitea Actions CI workflow (ruff, pytest, architecture, docs)
- Add Gitea deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 20:25:29 +01:00
parent ecb5309879
commit 0437af67ec
31 changed files with 1925 additions and 780 deletions

View File

@@ -0,0 +1,134 @@
// app/modules/core/static/merchant/js/init-alpine.js
/**
* Alpine.js initialization for merchant pages
* Provides common data and methods for all merchant pages
*/
// Use centralized logger
const merchantLog = window.LogConfig.log;
console.log('[MERCHANT INIT-ALPINE] Loading...');
// Sidebar section state persistence
const MERCHANT_SIDEBAR_STORAGE_KEY = 'merchant_sidebar_sections';
function getMerchantSidebarSectionsFromStorage() {
try {
const stored = localStorage.getItem(MERCHANT_SIDEBAR_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
}
// Default: all sections open
return {
billing: true,
account: true
};
}
function saveMerchantSidebarSectionsToStorage(sections) {
try {
localStorage.setItem(MERCHANT_SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
} catch (e) {
console.warn('[MERCHANT INIT-ALPINE] Failed to save sidebar state to localStorage:', e);
}
}
function data() {
console.log('[MERCHANT INIT-ALPINE] data() function called');
return {
dark: false,
isSideMenuOpen: false,
isProfileMenuOpen: false,
currentPage: '',
merchantName: '',
// Sidebar collapsible sections state
openSections: getMerchantSidebarSectionsFromStorage(),
init() {
// Set current page from URL
const path = window.location.pathname;
const segments = path.split('/').filter(Boolean);
// For /merchants/dashboard -> 'dashboard'
// For /merchants/billing/subscriptions -> 'subscriptions'
this.currentPage = segments[segments.length - 1] || 'dashboard';
// Load merchant name from JWT token
const token = localStorage.getItem('merchant_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
this.merchantName = payload.merchant_name || payload.sub || 'Merchant';
} catch (e) {
this.merchantName = 'Merchant';
}
}
// Load theme preference
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
this.dark = true;
}
// Save last visited page (for redirect after login)
if (!path.includes('/login') &&
!path.includes('/logout') &&
!path.includes('/errors/')) {
try {
localStorage.setItem('merchant_last_visited_page', path);
} catch (e) {
// Ignore storage errors
}
}
},
toggleSideMenu() {
this.isSideMenuOpen = !this.isSideMenuOpen;
},
closeSideMenu() {
this.isSideMenuOpen = false;
},
toggleProfileMenu() {
this.isProfileMenuOpen = !this.isProfileMenuOpen;
},
closeProfileMenu() {
this.isProfileMenuOpen = false;
},
toggleTheme() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
},
// Sidebar section toggle with persistence
toggleSection(section) {
this.openSections[section] = !this.openSections[section];
saveMerchantSidebarSectionsToStorage(this.openSections);
},
async handleLogout() {
console.log('Logging out merchant user...');
try {
// Call logout API
await apiClient.post('/merchants/auth/logout');
console.log('Logout API called successfully');
} catch (error) {
console.error('Logout API error (continuing anyway):', error);
} finally {
// Clear merchant tokens only
console.log('Clearing merchant tokens...');
localStorage.removeItem('merchant_token');
console.log('Redirecting to login...');
window.location.href = '/merchants/login';
}
}
};
}

View File

@@ -0,0 +1,143 @@
// app/modules/core/static/merchant/js/login.js
// noqa: js-003 - Standalone login page, doesn't use base layout
// noqa: js-004 - No sidebar on login page, doesn't need currentPage
// Use centralized logger
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
function merchantLogin() {
return {
dark: false,
credentials: {
email: '',
password: ''
},
loading: false,
error: null,
success: null,
errors: {},
init() {
// Guard against multiple initialization
if (window._merchantLoginInitialized) return;
window._merchantLoginInitialized = true;
loginLog.info('=== MERCHANT LOGIN PAGE INITIALIZING ===');
// Just set theme - NO auth checking, NO redirecting!
// If user lands here with a valid token, the server-side route
// already handles the redirect. Don't redirect from JS or it
// creates an infinite loop with expired tokens.
this.dark = localStorage.getItem('theme') === 'dark';
const token = localStorage.getItem('merchant_token');
if (token) {
loginLog.warn('Found existing token on login page');
loginLog.info('Not redirecting - server handles auth redirect, clearing stale token');
localStorage.removeItem('merchant_token');
}
loginLog.info('=== MERCHANT LOGIN PAGE INITIALIZATION COMPLETE ===');
},
clearTokens() {
loginLog.debug('Clearing merchant auth tokens...');
localStorage.removeItem('merchant_token');
},
clearErrors() {
this.error = null;
this.success = null;
this.errors = {};
},
validateForm() {
this.clearErrors();
let isValid = true;
if (!this.credentials.email.trim()) {
this.errors.email = 'Email is required';
isValid = false;
}
if (!this.credentials.password) {
this.errors.password = 'Password is required';
isValid = false;
} else if (this.credentials.password.length < 6) {
this.errors.password = 'Password must be at least 6 characters';
isValid = false;
}
return isValid;
},
async handleLogin() {
loginLog.info('=== MERCHANT LOGIN ATTEMPT STARTED ===');
if (!this.validateForm()) {
loginLog.warn('Form validation failed, aborting login');
return;
}
this.loading = true;
this.clearErrors();
try {
loginLog.info('Calling merchant login API endpoint...');
const url = '/merchants/auth/login';
const payload = {
email_or_username: this.credentials.email.trim(),
password: this.credentials.password
};
const response = await apiClient.post(url, payload);
loginLog.info('Login API response received');
// Validate response
if (!response.access_token && !response.token) {
loginLog.error('Invalid response: No access token');
throw new Error('Invalid response from server - no token');
}
loginLog.info('Login successful, storing authentication data...');
// Store authentication data
const token = response.access_token || response.token;
localStorage.setItem('merchant_token', token);
// Show success message
this.success = 'Login successful! Redirecting...';
// Check for last visited page
const lastPage = localStorage.getItem('merchant_last_visited_page');
const redirectTo = (lastPage && lastPage.startsWith('/merchants/') && !lastPage.includes('/login'))
? lastPage
: '/merchants/dashboard';
loginLog.info('Redirecting to:', redirectTo);
window.location.href = redirectTo;
} catch (error) {
window.LogConfig.logError(error, 'MerchantLogin');
this.error = error.message || 'Invalid email or password. Please try again.';
// Only clear tokens on login FAILURE
this.clearTokens();
} finally {
this.loading = false;
loginLog.info('=== MERCHANT LOGIN ATTEMPT FINISHED ===');
}
},
toggleDarkMode() {
this.dark = !this.dark;
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
}
}
}
loginLog.info('Merchant login module loaded');