Major refactoring to achieve zero architecture violations: API Layer: - vendor/settings.py: Move validation to Pydantic field validators (tax rate, delivery method, boost sort, preorder days, languages, locales) - admin/email_templates.py: Add Pydantic response models (TemplateListResponse, CategoriesResponse) - shop/auth.py: Move password reset logic to CustomerService Service Layer: - customer_service.py: Add password reset methods (get_customer_for_password_reset, validate_and_reset_password) Exception Layer: - customer.py: Add InvalidPasswordResetTokenException, PasswordTooShortException Frontend: - admin/email-templates.js: Use apiClient, Utils.showToast() - vendor/email-templates.js: Use apiClient, parent init pattern Templates: - admin/email-templates.html: Fix block name to extra_scripts - shop/base.html: Add language default filter Tooling: - validate_architecture.py: Fix LANG-001 false positive for SUPPORTED_LANGUAGES and SUPPORTED_LOCALES blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
367 lines
20 KiB
HTML
367 lines
20 KiB
HTML
{% extends "admin/base.html" %}
|
|
|
|
{% block title %}Email Templates{% endblock %}
|
|
|
|
{% block alpine_data %}emailTemplatesPage(){% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="py-6">
|
|
<!-- Header -->
|
|
<div class="mb-8">
|
|
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
|
Email Templates
|
|
</h2>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Manage platform email templates. Vendors can override non-platform-only templates.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div x-show="loading" class="flex justify-center py-12">
|
|
<span x-html="$icon('spinner', 'h-8 w-8 text-purple-600')"></span>
|
|
</div>
|
|
|
|
<div x-show="!loading" x-cloak>
|
|
<!-- Category Tabs -->
|
|
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
|
|
<nav class="-mb-px flex space-x-8 overflow-x-auto">
|
|
<button
|
|
@click="selectedCategory = null"
|
|
:class="{
|
|
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === null,
|
|
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== null
|
|
}"
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
|
|
All Templates
|
|
</button>
|
|
<template x-for="cat in categories" :key="cat.code">
|
|
<button
|
|
@click="selectedCategory = cat.code"
|
|
:class="{
|
|
'border-purple-500 text-purple-600 dark:text-purple-400': selectedCategory === cat.code,
|
|
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': selectedCategory !== cat.code
|
|
}"
|
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors">
|
|
<span x-text="cat.name"></span>
|
|
</button>
|
|
</template>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Templates List -->
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Template
|
|
</th>
|
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Category
|
|
</th>
|
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Languages
|
|
</th>
|
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
<template x-for="template in filteredTemplates" :key="template.code">
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div class="flex items-center">
|
|
<span x-html="$icon('mail', 'h-5 w-5 text-gray-400 mr-3')"></span>
|
|
<div>
|
|
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="template.name"></div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="template.code"></div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span class="px-2 py-1 text-xs rounded-full"
|
|
:class="getCategoryClass(template.category)"
|
|
x-text="template.category"></span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<div class="flex space-x-1">
|
|
<template x-for="lang in template.languages" :key="lang">
|
|
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded uppercase"
|
|
x-text="lang"></span>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<span x-show="template.is_platform_only"
|
|
class="px-2 py-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 rounded-full">
|
|
Platform Only
|
|
</span>
|
|
<span x-show="!template.is_platform_only"
|
|
class="px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
|
|
Overridable
|
|
</span>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<button @click="editTemplate(template)"
|
|
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300 mr-3">
|
|
Edit
|
|
</button>
|
|
<button @click="previewTemplate(template)"
|
|
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300">
|
|
Preview
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
|
|
<tr x-show="filteredTemplates.length === 0">
|
|
<td colspan="5" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
|
No templates found
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Template Modal -->
|
|
<div x-show="showEditModal"
|
|
x-cloak
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0">
|
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
|
@click="closeEditModal()"></div>
|
|
|
|
<!-- Modal Panel -->
|
|
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
|
|
<!-- Header -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white" x-text="editingTemplate?.name || 'Edit Template'"></h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 font-mono" x-text="editingTemplate?.code"></p>
|
|
</div>
|
|
<button @click="closeEditModal()" class="text-gray-400 hover:text-gray-500">
|
|
<span x-html="$icon('x', 'h-6 w-6')"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Language Tabs -->
|
|
<div class="mt-4 flex space-x-2">
|
|
<template x-for="lang in ['en', 'fr', 'de', 'lb']" :key="lang">
|
|
<button
|
|
@click="editLanguage = lang; loadTemplateLanguage()"
|
|
:class="{
|
|
'bg-purple-600 text-white': editLanguage === lang,
|
|
'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-500': editLanguage !== lang
|
|
}"
|
|
class="px-3 py-1 text-sm font-medium rounded-md uppercase transition-colors">
|
|
<span x-text="lang"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="px-6 py-4 max-h-[60vh] overflow-y-auto">
|
|
<!-- Loading -->
|
|
<div x-show="loadingTemplate" class="flex justify-center py-8">
|
|
<span x-html="$icon('spinner', 'h-6 w-6 text-purple-600')"></span>
|
|
</div>
|
|
|
|
<div x-show="!loadingTemplate" class="space-y-4">
|
|
<!-- Subject -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Subject
|
|
</label>
|
|
<input type="text"
|
|
x-model="editForm.subject"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Supports Jinja2 variables like {{ '{{' }} customer_name {{ '}}' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- HTML Body -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
HTML Body
|
|
</label>
|
|
<textarea x-model="editForm.body_html"
|
|
rows="12"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
|
|
</div>
|
|
|
|
<!-- Plain Text Body -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Plain Text Body
|
|
</label>
|
|
<textarea x-model="editForm.body_text"
|
|
rows="6"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
|
|
</div>
|
|
|
|
<!-- Variables Reference -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
|
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Variables</h4>
|
|
<div class="flex flex-wrap gap-2">
|
|
<template x-for="variable in editForm.variables || []" :key="variable">
|
|
<span class="px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 rounded text-xs font-mono"
|
|
x-text="'{{ ' + variable + ' }}'"></span>
|
|
</template>
|
|
<span x-show="!editForm.variables || editForm.variables.length === 0"
|
|
class="text-gray-500 dark:text-gray-400 text-sm">No variables defined</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-between">
|
|
<div>
|
|
<button @click="sendTestEmail()"
|
|
:disabled="sendingTest"
|
|
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
|
|
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4')"></span>
|
|
<span x-html="$icon('mail', '-ml-1 mr-2 h-4 w-4')"></span>
|
|
<span x-text="sendingTest ? 'Sending...' : 'Send Test Email'"></span>
|
|
</button>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<button @click="closeEditModal()"
|
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
|
|
Cancel
|
|
</button>
|
|
<button @click="saveTemplate()"
|
|
:disabled="saving"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
|
|
<span x-show="saving" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
|
|
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview Modal -->
|
|
<div x-show="showPreviewModal"
|
|
x-cloak
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0">
|
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
|
@click="showPreviewModal = false"></div>
|
|
|
|
<!-- Modal Panel -->
|
|
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-4xl sm:w-full">
|
|
<!-- Header -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Email Preview</h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="previewData?.subject"></p>
|
|
</div>
|
|
<button @click="showPreviewModal = false" class="text-gray-400 hover:text-gray-500">
|
|
<span x-html="$icon('x', 'h-6 w-6')"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="p-6 max-h-[70vh] overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
|
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
|
|
<iframe :srcdoc="previewData?.body_html"
|
|
class="w-full h-96 border-0"
|
|
sandbox="allow-same-origin"></iframe>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end">
|
|
<button @click="showPreviewModal = false"
|
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Test Email Modal -->
|
|
<div x-show="showTestEmailModal"
|
|
x-cloak
|
|
class="fixed inset-0 z-50 overflow-y-auto"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0">
|
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
|
<!-- Backdrop -->
|
|
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
|
@click="showTestEmailModal = false"></div>
|
|
|
|
<!-- Modal Panel -->
|
|
<div class="relative bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-md sm:w-full">
|
|
<!-- Header -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Send Test Email</h3>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="px-6 py-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Send to Email
|
|
</label>
|
|
<input type="email"
|
|
x-model="testEmailAddress"
|
|
placeholder="your@email.com"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 dark:bg-gray-700 dark:text-white text-sm">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-t border-gray-200 dark:border-gray-600 flex justify-end space-x-3">
|
|
<button @click="showTestEmailModal = false"
|
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500">
|
|
Cancel
|
|
</button>
|
|
<button @click="confirmSendTestEmail()"
|
|
:disabled="!testEmailAddress || sendingTest"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<span x-show="sendingTest" x-html="$icon('spinner', '-ml-1 mr-2 h-4 w-4 text-white')"></span>
|
|
<span x-text="sendingTest ? 'Sending...' : 'Send Test'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script src="{{ url_for('static', path='admin/js/email-templates.js') }}"></script>
|
|
{% endblock %}
|