feat(hosting,prospecting): add hosting unit tests and fix template bugs
All checks were successful
CI / ruff (push) Successful in 10s
CI / pytest (push) Successful in 48m48s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 38s
CI / deploy (push) Successful in 51s

- Add 55 unit tests for hosting module (hosted site service, client
  service service, stats service) with full fixture setup
- Fix table_empty_state macro: add x_message param for dynamic Alpine.js
  expressions rendered via x-text instead of server-side Jinja
- Fix hosting templates (sites, clients) using message= with Alpine
  expressions that rendered as literal text
- Fix prospecting templates (leads, scan-jobs, prospects) using
  nonexistent subtitle= param, migrated to x_message=
- Align hosting and prospecting admin templates with shared design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 06:18:26 +01:00
parent 8136739233
commit 2287f4597d
21 changed files with 2014 additions and 1118 deletions

View File

@@ -14,7 +14,7 @@ from .admin_services import router as admin_services_router
from .admin_sites import router as admin_sites_router from .admin_sites import router as admin_sites_router
from .admin_stats import router as admin_stats_router from .admin_stats import router as admin_stats_router
router = APIRouter() router = APIRouter(prefix="/hosting")
router.include_router(admin_sites_router, tags=["hosting-sites"]) router.include_router(admin_sites_router, tags=["hosting-sites"])
router.include_router(admin_services_router, tags=["hosting-services"]) router.include_router(admin_services_router, tags=["hosting-services"])

View File

@@ -1,7 +1,8 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/pagination.html' import pagination_controls %} {% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Client Services{% endblock %} {% block title %}Client Services{% endblock %}
@@ -11,37 +12,35 @@
{{ page_header('Client Services') }} {{ page_header('Client Services') }}
<!-- Filters --> <!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-3"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div class="flex flex-wrap gap-3">
<label class="text-sm text-gray-600 dark:text-gray-400">Service Type</label> <select x-model="filterType" @change="pagination.page = 1; loadServices()"
<select x-model="filterType" @change="loadServices()" class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> <option value="">All Types</option>
<option value="">All</option>
<option value="domain">Domain</option> <option value="domain">Domain</option>
<option value="email">Email</option> <option value="email">Email</option>
<option value="ssl">SSL</option> <option value="ssl">SSL</option>
<option value="hosting">Hosting</option> <option value="hosting">Hosting</option>
<option value="website_maintenance">Website Maintenance</option> <option value="website_maintenance">Website Maintenance</option>
</select> </select>
</div>
<div> <select x-model="filterStatus" @change="pagination.page = 1; loadServices()"
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<select x-model="filterStatus" @change="loadServices()" <option value="">All Status</option>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="expired">Expired</option> <option value="expired">Expired</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
</select> </select>
</div>
<div class="flex items-end"> <button type="button" @click="showExpiringOnly = !showExpiringOnly; pagination.page = 1; loadServices()"
<button @click="showExpiringOnly = !showExpiringOnly; loadServices()" :class="showExpiringOnly
:class="showExpiringOnly ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'" ? 'bg-red-600 text-white border-red-600'
class="px-4 py-2 text-sm font-medium rounded-lg"> : 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
<span x-html="$icon('clock', 'w-4 h-4 inline mr-1')"></span> class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border transition-colors duration-150">
<span x-html="$icon('clock', 'w-4 h-4 mr-2')"></span>
Expiring Soon Expiring Soon
</button> </button>
</div> </div>
@@ -52,31 +51,22 @@
{{ error_state('Error loading services') }} {{ error_state('Error loading services') }}
<!-- Services Table --> <!-- Services Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow"> <div x-show="!loading && !error">
<div class="w-full overflow-x-auto"> {% call table_wrapper() %}
<table class="w-full whitespace-nowrap"> {{ table_header(['Service', 'Type', 'Status', 'Price', 'Expires', 'Site']) }}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Service</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Price</th>
<th class="px-4 py-3">Expires</th>
<th class="px-4 py-3">Site</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No services found', x_message="filterType || filterStatus || showExpiringOnly ? 'Try adjusting your filters' : 'No client services yet'", show_condition='services.length === 0', icon='cube') }}
<template x-for="svc in services" :key="svc.id"> <template x-for="svc in services" :key="svc.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700" <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
:class="isExpiringSoon(svc) ? 'bg-red-50 dark:bg-red-900/20' : ''"> :class="isExpiringSoon(svc) ? 'bg-red-50 dark:bg-red-900/20' : ''">
<td class="px-4 py-3 text-sm font-semibold" x-text="svc.name"></td> <td class="px-4 py-3 text-sm font-semibold" x-text="svc.name"></td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="svc.service_type.replace('_', ' ')"></span> x-text="svc.service_type.replace('_', ' ')"></span>
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="svc.status === 'active' ? 'bg-green-100 text-green-700' : svc.status === 'expired' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'" :class="svc.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100' : svc.status === 'expired' ? 'bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="svc.status"></span> x-text="svc.status"></span>
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
@@ -84,63 +74,64 @@
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'" <span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'"
:class="isExpiringSoon(svc) ? 'text-red-600 font-semibold' : ''"></span> :class="isExpiringSoon(svc) ? 'text-red-600 dark:text-red-400 font-semibold' : ''"></span>
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3">
<a :href="'/admin/hosting/sites/' + svc.hosted_site_id" <a :href="'/admin/hosting/sites/' + svc.hosted_site_id"
class="text-teal-600 hover:text-teal-900 dark:text-teal-400"> class="flex items-center justify-center p-2 text-teal-600 rounded-lg hover:bg-teal-50 dark:text-teal-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
View Site title="View site">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a> </a>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> {% endcall %}
</div>
<div x-show="services.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500">
No services found
</div>
</div>
{{ pagination_controls() }} {{ pagination() }}
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
function hostingClientsList() { function hostingClientsList() {
return { return {
...data(),
currentPage: 'hosting-clients',
loading: true, loading: true,
error: false, error: null,
services: [], services: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
filterType: '', filterType: '',
filterStatus: '', filterStatus: '',
showExpiringOnly: false, showExpiringOnly: false,
async init() { await this.loadServices(); }, pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
async init() {
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadServices();
},
async loadServices() { async loadServices() {
this.loading = true; this.loading = true;
this.error = false; this.error = null;
try { try {
// Use the sites endpoint to get all services across all sites const params = new URLSearchParams({
const params = new URLSearchParams({ page: this.page, per_page: this.perPage }); page: this.pagination.page,
per_page: this.pagination.per_page,
});
if (this.filterStatus) params.set('status', this.filterStatus); if (this.filterStatus) params.set('status', this.filterStatus);
// Get sites and flatten their services const response = await apiClient.get('/admin/hosting/sites?' + params);
const resp = await fetch('/api/admin/hosting/sites?' + params); const sites = response.items || [];
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
// Collect all services from all sites // Collect all services from all sites
let allServices = []; let allServices = [];
for (const site of data.items) { for (const site of sites) {
const svcResp = await fetch('/api/admin/hosting/sites/' + site.id + '/services'); try {
if (svcResp.ok) { const svcs = await apiClient.get('/admin/hosting/sites/' + site.id + '/services');
const svcs = await svcResp.json(); const svcList = Array.isArray(svcs) ? svcs : (svcs.items || []);
allServices = allServices.concat(svcs); allServices = allServices.concat(svcList.map(s => ({ ...s, hosted_site_id: site.id })));
} } catch (e) { /* skip */ }
} }
// Apply client-side filters // Apply client-side filters
@@ -149,16 +140,47 @@ function hostingClientsList() {
if (this.showExpiringOnly) allServices = allServices.filter(s => this.isExpiringSoon(s)); if (this.showExpiringOnly) allServices = allServices.filter(s => this.isExpiringSoon(s));
this.services = allServices; this.services = allServices;
this.total = allServices.length; this.pagination.total = allServices.length;
} catch (e) { this.error = true; } this.pagination.pages = Math.ceil(allServices.length / this.pagination.per_page) || 1;
finally { this.loading = false; } } catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
}, },
isExpiringSoon(svc) { isExpiringSoon(svc) {
if (!svc.expires_at || svc.status !== 'active') return false; if (!svc.expires_at || svc.status !== 'active') return false;
const daysLeft = (new Date(svc.expires_at) - new Date()) / (1000 * 60 * 60 * 24); const daysLeft = (new Date(svc.expires_at) - new Date()) / (1000 * 60 * 60 * 24);
return daysLeft <= 30 && daysLeft > 0; return daysLeft <= 30 && daysLeft > 0;
}, },
goToPage(p) { this.page = p; this.loadServices(); }, get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
get totalPages() { return this.pagination.pages; },
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); return pages; }
pages.push(1);
if (current > 3) pages.push('...');
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
return pages;
},
goToPage(page) {
if (page === '...' || page < 1 || page > this.totalPages) return;
this.pagination.page = page;
this.loadServices();
},
nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadServices(); } },
previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadServices(); } },
}; };
} }
</script> </script>

View File

@@ -1,5 +1,5 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header, section_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Hosting Dashboard{% endblock %} {% block title %}Hosting Dashboard{% endblock %}
@@ -14,7 +14,7 @@
<div x-show="!loading && !error" class="space-y-6"> <div x-show="!loading && !error" class="space-y-6">
<!-- KPI Cards --> <!-- KPI Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500"> <div class="p-3 mr-4 text-teal-500 bg-teal-100 rounded-full dark:text-teal-100 dark:bg-teal-500">
<span x-html="$icon('globe', 'w-5 h-5')"></span> <span x-html="$icon('globe', 'w-5 h-5')"></span>
</div> </div>
@@ -23,7 +23,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_sites || 0"></p> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_sites || 0"></p>
</div> </div>
</div> </div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500"> <div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('check-circle', 'w-5 h-5')"></span> <span x-html="$icon('check-circle', 'w-5 h-5')"></span>
</div> </div>
@@ -32,7 +32,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.live_sites || 0"></p> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.live_sites || 0"></p>
</div> </div>
</div> </div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500"> <div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span> <span x-html="$icon('eye', 'w-5 h-5')"></span>
</div> </div>
@@ -41,7 +41,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.poc_sites || 0"></p> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.poc_sites || 0"></p>
</div> </div>
</div> </div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500"> <div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('clock', 'w-5 h-5')"></span> <span x-html="$icon('clock', 'w-5 h-5')"></span>
</div> </div>
@@ -53,41 +53,44 @@
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mb-6"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Quick Actions', icon='cursor-click') }}
<div class="flex flex-wrap gap-3">
<a href="/admin/hosting/sites/new" <a href="/admin/hosting/sites/new"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none">
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
New Site New Site
</a> </a>
<a href="/admin/hosting/sites" <a href="/admin/hosting/sites"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
All Sites All Sites
</a> </a>
<a href="/admin/hosting/clients" <a href="/admin/hosting/clients"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
<span x-html="$icon('cube', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('cube', 'w-4 h-4 mr-2')"></span>
All Services All Services
</a> </a>
</div> </div>
</div>
<!-- Sites by Status + Services by Type --> <!-- Sites by Status + Active Services -->
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Sites by Status</h3> {{ section_header('Sites by Status', icon='globe') }}
<div class="space-y-3"> <div class="space-y-3">
<template x-for="status in ['draft', 'poc_ready', 'proposal_sent', 'accepted', 'live', 'suspended', 'cancelled']" :key="status"> <template x-for="status in ['draft', 'poc_ready', 'proposal_sent', 'accepted', 'live', 'suspended', 'cancelled']" :key="status">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="status.replace('_', ' ')"></span> <span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="status.replace('_', ' ')"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="statusBadgeClass(status)" :class="statusBadgeClass(status)"
x-text="stats.sites_by_status?.[status] || 0"></span> x-text="stats.sites_by_status?.[status] || 0"></span>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Active Services</h3> {{ section_header('Active Services', icon='cube') }}
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Active</span> <span class="text-sm text-gray-600 dark:text-gray-400">Total Active</span>
@@ -100,7 +103,7 @@
</div> </div>
</template> </template>
</div> </div>
<div class="mt-4 pt-4 border-t dark:border-gray-700"> <div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Monthly Revenue</span> <span class="text-sm font-medium text-gray-600 dark:text-gray-400">Monthly Revenue</span>
<span class="text-lg font-semibold text-green-600 dark:text-green-400" <span class="text-lg font-semibold text-green-600 dark:text-green-400"
@@ -116,16 +119,16 @@
<script> <script>
function hostingDashboard() { function hostingDashboard() {
return { return {
...data(),
currentPage: 'hosting-dashboard',
loading: true, loading: true,
error: false, error: null,
stats: {}, stats: {},
async init() { async init() {
try { try {
const resp = await fetch('/api/admin/hosting/stats/dashboard'); this.stats = await apiClient.get('/admin/hosting/stats/dashboard');
if (!resp.ok) throw new Error('Failed to load');
this.stats = await resp.json();
} catch (e) { } catch (e) {
this.error = true; this.error = e.message;
} finally { } finally {
this.loading = false; this.loading = false;
} }

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import section_header, tab_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal %}
{% from 'shared/macros/inputs.html' import number_stepper %} {% from 'shared/macros/inputs.html' import number_stepper %}
{% block title %}Site Detail{% endblock %} {% block title %}Site Detail{% endblock %}
@@ -13,122 +14,124 @@
<div x-show="!loading && !error && site" class="space-y-6"> <div x-show="!loading && !error && site" class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/admin/hosting/sites" <a href="/admin/hosting/sites"
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"> class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Back to sites">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span> <span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a> </a>
<div class="relative hidden w-10 h-10 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-teal-100 dark:bg-teal-600 flex items-center justify-center">
<span class="text-sm font-semibold text-teal-600 dark:text-teal-100"
x-text="site.business_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div> <div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" <h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="site.business_name"></h2> x-text="site.business_name"></h2>
<p class="text-sm text-gray-500" x-show="site.live_domain" x-text="site.live_domain"></p> <p class="text-sm text-gray-500 dark:text-gray-400" x-show="site.live_domain" x-text="site.live_domain"></p>
</div> </div>
<span class="px-3 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="statusBadgeClass(site.status)" :class="statusBadgeClass(site.status)"
x-text="site.status.replace('_', ' ')"></span> x-text="site.status.replace('_', ' ')"></span>
</div> </div>
</div> </div>
<!-- Lifecycle Actions --> <!-- Lifecycle Actions -->
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<button x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')" <button type="button" x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
class="px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"> class="inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
Mark POC Ready Mark POC Ready
</button> </button>
<button x-show="site.status === 'poc_ready'" @click="showProposalModal = true" <button type="button" x-show="site.status === 'poc_ready'" @click="showProposalModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700"> class="inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none">
Send Proposal Send Proposal
</button> </button>
<button x-show="site.status === 'proposal_sent'" @click="showAcceptModal = true" <button type="button" x-show="site.status === 'proposal_sent'" @click="showAcceptModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"> class="inline-flex items-center px-3 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">
Accept Proposal Accept Proposal
</button> </button>
<button x-show="site.status === 'accepted'" @click="showGoLiveModal = true" <button type="button" x-show="site.status === 'accepted'" @click="showGoLiveModal = true"
class="px-3 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"> class="inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
Go Live Go Live
</button> </button>
<button x-show="site.status === 'live'" @click="doAction('suspend')" <button type="button" x-show="site.status === 'live'" @click="doAction('suspend')"
class="px-3 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"> class="inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none">
Suspend Suspend
</button> </button>
<button x-show="site.status === 'suspended'" @click="doAction('go-live', {domain: site.live_domain})" <button type="button" x-show="site.status === 'suspended'" @click="doAction('go-live', {domain: site.live_domain})"
class="px-3 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"> class="inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
Reactivate Reactivate
</button> </button>
<button x-show="site.status !== 'cancelled' && site.status !== 'live'" <button type="button" x-show="site.status !== 'cancelled' && site.status !== 'live'"
@click="doAction('cancel')" @click="doAction('cancel')"
class="px-3 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> class="inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
Cancel Cancel
</button> </button>
<a x-show="['poc_ready', 'proposal_sent'].includes(site.status)" <a x-show="['poc_ready', 'proposal_sent'].includes(site.status)"
:href="'/hosting/sites/' + site.id + '/preview'" target="_blank" :href="'/hosting/sites/' + site.id + '/preview'" target="_blank"
class="ml-auto px-3 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900"> class="sm:ml-auto inline-flex items-center px-3 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
<span x-html="$icon('eye', 'w-4 h-4 inline mr-1')"></span> <span x-html="$icon('eye', 'w-4 h-4 mr-1')"></span>
Preview POC Preview POC
</a> </a>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700"> {{ tab_header([
<nav class="flex -mb-px space-x-8"> {'id': 'overview', 'label': 'Overview', 'icon': 'eye'},
<template x-for="tab in tabs" :key="tab.id"> {'id': 'services', 'label': 'Services', 'icon': 'cube'},
<button @click="activeTab = tab.id" {'id': 'store', 'label': 'Store', 'icon': 'shopping-bag'},
:class="activeTab === tab.id ? 'border-teal-500 text-teal-600 dark:text-teal-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'" ], active_var='activeTab') }}
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
x-text="tab.label"></button>
</template>
</nav>
</div>
<!-- Tab: Overview --> <!-- Tab: Overview -->
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2"> <div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3> {{ section_header('Contact Info', icon='phone') }}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Name</span> <span class="text-gray-600 dark:text-gray-400">Name</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_name || '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.contact_name || '—'"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Email</span> <span class="text-gray-600 dark:text-gray-400">Email</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_email || '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.contact_email || '—'"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Phone</span> <span class="text-gray-600 dark:text-gray-400">Phone</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.contact_phone || '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.contact_phone || '—'"></span>
</div> </div>
</div> </div>
</div> </div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Timeline</h3> {{ section_header('Timeline', icon='clock') }}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Created</span> <span class="text-gray-600 dark:text-gray-400">Created</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.created_at ? new Date(site.created_at).toLocaleDateString() : '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.created_at ? new Date(site.created_at).toLocaleDateString() : '—'"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Proposal Sent</span> <span class="text-gray-600 dark:text-gray-400">Proposal Sent</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.proposal_sent_at ? new Date(site.proposal_sent_at).toLocaleDateString() : '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.proposal_sent_at ? new Date(site.proposal_sent_at).toLocaleDateString() : '—'"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Accepted</span> <span class="text-gray-600 dark:text-gray-400">Accepted</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.proposal_accepted_at ? new Date(site.proposal_accepted_at).toLocaleDateString() : '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.proposal_accepted_at ? new Date(site.proposal_accepted_at).toLocaleDateString() : '—'"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Went Live</span> <span class="text-gray-600 dark:text-gray-400">Went Live</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.went_live_at ? new Date(site.went_live_at).toLocaleDateString() : '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.went_live_at ? new Date(site.went_live_at).toLocaleDateString() : '—'"></span>
</div> </div>
</div> </div>
</div> </div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 md:col-span-2" x-show="site.proposal_notes || site.internal_notes"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 md:col-span-2" x-show="site.proposal_notes || site.internal_notes">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Notes</h3> {{ section_header('Notes', icon='document-text') }}
<div x-show="site.proposal_notes" class="mb-3"> <div x-show="site.proposal_notes" class="mb-3">
<p class="text-xs text-gray-500 uppercase mb-1">Proposal Notes</p> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Proposal Notes</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.proposal_notes"></p> <p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.proposal_notes"></p>
</div> </div>
<div x-show="site.internal_notes"> <div x-show="site.internal_notes">
<p class="text-xs text-gray-500 uppercase mb-1">Internal Notes</p> <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Internal Notes</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.internal_notes"></p> <p class="text-sm text-gray-700 dark:text-gray-300" x-text="site.internal_notes"></p>
</div> </div>
</div> </div>
@@ -137,25 +140,25 @@
<!-- Tab: Services --> <!-- Tab: Services -->
<div x-show="activeTab === 'services'" class="space-y-4"> <div x-show="activeTab === 'services'" class="space-y-4">
<div class="flex justify-end"> <div class="flex justify-end">
<button @click="showServiceModal = true" <button type="button" @click="showServiceModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none">
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span> <span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Add Service Add Service
</button> </button>
</div> </div>
<template x-for="svc in site.client_services || []" :key="svc.id"> <template x-for="svc in site.client_services || []" :key="svc.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="svc.name"></span> <span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="svc.name"></span>
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" <span class="ml-2 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="svc.service_type.replace('_', ' ')"></span> x-text="svc.service_type.replace('_', ' ')"></span>
</div> </div>
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="svc.status === 'active' ? 'bg-green-100 text-green-700' : svc.status === 'expired' ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'" :class="svc.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100' : svc.status === 'expired' ? 'bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="svc.status"></span> x-text="svc.status"></span>
</div> </div>
<div class="mt-2 flex gap-4 text-xs text-gray-500"> <div class="mt-2 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-gray-400">
<span x-show="svc.price_cents" x-text="'€' + (svc.price_cents / 100).toFixed(2) + '/' + (svc.billing_period || 'month')"></span> <span x-show="svc.price_cents" x-text="'€' + (svc.price_cents / 100).toFixed(2) + '/' + (svc.billing_period || 'month')"></span>
<span x-show="svc.domain_name" x-text="svc.domain_name"></span> <span x-show="svc.domain_name" x-text="svc.domain_name"></span>
<span x-show="svc.expires_at" x-text="'Expires: ' + new Date(svc.expires_at).toLocaleDateString()"></span> <span x-show="svc.expires_at" x-text="'Expires: ' + new Date(svc.expires_at).toLocaleDateString()"></span>
@@ -167,17 +170,17 @@
</div> </div>
<!-- Tab: Store --> <!-- Tab: Store -->
<div x-show="activeTab === 'store'" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div x-show="activeTab === 'store'" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Store Info</h3> {{ section_header('Store Info', icon='shopping-bag') }}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Store ID</span> <span class="text-gray-600 dark:text-gray-400">Store ID</span>
<span class="text-gray-700 dark:text-gray-300" x-text="site.store_id"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="site.store_id"></span>
</div> </div>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<a :href="'/admin/stores/' + site.store_id" <a :href="'/admin/stores/' + site.store_id"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
Open in Store Admin Open in Store Admin
</a> </a>
@@ -185,101 +188,66 @@
</div> </div>
</div> </div>
<!-- Send Proposal Modal --> {# noqa: FE-004 #} <!-- Send Proposal Modal -->
<div x-show="showProposalModal" x-cloak {% call modal('proposalModal', 'Send Proposal', show_var='showProposalModal', size='md', show_footer=false) %}
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showProposalModal = false">
<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"
@keydown.escape.window="showProposalModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Send Proposal</h3>
<button @click="showProposalModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Proposal Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Proposal Notes</label>
<textarea x-model="proposalNotes" rows="4" <textarea x-model="proposalNotes" rows="4"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div> </div>
<footer class="flex justify-end mt-6 space-x-3"> <div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button @click="showProposalModal = false" <button type="button" @click="showProposalModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button> class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button @click="sendProposal()" <button type="button" @click="sendProposal()"
class="px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">Send</button> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 border border-transparent rounded-lg hover:bg-yellow-700 focus:outline-none transition-colors duration-150">
</footer> <span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span> Send
</div>
</div>
<!-- Accept Proposal Modal --> {# noqa: FE-004 #}
<div x-show="showAcceptModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showAcceptModal = false">
<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"
@keydown.escape.window="showAcceptModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Accept Proposal</h3>
<button @click="showAcceptModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button> </button>
</header>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Existing Merchant ID (leave empty to create new)</label>
<input type="number" x-model="acceptMerchantId" placeholder="Optional"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<footer class="flex justify-end mt-6 space-x-3"> {% endcall %}
<button @click="showAcceptModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button>
<button @click="acceptProposal()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">Accept</button>
</footer>
</div>
</div>
<!-- Go Live Modal --> {# noqa: FE-004 #} <!-- Accept Proposal Modal -->
<div x-show="showGoLiveModal" x-cloak {% call modal('acceptModal', 'Accept Proposal', show_var='showAcceptModal', size='md', show_footer=false) %}
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showGoLiveModal = false">
<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"
@keydown.escape.window="showGoLiveModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Go Live</h3>
<button @click="showGoLiveModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Domain *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Existing Merchant ID (leave empty to create new)</label>
<input type="number" x-model="acceptMerchantId" placeholder="Optional" {# noqa: FE008 - merchant ID input #}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div>
<div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button type="button" @click="showAcceptModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button type="button" @click="acceptProposal()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none transition-colors duration-150">
<span x-html="$icon('check', 'w-4 h-4 mr-2')"></span> Accept
</button>
</div>
{% endcall %}
<!-- Go Live Modal -->
{% call modal('goLiveModal', 'Go Live', show_var='showGoLiveModal', size='md', show_footer=false) %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Domain <span class="text-red-500">*</span>
</label>
<input type="text" x-model="goLiveDomain" placeholder="example.lu" <input type="text" x-model="goLiveDomain" placeholder="example.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<footer class="flex justify-end mt-6 space-x-3"> <div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button @click="showGoLiveModal = false" <button type="button" @click="showGoLiveModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button> class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button @click="goLive()" <button type="button" @click="goLive()"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">Go Live</button> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none transition-colors duration-150">
</footer> <span x-html="$icon('check', 'w-4 h-4 mr-2')"></span> Go Live
</div>
</div>
<!-- Add Service Modal --> {# noqa: FE-004 #}
<div x-show="showServiceModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showServiceModal = false">
<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"
@keydown.escape.window="showServiceModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add Service</h3>
<button @click="showServiceModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button> </button>
</header> </div>
{% endcall %}
<!-- Add Service Modal -->
{% call modal('serviceModal', 'Add Service', show_var='showServiceModal', size='md', show_footer=false) %}
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Type</label>
<select x-model="newService.service_type" <select x-model="newService.service_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="domain">Domain</option> <option value="domain">Domain</option>
<option value="email">Email</option> <option value="email">Email</option>
<option value="ssl">SSL</option> <option value="ssl">SSL</option>
@@ -288,19 +256,19 @@
</select> </select>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Name</label>
<input type="text" x-model="newService.name" placeholder="e.g., acme.lu domain" <input type="text" x-model="newService.name" placeholder="e.g., acme.lu domain"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Price (cents)</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price (cents)</label>
{{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }} {{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }}
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Billing</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Billing</label>
<select x-model="newService.billing_period" <select x-model="newService.billing_period"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
<option value="annual">Annual</option> <option value="annual">Annual</option>
<option value="one_time">One-time</option> <option value="one_time">One-time</option>
@@ -308,29 +276,27 @@
</div> </div>
</div> </div>
</div> </div>
<footer class="flex justify-end mt-6 space-x-3"> <div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button @click="showServiceModal = false" <button type="button" @click="showServiceModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700">Cancel</button> class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">Cancel</button>
<button @click="addService()" <button type="button" @click="addService()"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">Add</button> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none transition-colors duration-150">
</footer> <span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span> Add
</button>
</div> </div>
</div> {% endcall %}
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
function hostingSiteDetail(siteId) { function hostingSiteDetail(siteId) {
return { return {
...data(),
currentPage: 'hosting-sites',
loading: true, loading: true,
error: false, error: null,
site: null, site: null,
activeTab: 'overview', activeTab: 'overview',
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'services', label: 'Services' },
{ id: 'store', label: 'Store' },
],
showProposalModal: false, showProposalModal: false,
showAcceptModal: false, showAcceptModal: false,
showGoLiveModal: false, showGoLiveModal: false,
@@ -342,23 +308,23 @@ function hostingSiteDetail(siteId) {
async init() { await this.loadSite(); }, async init() { await this.loadSite(); },
async loadSite() { async loadSite() {
this.loading = true; this.loading = true;
this.error = null;
try { try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId); this.site = await apiClient.get('/admin/hosting/sites/' + siteId);
if (!resp.ok) throw new Error('Failed to load'); } catch (e) {
this.site = await resp.json(); this.error = e.message;
} catch (e) { this.error = true; } } finally {
finally { this.loading = false; } this.loading = false;
}
}, },
async doAction(action, body = {}) { async doAction(action, body = {}) {
try { try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/' + action, { await apiClient.post('/admin/hosting/sites/' + siteId + '/' + action, body);
method: 'POST', Utils.showToast('Action completed', 'success');
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Action failed'); return; }
await this.loadSite(); await this.loadSite();
} catch (e) { alert('Action failed'); } } catch (e) {
Utils.showToast(e.message || 'Action failed', 'error');
}
}, },
async sendProposal() { async sendProposal() {
await this.doAction('send-proposal', { notes: this.proposalNotes }); await this.doAction('send-proposal', { notes: this.proposalNotes });
@@ -376,16 +342,14 @@ function hostingSiteDetail(siteId) {
}, },
async addService() { async addService() {
try { try {
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/services', { await apiClient.post('/admin/hosting/sites/' + siteId + '/services', this.newService);
method: 'POST', Utils.showToast('Service added', 'success');
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newService),
});
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Failed'); return; }
this.showServiceModal = false; this.showServiceModal = false;
this.newService = { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' }; this.newService = { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' };
await this.loadSite(); await this.loadSite();
} catch (e) { alert('Failed to add service'); } } catch (e) {
Utils.showToast(e.message || 'Failed to add service', 'error');
}
}, },
statusBadgeClass(status) { statusBadgeClass(status) {
const classes = { const classes = {

View File

@@ -6,67 +6,71 @@
{% block alpine_data %}hostingSiteNew(){% endblock %} {% block alpine_data %}hostingSiteNew(){% endblock %}
{% block content %} {% block content %}
{{ page_header('New Hosted Site') }} {{ page_header('New Hosted Site', back_url='/admin/hosting/sites', back_label='Back') }}
<div class="max-w-2xl"> <div class="max-w-2xl mx-auto">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="px-4 py-5 sm:p-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="space-y-6"> <div class="space-y-5">
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Business Name *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
<input type="text" x-model="form.business_name" placeholder="Acme Luxembourg SARL" Business Name <span class="text-red-500">*</span>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-teal-400 focus:ring-teal-300"> </label>
<input type="text" x-model="form.business_name" placeholder="Acme Luxembourg SARL" autofocus
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Name</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Contact Name</label>
<input type="text" x-model="form.contact_name" placeholder="Jean Dupont" <input type="text" x-model="form.contact_name" placeholder="Jean Dupont"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Email</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Contact Email</label>
<input type="email" x-model="form.contact_email" placeholder="contact@acme.lu" <input type="email" x-model="form.contact_email" placeholder="contact@acme.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Contact Phone</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Contact Phone</label>
<input type="tel" x-model="form.contact_phone" placeholder="+352 ..." <input type="tel" x-model="form.contact_phone" placeholder="+352 ..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Internal Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Internal Notes</label>
<textarea x-model="form.internal_notes" rows="3" placeholder="Any internal notes..." <textarea x-model="form.internal_notes" rows="3" placeholder="Any internal notes..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div> </div>
<!-- Prospect Selector --> <!-- Prospect Selector -->
<div> <div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Create from Prospect (optional)</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Create from Prospect (optional)</label>
<div class="flex mt-1 space-x-2"> <div class="flex mt-1 space-x-2">
<input type="number" x-model="prospectId" placeholder="Prospect ID" <input type="number" x-model="prospectId" placeholder="Prospect ID" {# noqa: FE008 - prospect ID input #}
class="flex-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<button @click="createFromProspect()" <button type="button" @click="createFromProspect()"
:disabled="!prospectId || creating" :disabled="!prospectId || creating"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700 disabled:opacity-50"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed">
Create from Prospect Create from Prospect
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="flex justify-end mt-8 space-x-3"> <!-- Footer Actions -->
<div class="flex flex-col sm:flex-row justify-end mt-8 pt-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<a href="/admin/hosting/sites" <a href="/admin/hosting/sites"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel Cancel
</a> </a>
<button @click="createSite()" <button type="button" @click="createSite()"
:disabled="!form.business_name || creating" :disabled="!form.business_name || creating"
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700 disabled:opacity-50"> class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-teal-600 border border-transparent rounded-lg hover:bg-teal-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!creating">Create Site</span> <span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
<span x-show="creating">Creating...</span> <span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="creating ? 'Creating...' : 'Create Site'"></span>
</button> </button>
</div> </div>
@@ -79,6 +83,8 @@
<script> <script>
function hostingSiteNew() { function hostingSiteNew() {
return { return {
...data(),
currentPage: 'hosting-sites',
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' }, form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
prospectId: '', prospectId: '',
creating: false, creating: false,
@@ -87,27 +93,25 @@ function hostingSiteNew() {
this.creating = true; this.creating = true;
this.errorMsg = ''; this.errorMsg = '';
try { try {
const resp = await fetch('/api/admin/hosting/sites', { const site = await apiClient.post('/admin/hosting/sites', this.form);
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
});
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
const site = await resp.json();
window.location.href = '/admin/hosting/sites/' + site.id; window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create site'; } } catch (e) {
finally { this.creating = false; } this.errorMsg = e.message || 'Failed to create site';
} finally {
this.creating = false;
}
}, },
async createFromProspect() { async createFromProspect() {
this.creating = true; this.creating = true;
this.errorMsg = ''; this.errorMsg = '';
try { try {
const resp = await fetch('/api/admin/hosting/sites/from-prospect/' + this.prospectId, { method: 'POST' }); const site = await apiClient.post('/admin/hosting/sites/from-prospect/' + this.prospectId);
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
const site = await resp.json();
window.location.href = '/admin/hosting/sites/' + site.id; window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create from prospect'; } } catch (e) {
finally { this.creating = false; } this.errorMsg = e.message || 'Failed to create from prospect';
} finally {
this.creating = false;
}
}, },
}; };
} }

View File

@@ -1,30 +1,36 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_header, table_empty %} {% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination_controls %} {% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Hosted Sites{% endblock %} {% block title %}Hosted Sites{% endblock %}
{% block alpine_data %}hostingSitesList(){% endblock %} {% block alpine_data %}hostingSitesList(){% endblock %}
{% block content %} {% block content %}
{{ page_header('Hosted Sites', action_label='New Site', action_href='/admin/hosting/sites/new', action_icon='plus') }} {{ page_header('Hosted Sites', action_label='New Site', action_url='/admin/hosting/sites/new', action_icon='plus') }}
<!-- Filters --> <!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-3"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <!-- Search -->
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label> <div class="flex-1 max-w-md">
<input type="text" x-model="search" @input.debounce.300ms="loadSites()" <div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text" x-model="search" @input.debounce.300ms="pagination.page = 1; loadSites()"
placeholder="Business name, email, domain..." placeholder="Business name, email, domain..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-teal-400 focus:ring-teal-300"> class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> </div>
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
<select x-model="filterStatus" @change="loadSites()" <!-- Filters -->
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> <div class="flex flex-wrap gap-3">
<option value="">All</option> <select x-model="filterStatus" @change="pagination.page = 1; loadSites()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Status</option>
<option value="draft">Draft</option> <option value="draft">Draft</option>
<option value="poc_ready">POC Ready</option> <option value="poc_ready">POC Ready</option>
<option value="proposal_sent">Proposal Sent</option> <option value="proposal_sent">Proposal Sent</option>
@@ -33,10 +39,9 @@
<option value="suspended">Suspended</option> <option value="suspended">Suspended</option>
<option value="cancelled">Cancelled</option> <option value="cancelled">Cancelled</option>
</select> </select>
</div>
<div class="flex items-end">
<a href="/admin/prospecting/prospects" <a href="/admin/prospecting/prospects"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-teal-700 bg-teal-100 rounded-lg hover:bg-teal-200 dark:text-teal-300 dark:bg-teal-900 dark:hover:bg-teal-800"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-teal-700 dark:text-teal-300 transition-colors duration-150 bg-teal-100 dark:bg-teal-900 border border-transparent rounded-lg hover:bg-teal-200 dark:hover:bg-teal-800 focus:outline-none">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Create from Prospect Create from Prospect
</a> </a>
@@ -48,24 +53,21 @@
{{ error_state('Error loading sites') }} {{ error_state('Error loading sites') }}
<!-- Sites Table --> <!-- Sites Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow"> <div x-show="!loading && !error">
<div class="w-full overflow-x-auto"> {% call table_wrapper() %}
<table class="w-full whitespace-nowrap"> {{ table_header(['Business', 'Status', 'Contact', 'Domain', 'Created', 'Actions']) }}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Domain</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No hosted sites found', x_message="search || filterStatus ? 'Try adjusting your search or filters' : 'Create your first hosted site to get started'", show_condition='sites.length === 0', icon='globe') }}
<template x-for="s in sites" :key="s.id"> <template x-for="s in sites" :key="s.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"> <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-teal-100 dark:bg-teal-600 flex items-center justify-center">
<span class="text-xs font-semibold text-teal-600 dark:text-teal-100"
x-text="s.business_name?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div> <div>
<p class="font-semibold" x-text="s.business_name"></p> <p class="font-semibold" x-text="s.business_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-text="s.contact_name || ''"></p> <p class="text-xs text-gray-600 dark:text-gray-400" x-text="s.contact_name || ''"></p>
@@ -73,7 +75,7 @@
</div> </div>
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="statusBadgeClass(s.status)" :class="statusBadgeClass(s.status)"
x-text="s.status.replace('_', ' ')"></span> x-text="s.status.replace('_', ' ')"></span>
</td> </td>
@@ -86,54 +88,90 @@
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span x-text="new Date(s.created_at).toLocaleDateString()" class="text-xs"></span> <span x-text="new Date(s.created_at).toLocaleDateString()" class="text-xs"></span>
</td> </td>
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a :href="'/admin/hosting/sites/' + s.id" <a :href="'/admin/hosting/sites/' + s.id"
class="text-teal-600 hover:text-teal-900 dark:text-teal-400 dark:hover:text-teal-300"> class="flex items-center justify-center p-2 text-teal-600 rounded-lg hover:bg-teal-50 dark:text-teal-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
View title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a> </a>
</div>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> {% endcall %}
</div>
{{ table_empty('No hosted sites found') }}
</div>
{{ pagination_controls() }} {{ pagination() }}
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
function hostingSitesList() { function hostingSitesList() {
return { return {
...data(),
currentPage: 'hosting-sites',
loading: true, loading: true,
error: false, error: null,
sites: [], sites: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
search: '', search: '',
filterStatus: '', filterStatus: '',
async init() { await this.loadSites(); }, pagination: { page: 1, per_page: 20, total: 0, pages: 0 },
async init() {
if (window.PlatformSettings) {
this.pagination.per_page = await window.PlatformSettings.getRowsPerPage();
}
await this.loadSites();
},
async loadSites() { async loadSites() {
this.loading = true; this.loading = true;
this.error = false; this.error = null;
try { try {
const params = new URLSearchParams({ page: this.page, per_page: this.perPage }); const params = new URLSearchParams({
page: this.pagination.page,
per_page: this.pagination.per_page,
});
if (this.search) params.set('search', this.search); if (this.search) params.set('search', this.search);
if (this.filterStatus) params.set('status', this.filterStatus); if (this.filterStatus) params.set('status', this.filterStatus);
const resp = await fetch('/api/admin/hosting/sites?' + params); const response = await apiClient.get('/admin/hosting/sites?' + params);
if (!resp.ok) throw new Error('Failed to load'); this.sites = response.items || [];
const data = await resp.json(); this.pagination.total = response.total || 0;
this.sites = data.items; this.pagination.pages = response.pages || 0;
this.total = data.total; } catch (e) {
this.pages = data.pages; this.error = e.message;
} catch (e) { this.error = true; } } finally {
finally { this.loading = false; } this.loading = false;
}
}, },
goToPage(p) { this.page = p; this.loadSites(); }, get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
get totalPages() { return this.pagination.pages; },
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.pagination.page;
if (total <= 7) { for (let i = 1; i <= total; i++) pages.push(i); return pages; }
pages.push(1);
if (current > 3) pages.push('...');
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) pages.push(i);
if (current < total - 2) pages.push('...');
pages.push(total);
return pages;
},
goToPage(page) {
if (page === '...' || page < 1 || page > this.totalPages) return;
this.pagination.page = page;
this.loadSites();
},
nextPage() { if (this.pagination.page < this.totalPages) { this.pagination.page++; this.loadSites(); } },
previousPage() { if (this.pagination.page > 1) { this.pagination.page--; this.loadSites(); } },
statusBadgeClass(status) { statusBadgeClass(status) {
const classes = { const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700', draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',

View File

View File

@@ -0,0 +1,186 @@
# app/modules/hosting/tests/conftest.py
"""
Module-specific fixtures for hosting tests.
Core fixtures (db, client, etc.) are inherited from the root conftest.py.
"""
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.hosting.models import (
ClientService,
ClientServiceStatus,
HostedSite,
HostedSiteStatus,
ServiceType,
)
@pytest.fixture
def hosting_platform(db):
"""Ensure a 'hosting' platform exists for hosted site creation."""
from app.modules.tenancy.models import Platform
platform = db.query(Platform).filter(Platform.code == "hosting").first()
if not platform:
platform = Platform(
code="hosting",
name="Hosting Platform",
is_active=True,
is_public=False,
default_language="fr",
supported_languages=["fr", "en", "de"],
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def system_user(db):
"""Create a system user for the HostWizard System merchant."""
from app.modules.tenancy.models import User
user = User(
email=f"system-{uuid.uuid4().hex[:8]}@hostwizard.lu",
username=f"system_{uuid.uuid4().hex[:8]}",
first_name="System",
last_name="User",
hashed_password="not-a-real-hash", # noqa: SEC001
role="super_admin",
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def system_merchant(db, system_user):
"""Ensure the HostWizard System merchant exists."""
from app.modules.tenancy.models import Merchant
merchant = db.query(Merchant).filter(Merchant.name == "HostWizard System").first()
if not merchant:
merchant = Merchant(
name="HostWizard System",
contact_email="system@hostwizard.lu",
owner_user_id=system_user.id,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def hosted_site(db, hosting_platform, system_merchant):
"""Create a hosted site in DRAFT status with its backing store."""
from app.modules.hosting.services.hosted_site_service import hosted_site_service
unique = uuid.uuid4().hex[:8]
site = hosted_site_service.create(
db,
{
"business_name": f"Test Business {unique}",
"contact_name": "John Doe",
"contact_email": f"john-{unique}@example.com",
"contact_phone": "+352 123 456",
"internal_notes": "Test site",
},
)
db.commit()
db.refresh(site)
return site
@pytest.fixture
def hosted_site_poc_ready(db, hosted_site):
"""Create a hosted site in POC_READY status."""
from app.modules.hosting.services.hosted_site_service import hosted_site_service
site = hosted_site_service.mark_poc_ready(db, hosted_site.id)
db.commit()
db.refresh(site)
return site
@pytest.fixture
def hosted_site_proposal_sent(db, hosted_site_poc_ready):
"""Create a hosted site in PROPOSAL_SENT status."""
from app.modules.hosting.services.hosted_site_service import hosted_site_service
site = hosted_site_service.send_proposal(
db, hosted_site_poc_ready.id, notes="Test proposal"
)
db.commit()
db.refresh(site)
return site
@pytest.fixture
def client_service_domain(db, hosted_site):
"""Create a domain client service for a hosted site."""
service = ClientService(
hosted_site_id=hosted_site.id,
service_type=ServiceType.DOMAIN,
name="test-domain.lu",
status=ClientServiceStatus.ACTIVE,
billing_period="annual",
price_cents=2500,
currency="EUR",
domain_name="test-domain.lu",
registrar="Namecheap",
expires_at=datetime.now(UTC) + timedelta(days=365),
auto_renew=True,
)
db.add(service)
db.commit()
db.refresh(service)
return service
@pytest.fixture
def client_service_email(db, hosted_site):
"""Create an email client service for a hosted site."""
service = ClientService(
hosted_site_id=hosted_site.id,
service_type=ServiceType.EMAIL,
name="Email Hosting",
status=ClientServiceStatus.ACTIVE,
billing_period="monthly",
price_cents=500,
currency="EUR",
mailbox_count=5,
auto_renew=True,
)
db.add(service)
db.commit()
db.refresh(service)
return service
@pytest.fixture
def client_service_expiring(db, hosted_site):
"""Create a client service expiring within 30 days."""
service = ClientService(
hosted_site_id=hosted_site.id,
service_type=ServiceType.SSL,
name="SSL Certificate",
status=ClientServiceStatus.ACTIVE,
billing_period="annual",
price_cents=0,
currency="EUR",
expires_at=datetime.now(UTC) + timedelta(days=15),
auto_renew=True,
)
db.add(service)
db.commit()
db.refresh(service)
return service

View File

@@ -0,0 +1,219 @@
# app/modules/hosting/tests/unit/test_client_service_service.py
"""
Unit tests for ClientServiceService.
"""
from datetime import UTC, datetime, timedelta
import pytest
from app.modules.hosting.exceptions import ClientServiceNotFoundException
from app.modules.hosting.models import ClientServiceStatus, ServiceType
from app.modules.hosting.services.client_service_service import ClientServiceService
@pytest.mark.unit
@pytest.mark.hosting
class TestClientServiceService:
"""Tests for ClientServiceService CRUD operations."""
def setup_method(self):
self.service = ClientServiceService()
def test_create_domain_service(self, db, hosted_site):
"""Test creating a domain service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "domain",
"name": "example.lu",
"domain_name": "example.lu",
"registrar": "Gandi",
"billing_period": "annual",
"price_cents": 1500,
},
)
db.commit()
assert svc.id is not None
assert svc.service_type == ServiceType.DOMAIN
assert svc.status == ClientServiceStatus.PENDING
assert svc.domain_name == "example.lu"
assert svc.registrar == "Gandi"
assert svc.price_cents == 1500
def test_create_email_service(self, db, hosted_site):
"""Test creating an email service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "email",
"name": "Email Hosting",
"mailbox_count": 10,
"billing_period": "monthly",
"price_cents": 900,
},
)
db.commit()
assert svc.service_type == ServiceType.EMAIL
assert svc.mailbox_count == 10
def test_create_ssl_service(self, db, hosted_site):
"""Test creating an SSL service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "ssl",
"name": "Let's Encrypt SSL",
"price_cents": 0,
"expires_at": datetime.now(UTC) + timedelta(days=90),
},
)
db.commit()
assert svc.service_type == ServiceType.SSL
assert svc.expires_at is not None
def test_create_hosting_service(self, db, hosted_site):
"""Test creating a hosting service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "hosting",
"name": "Shared Hosting",
"billing_period": "monthly",
"price_cents": 2000,
},
)
db.commit()
assert svc.service_type == ServiceType.HOSTING
def test_create_maintenance_service(self, db, hosted_site):
"""Test creating a website maintenance service."""
svc = self.service.create(
db,
hosted_site.id,
{
"service_type": "website_maintenance",
"name": "Monthly Maintenance",
"billing_period": "monthly",
"price_cents": 5000,
"notes": "Includes security updates",
},
)
db.commit()
assert svc.service_type == ServiceType.WEBSITE_MAINTENANCE
assert svc.notes == "Includes security updates"
def test_get_by_id(self, db, client_service_domain):
"""Test getting a service by ID."""
result = self.service.get_by_id(db, client_service_domain.id)
assert result.id == client_service_domain.id
def test_get_by_id_not_found(self, db):
"""Test getting non-existent service raises exception."""
with pytest.raises(ClientServiceNotFoundException):
self.service.get_by_id(db, 99999)
def test_get_for_site(self, db, hosted_site, client_service_domain, client_service_email):
"""Test getting all services for a site."""
services = self.service.get_for_site(db, hosted_site.id)
assert len(services) >= 2
def test_update_service(self, db, client_service_domain):
"""Test updating a service."""
updated = self.service.update(
db,
client_service_domain.id,
{"name": "updated-domain.lu", "price_cents": 3000},
)
db.commit()
assert updated.name == "updated-domain.lu"
assert updated.price_cents == 3000
def test_update_service_status(self, db, client_service_domain):
"""Test updating a service status."""
updated = self.service.update(
db,
client_service_domain.id,
{"status": ClientServiceStatus.SUSPENDED},
)
db.commit()
assert updated.status == ClientServiceStatus.SUSPENDED
def test_delete_service(self, db, client_service_domain):
"""Test deleting a service."""
svc_id = client_service_domain.id
self.service.delete(db, svc_id)
db.commit()
with pytest.raises(ClientServiceNotFoundException):
self.service.get_by_id(db, svc_id)
def test_get_expiring_soon(self, db, client_service_expiring):
"""Test getting services expiring within 30 days."""
expiring = self.service.get_expiring_soon(db, days=30)
assert len(expiring) >= 1
assert all(s.expires_at is not None for s in expiring)
def test_get_expiring_soon_excludes_distant(self, db, client_service_domain):
"""Test that services expiring far in the future are excluded."""
# client_service_domain expires in 365 days
expiring = self.service.get_expiring_soon(db, days=30)
ids = [s.id for s in expiring]
assert client_service_domain.id not in ids
def test_get_all(self, db, client_service_domain, client_service_email):
"""Test listing all services with pagination."""
services, total = self.service.get_all(db)
assert total >= 2
assert len(services) >= 2
def test_get_all_filter_type(self, db, client_service_domain, client_service_email):
"""Test filtering services by type."""
services, total = self.service.get_all(db, service_type="domain")
assert total >= 1
assert all(s.service_type == ServiceType.DOMAIN for s in services)
def test_get_all_filter_status(self, db, client_service_domain):
"""Test filtering services by status."""
services, total = self.service.get_all(db, status="active")
assert total >= 1
assert all(s.status == ClientServiceStatus.ACTIVE for s in services)
def test_get_all_pagination(self, db, client_service_domain, client_service_email):
"""Test pagination."""
services, total = self.service.get_all(db, page=1, per_page=1)
assert len(services) == 1
assert total >= 2
def test_default_currency(self, db, hosted_site):
"""Test that default currency is EUR."""
svc = self.service.create(
db,
hosted_site.id,
{"service_type": "hosting", "name": "Test"},
)
db.commit()
assert svc.currency == "EUR"
def test_auto_renew_default(self, db, hosted_site):
"""Test that auto_renew defaults to True."""
svc = self.service.create(
db,
hosted_site.id,
{"service_type": "hosting", "name": "Test"},
)
db.commit()
assert svc.auto_renew is True

View File

@@ -0,0 +1,325 @@
# app/modules/hosting/tests/unit/test_hosted_site_service.py
"""
Unit tests for HostedSiteService.
"""
import uuid
import pytest
from app.modules.hosting.exceptions import (
HostedSiteNotFoundException,
InvalidStatusTransitionException,
)
from app.modules.hosting.models import HostedSiteStatus
from app.modules.hosting.services.hosted_site_service import (
HostedSiteService,
_slugify,
)
@pytest.mark.unit
@pytest.mark.hosting
class TestSlugify:
"""Tests for the _slugify helper."""
def test_basic_slug(self):
assert _slugify("My Business") == "my-business"
def test_special_characters_removed(self):
assert _slugify("Café & Brasserie!") == "caf-brasserie"
def test_multiple_spaces_collapsed(self):
assert _slugify(" Too Many Spaces ") == "too-many-spaces"
def test_max_length(self):
long_name = "A" * 100
assert len(_slugify(long_name)) <= 50
def test_empty_string(self):
assert _slugify("") == ""
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteService:
"""Tests for HostedSiteService CRUD operations."""
def setup_method(self):
self.service = HostedSiteService()
def test_create_site(self, db, hosting_platform, system_merchant):
"""Test creating a hosted site with auto-created store."""
unique = uuid.uuid4().hex[:8]
site = self.service.create(
db,
{
"business_name": f"New Business {unique}",
"contact_email": f"test-{unique}@example.com",
},
)
db.commit()
assert site.id is not None
assert site.business_name == f"New Business {unique}"
assert site.status == HostedSiteStatus.DRAFT
assert site.store_id is not None
def test_create_site_with_all_fields(self, db, hosting_platform, system_merchant):
"""Test creating a site with all optional fields."""
unique = uuid.uuid4().hex[:8]
site = self.service.create(
db,
{
"business_name": f"Full Business {unique}",
"contact_name": "Jane Doe",
"contact_email": f"jane-{unique}@example.com",
"contact_phone": "+352 999 888",
"internal_notes": "VIP client",
},
)
db.commit()
assert site.contact_name == "Jane Doe"
assert site.contact_phone == "+352 999 888"
assert site.internal_notes == "VIP client"
def test_get_by_id(self, db, hosted_site):
"""Test getting a hosted site by ID."""
result = self.service.get_by_id(db, hosted_site.id)
assert result.id == hosted_site.id
assert result.business_name == hosted_site.business_name
def test_get_by_id_not_found(self, db):
"""Test getting non-existent site raises exception."""
with pytest.raises(HostedSiteNotFoundException):
self.service.get_by_id(db, 99999)
def test_get_all(self, db, hosted_site):
"""Test listing all hosted sites."""
sites, total = self.service.get_all(db)
assert total >= 1
assert len(sites) >= 1
def test_get_all_search(self, db, hosted_site):
"""Test searching hosted sites by business name."""
name_part = hosted_site.business_name[:12]
sites, total = self.service.get_all(db, search=name_part)
assert total >= 1
def test_get_all_filter_status(self, db, hosted_site):
"""Test filtering sites by status."""
sites, total = self.service.get_all(db, status="draft")
assert total >= 1
assert all(s.status == HostedSiteStatus.DRAFT for s in sites)
def test_get_all_pagination(self, db, hosted_site):
"""Test pagination returns correct subset."""
sites, total = self.service.get_all(db, page=1, per_page=1)
assert len(sites) <= 1
def test_update(self, db, hosted_site):
"""Test updating a hosted site."""
updated = self.service.update(
db,
hosted_site.id,
{"business_name": "Updated Name", "internal_notes": "Updated notes"},
)
db.commit()
assert updated.business_name == "Updated Name"
assert updated.internal_notes == "Updated notes"
def test_delete(self, db, hosted_site):
"""Test deleting a hosted site."""
site_id = hosted_site.id
self.service.delete(db, site_id)
db.commit()
with pytest.raises(HostedSiteNotFoundException):
self.service.get_by_id(db, site_id)
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteLifecycle:
"""Tests for hosted site lifecycle transitions."""
def setup_method(self):
self.service = HostedSiteService()
def test_mark_poc_ready(self, db, hosted_site):
"""Test DRAFT → POC_READY transition."""
site = self.service.mark_poc_ready(db, hosted_site.id)
db.commit()
assert site.status == HostedSiteStatus.POC_READY
def test_send_proposal(self, db, hosted_site_poc_ready):
"""Test POC_READY → PROPOSAL_SENT transition."""
site = self.service.send_proposal(
db, hosted_site_poc_ready.id, notes="Proposal notes"
)
db.commit()
assert site.status == HostedSiteStatus.PROPOSAL_SENT
assert site.proposal_sent_at is not None
assert site.proposal_notes == "Proposal notes"
def test_send_proposal_without_notes(self, db, hosted_site_poc_ready):
"""Test sending proposal without notes."""
site = self.service.send_proposal(db, hosted_site_poc_ready.id)
db.commit()
assert site.status == HostedSiteStatus.PROPOSAL_SENT
assert site.proposal_sent_at is not None
def test_cancel_from_draft(self, db, hosted_site):
"""Test DRAFT → CANCELLED transition."""
site = self.service.cancel(db, hosted_site.id)
db.commit()
assert site.status == HostedSiteStatus.CANCELLED
def test_cancel_from_poc_ready(self, db, hosted_site_poc_ready):
"""Test POC_READY → CANCELLED transition."""
site = self.service.cancel(db, hosted_site_poc_ready.id)
db.commit()
assert site.status == HostedSiteStatus.CANCELLED
def test_cancel_from_proposal_sent(self, db, hosted_site_proposal_sent):
"""Test PROPOSAL_SENT → CANCELLED transition."""
site = self.service.cancel(db, hosted_site_proposal_sent.id)
db.commit()
assert site.status == HostedSiteStatus.CANCELLED
def test_invalid_transition_draft_to_live(self, db, hosted_site):
"""Test that DRAFT → LIVE is not allowed."""
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(db, hosted_site.id, HostedSiteStatus.LIVE)
def test_invalid_transition_draft_to_accepted(self, db, hosted_site):
"""Test that DRAFT → ACCEPTED is not allowed."""
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(db, hosted_site.id, HostedSiteStatus.ACCEPTED)
def test_invalid_transition_cancelled_to_anything(self, db, hosted_site):
"""Test that CANCELLED state has no valid transitions."""
self.service.cancel(db, hosted_site.id)
db.commit()
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(db, hosted_site.id, HostedSiteStatus.DRAFT)
def test_invalid_transition_poc_ready_to_accepted(self, db, hosted_site_poc_ready):
"""Test that POC_READY → ACCEPTED is not allowed (must go through PROPOSAL_SENT)."""
with pytest.raises(InvalidStatusTransitionException):
self.service._transition(
db, hosted_site_poc_ready.id, HostedSiteStatus.ACCEPTED
)
def test_suspend_from_live(self, db, hosted_site_proposal_sent):
"""Test LIVE → SUSPENDED transition."""
# Need to get to LIVE first: accepted → live
site = self.service._transition(
db, hosted_site_proposal_sent.id, HostedSiteStatus.ACCEPTED
)
db.flush()
# Manually set to LIVE (bypass go_live which needs StoreDomainService)
site.status = HostedSiteStatus.LIVE
db.flush()
site = self.service.suspend(db, site.id)
db.commit()
assert site.status == HostedSiteStatus.SUSPENDED
def test_reactivate_from_suspended(self, db, hosted_site_proposal_sent):
"""Test SUSPENDED → LIVE transition."""
site = self.service._transition(
db, hosted_site_proposal_sent.id, HostedSiteStatus.ACCEPTED
)
db.flush()
site.status = HostedSiteStatus.LIVE
db.flush()
site = self.service.suspend(db, site.id)
db.flush()
site = self.service._transition(db, site.id, HostedSiteStatus.LIVE)
db.commit()
assert site.status == HostedSiteStatus.LIVE
@pytest.mark.unit
@pytest.mark.hosting
class TestHostedSiteFromProspect:
"""Tests for creating hosted sites from prospects."""
def setup_method(self):
self.service = HostedSiteService()
def test_create_from_prospect(self, db, hosting_platform, system_merchant):
"""Test creating a hosted site from a prospect."""
from app.modules.prospecting.models import Prospect
prospect = Prospect(
channel="digital",
domain_name=f"prospect-{uuid.uuid4().hex[:8]}.lu",
business_name="Prospect Business",
status="active",
has_website=True,
)
db.add(prospect)
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
db.commit()
assert site.id is not None
assert site.prospect_id == prospect.id
assert site.business_name == "Prospect Business"
def test_create_from_prospect_with_contacts(
self, db, hosting_platform, system_merchant
):
"""Test creating from prospect pre-fills contact info."""
from app.modules.prospecting.models import Prospect, ProspectContact
prospect = Prospect(
channel="digital",
domain_name=f"contacts-{uuid.uuid4().hex[:8]}.lu",
business_name="Contact Business",
status="active",
)
db.add(prospect)
db.flush()
email_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="email",
value="hello@test.lu",
is_primary=True,
)
phone_contact = ProspectContact(
prospect_id=prospect.id,
contact_type="phone",
value="+352 111 222",
is_primary=True,
)
db.add_all([email_contact, phone_contact])
db.commit()
db.refresh(prospect)
site = self.service.create_from_prospect(db, prospect.id)
db.commit()
assert site.contact_email == "hello@test.lu"
assert site.contact_phone == "+352 111 222"
def test_create_from_nonexistent_prospect(
self, db, hosting_platform, system_merchant
):
"""Test creating from non-existent prospect raises exception."""
from app.modules.prospecting.exceptions import ProspectNotFoundException
with pytest.raises(ProspectNotFoundException):
self.service.create_from_prospect(db, 99999)

View File

@@ -0,0 +1,66 @@
# app/modules/hosting/tests/unit/test_stats_service.py
"""
Unit tests for hosting StatsService.
"""
import pytest
from app.modules.hosting.services.stats_service import StatsService
@pytest.mark.unit
@pytest.mark.hosting
class TestStatsService:
"""Tests for hosting StatsService."""
def setup_method(self):
self.service = StatsService()
def test_dashboard_stats_empty(self, db):
"""Test dashboard stats with no data."""
stats = self.service.get_dashboard_stats(db)
assert stats["total_sites"] == 0
assert stats["live_sites"] == 0
assert stats["poc_sites"] == 0
assert stats["active_services"] == 0
assert stats["monthly_revenue_cents"] == 0
assert stats["upcoming_renewals"] == 0
def test_dashboard_stats_with_site(self, db, hosted_site):
"""Test dashboard stats with a draft site."""
stats = self.service.get_dashboard_stats(db)
assert stats["total_sites"] >= 1
assert stats["poc_sites"] >= 1 # draft counts as POC
assert stats["sites_by_status"].get("draft", 0) >= 1
def test_dashboard_stats_with_services(
self, db, hosted_site, client_service_domain, client_service_email
):
"""Test dashboard stats with active services."""
stats = self.service.get_dashboard_stats(db)
assert stats["active_services"] >= 2
assert stats["monthly_revenue_cents"] >= 3000 # 2500 + 500
def test_dashboard_stats_services_by_type(
self, db, hosted_site, client_service_domain, client_service_email
):
"""Test services_by_type breakdown."""
stats = self.service.get_dashboard_stats(db)
assert "domain" in stats["services_by_type"]
assert "email" in stats["services_by_type"]
def test_dashboard_stats_upcoming_renewals(
self, db, hosted_site, client_service_expiring
):
"""Test upcoming renewals count."""
stats = self.service.get_dashboard_stats(db)
assert stats["upcoming_renewals"] >= 1
def test_dashboard_stats_sites_by_status(
self, db, hosted_site, hosted_site_poc_ready
):
"""Test sites_by_status breakdown."""
stats = self.service.get_dashboard_stats(db)
assert isinstance(stats["sites_by_status"], dict)
total_from_breakdown = sum(stats["sites_by_status"].values())
assert total_from_breakdown == stats["total_sites"]

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Campaigns{% endblock %} {% block title %}Campaigns{% endblock %}
@@ -10,15 +11,22 @@
{{ page_header('Campaign Templates', action_label='New Template', action_onclick='showCreateModal = true', action_icon='plus') }} {{ page_header('Campaign Templates', action_label='New Template', action_onclick='showCreateModal = true', action_icon='plus') }}
<!-- Filter by Lead Type --> <!-- Filter by Lead Type -->
<div class="mb-6 flex flex-wrap gap-2"> <div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<button @click="filterLeadType = ''" :class="!filterLeadType ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'" <div class="flex flex-wrap gap-2">
class="px-3 py-1.5 text-sm rounded-lg">All</button> <button type="button" @click="filterLeadType = ''"
:class="!filterLeadType
? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-150">All</button>
<template x-for="lt in leadTypes" :key="lt.value"> <template x-for="lt in leadTypes" :key="lt.value">
<button @click="filterLeadType = lt.value" <button type="button" @click="filterLeadType = lt.value"
:class="filterLeadType === lt.value ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'" :class="filterLeadType === lt.value
class="px-3 py-1.5 text-sm rounded-lg" ? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-150"
x-text="lt.label"></button> x-text="lt.label"></button>
</template> </template>
</div>
</div> </div>
{{ loading_state('Loading templates...') }} {{ loading_state('Loading templates...') }}
@@ -26,73 +34,80 @@
<!-- Templates Grid --> <!-- Templates Grid -->
<div x-show="!loading && !error" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div x-show="!loading && !error" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Empty State -->
<div x-show="filteredTemplates().length === 0" class="col-span-full py-12 text-center">
<span x-html="$icon('mail', 'w-12 h-12 mx-auto mb-3 text-gray-300')"></span>
<p class="font-medium text-gray-600 dark:text-gray-400">No templates found</p>
<p class="text-xs text-gray-500 mt-1" x-text="filterLeadType ? 'Try a different lead type filter' : 'Create your first campaign template'"></p>
</div>
<template x-for="tpl in filteredTemplates()" :key="tpl.id"> <template x-for="tpl in filteredTemplates()" :key="tpl.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow duration-150">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div> <div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="tpl.name"></h3> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="tpl.name"></h3>
<div class="flex items-center space-x-2 mt-1"> <div class="flex items-center space-x-2 mt-1">
<span class="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300" <span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
x-text="tpl.lead_type.replace('_', ' ')"></span> x-text="tpl.lead_type.replace('_', ' ')"></span>
<span class="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400" <span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
x-text="tpl.channel"></span> x-text="tpl.channel"></span>
<span class="text-xs text-gray-400" x-text="tpl.language.toUpperCase()"></span> <span class="text-xs text-gray-400" x-text="tpl.language.toUpperCase()"></span>
</div> </div>
</div> </div>
<span class="w-2 h-2 rounded-full" :class="tpl.is_active ? 'bg-green-500' : 'bg-gray-400'"></span> <span class="w-2.5 h-2.5 rounded-full mt-1" :class="tpl.is_active ? 'bg-green-500' : 'bg-gray-400'"
:title="tpl.is_active ? 'Active' : 'Inactive'"></span>
</div> </div>
<p x-show="tpl.subject_template" class="text-xs text-gray-500 mb-2 truncate" x-text="'Subject: ' + tpl.subject_template"></p> <p x-show="tpl.subject_template" class="text-xs text-gray-500 dark:text-gray-400 mb-2 truncate" x-text="'Subject: ' + tpl.subject_template"></p>
<p class="text-xs text-gray-400 line-clamp-2" x-text="tpl.body_template"></p> <p class="text-xs text-gray-400 line-clamp-2" x-text="tpl.body_template"></p>
<div class="flex justify-end mt-3 space-x-2"> <div class="flex justify-end mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 space-x-2">
<button @click="editTemplate(tpl)" class="text-xs text-purple-600 hover:text-purple-900 dark:text-purple-400">Edit</button> <button type="button" @click="editTemplate(tpl)"
<button @click="deleteTemplate(tpl.id)" class="text-xs text-red-600 hover:text-red-900 dark:text-red-400">Delete</button> class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Edit template">
<span x-html="$icon('edit', 'w-4 h-4')"></span>
</button>
<button type="button" @click="deleteTemplate(tpl.id)"
class="flex items-center justify-center p-2 text-red-600 rounded-lg hover:bg-red-50 dark:text-red-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Delete template">
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
<!-- Create/Edit Template Modal --> {# noqa: FE-004 #} <!-- Create/Edit Template Modal -->
<div x-show="showCreateModal || showEditModal" x-cloak {% call modal('templateModal', 'New Template', show_var='showCreateModal || showEditModal', size='lg', show_footer=false) %}
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false; showEditModal = false">
<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-2xl max-h-[90vh] overflow-y-auto"
@keydown.escape.window="showCreateModal = false; showEditModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"
x-text="showEditModal ? 'Edit Template' : 'New Template'"></h3>
<button @click="showCreateModal = false; showEditModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="templateForm.name" <input type="text" x-model="templateForm.name"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Lead Type</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Lead Type</label>
<select x-model="templateForm.lead_type" <select x-model="templateForm.lead_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<template x-for="lt in leadTypes" :key="lt.value"> <template x-for="lt in leadTypes" :key="lt.value">
<option :value="lt.value" x-text="lt.label"></option> <option :value="lt.value" x-text="lt.label"></option>
</template> </template>
</select> </select>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Channel</label>
<select x-model="templateForm.channel" <select x-model="templateForm.channel"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="email">Email</option> <option value="email">Email</option>
<option value="letter">Letter</option> <option value="letter">Letter</option>
<option value="phone_script">Phone Script</option> <option value="phone_script">Phone Script</option>
</select> </select>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Language</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Language</label>
<select x-model="templateForm.language" <select x-model="templateForm.language"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="fr">French</option> <option value="fr">French</option>
<option value="de">German</option> <option value="de">German</option>
<option value="en">English</option> <option value="en">English</option>
@@ -101,41 +116,43 @@
</div> </div>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Subject</label>
<input type="text" x-model="templateForm.subject_template" <input type="text" x-model="templateForm.subject_template"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Body</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Body</label>
<textarea x-model="templateForm.body_template" rows="10" <textarea x-model="templateForm.body_template" rows="10"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 font-mono"></textarea> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300 font-mono"></textarea>
<div class="mt-1 flex flex-wrap gap-1"> <div class="mt-2 flex flex-wrap gap-1">
<span class="text-xs text-gray-400">Placeholders:</span> <span class="text-xs text-gray-400">Placeholders:</span>
<template x-for="ph in placeholders" :key="ph"> <template x-for="ph in placeholders" :key="ph">
<button @click="insertPlaceholder(ph)" <button type="button" @click="insertPlaceholder(ph)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400" class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 px-1 py-0.5 rounded hover:bg-purple-50 dark:hover:bg-gray-700 transition-colors"
x-text="ph"></button> x-text="ph"></button>
</template> </template>
</div> </div>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" x-model="templateForm.is_active" id="tpl-active" <input type="checkbox" x-model="templateForm.is_active" id="tpl-active"
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500"> class="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<label for="tpl-active" class="ml-2 text-sm text-gray-600 dark:text-gray-400">Active</label> <label for="tpl-active" class="ml-2 text-sm text-gray-700 dark:text-gray-400">Active</label>
</div> </div>
</div> </div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showCreateModal = false; showEditModal = false" <!-- Footer Actions -->
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> <div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button type="button" @click="showCreateModal = false; showEditModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel Cancel
</button> </button>
<button @click="saveTemplate()" <button type="button" @click="saveTemplate()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple transition-colors duration-150">
Save <span x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-text="showEditModal ? 'Update Template' : 'Create Template'"></span>
</button> </button>
</footer>
</div> </div>
</div> {% endcall %}
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -1,130 +1,145 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state %}
{% block title %}Quick Capture{% endblock %} {% block title %}Quick Capture{% endblock %}
{% block alpine_data %}quickCapture(){% endblock %} {% block alpine_data %}quickCapture(){% endblock %}
{% block content %} {% block content %}
{{ page_header('Quick Capture') }} {{ page_header('Quick Capture', subtitle='Capture offline prospects on the go') }}
<div class="max-w-lg mx-auto"> <!-- Success Message -->
<!-- Success Message --> <div x-show="saved" x-transition
<div x-show="saved" x-transition class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg dark:bg-green-900 dark:text-green-300 max-w-2xl mx-auto">
class="mb-4 p-4 bg-green-100 text-green-700 rounded-lg dark:bg-green-900 dark:text-green-300">
<span x-html="$icon('check-circle', 'w-5 h-5 inline mr-2')"></span> <span x-html="$icon('check-circle', 'w-5 h-5 inline mr-2')"></span>
<span x-text="'Prospect saved: ' + lastSaved"></span> <span x-text="'Prospect saved: ' + lastSaved"></span>
</div> </div>
<div class="max-w-2xl mx-auto">
<div class="px-4 py-5 sm:p-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800 space-y-5">
<!-- Business Name --> <!-- Business Name -->
<div> <div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Business Name *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="form.business_name" required autofocus <input type="text" x-model="form.business_name" required autofocus
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300 py-3"> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"
placeholder="e.g. Boulangerie Schmidt">
</div> </div>
<!-- Phone + Email Row --> <!-- Phone + Email Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Phone</label>
<input type="tel" x-model="form.phone" placeholder="+352..." <input type="tel" x-model="form.phone" placeholder="+352..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Email</label>
<input type="email" x-model="form.email" placeholder="contact@..." <input type="email" x-model="form.email" placeholder="contact@..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
</div> </div>
<!-- Address --> <!-- Address -->
<div> <div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Address</label>
<input type="text" x-model="form.address" <input type="text" x-model="form.address" placeholder="Street and number"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<!-- City + Postal Code --> <!-- City + Postal Code -->
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4 mb-5">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">City</label>
<input type="text" x-model="form.city" placeholder="Luxembourg" <input type="text" x-model="form.city" placeholder="Luxembourg"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Postal Code</label>
<input type="text" x-model="form.postal_code" <input type="text" x-model="form.postal_code" placeholder="L-1234"
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
</div> </div>
<!-- Source --> <!-- Source -->
<div> <div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Source</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Source</label>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2"> <div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<template x-for="src in sources" :key="src.value"> <template x-for="src in sources" :key="src.value">
<button @click="form.source = src.value" <button type="button" @click="form.source = src.value"
:class="form.source === src.value ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600'" :class="form.source === src.value
class="px-3 py-2 text-sm font-medium rounded-lg border" ? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors duration-150"
x-text="src.label"></button> x-text="src.label"></button>
</template> </template>
</div> </div>
</div> </div>
<!-- Tags --> <!-- Tags -->
<div> <div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Tags</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<template x-for="tag in availableTags" :key="tag"> <template x-for="tag in availableTags" :key="tag">
<button @click="toggleTag(tag)" <button type="button" @click="toggleTag(tag)"
:class="form.tags.includes(tag) ? 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300' : 'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400'" :class="form.tags.includes(tag)
class="px-3 py-1.5 text-xs font-medium rounded-full border" ? 'bg-purple-100 text-purple-700 border-purple-300 dark:bg-purple-900 dark:text-purple-300 dark:border-purple-700'
: 'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'"
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors duration-150"
x-text="tag"></button> x-text="tag"></button>
</template> </template>
</div> </div>
</div> </div>
<!-- Notes --> <!-- Notes -->
<div> <div class="mb-5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Notes</label>
<textarea x-model="form.notes" rows="3" placeholder="Quick notes..." <textarea x-model="form.notes" rows="3" placeholder="Quick notes..."
class="w-full text-base rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 py-3"></textarea> class="w-full px-3 py-2.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div> </div>
<!-- Location --> <!-- Location + Submit Row -->
<div class="flex items-center space-x-3"> <div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button @click="getLocation()" <button type="button" @click="getLocation()"
:disabled="gettingLocation" :disabled="gettingLocation"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700 disabled:opacity-50"> class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray disabled:opacity-50">
<span x-html="$icon('location-marker', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('location-marker', 'w-4 h-4 mr-2')"></span>
<span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span> <span x-text="gettingLocation ? 'Getting location...' : (form.location_lat ? 'Location saved' : 'Get Location')"></span>
</button> </button>
<span x-show="form.location_lat" class="text-xs text-green-600"> <span x-show="form.location_lat" class="text-xs text-green-600 dark:text-green-400 self-center">
<span x-text="form.location_lat?.toFixed(4)"></span>, <span x-text="form.location_lng?.toFixed(4)"></span> <span x-text="form.location_lat?.toFixed(4)"></span>, <span x-text="form.location_lng?.toFixed(4)"></span>
</span> </span>
</div>
<!-- Submit --> <div class="sm:ml-auto">
<button @click="submitCapture()" <button type="button" @click="submitCapture()"
:disabled="submitting || !form.business_name" :disabled="submitting || !form.business_name"
class="w-full px-6 py-4 text-lg font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 active:bg-purple-800"> class="w-full sm:w-auto inline-flex items-center justify-center px-6 py-2.5 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 disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!submitting">Save & Capture Next</span> <span x-show="!submitting" x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
<span x-show="submitting">Saving...</span> <span x-show="submitting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="submitting ? 'Saving...' : 'Save & Capture Next'"></span>
</button> </button>
</div> </div>
</div>
</div>
<!-- Recent Captures --> <!-- Recent Captures -->
<div x-show="recentCaptures.length > 0" class="mt-6"> <div x-show="recentCaptures.length > 0" class="mt-6">
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2"> <h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3">
Recent Captures (<span x-text="recentCaptures.length"></span>) Recent Captures (<span x-text="recentCaptures.length"></span>)
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="cap in recentCaptures" :key="cap.id"> <template x-for="cap in recentCaptures" :key="cap.id">
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm dark:bg-gray-800"> <div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300"
x-text="cap.business_name.charAt(0).toUpperCase()"></span>
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cap.business_name"></span> <span class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="cap.business_name"></span>
<span class="text-xs text-gray-500" x-text="cap.city || cap.source"></span> </div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="cap.city || cap.source"></span>
</div> </div>
</template> </template>
</div> </div>

View File

@@ -1,5 +1,5 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header, section_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Prospecting Dashboard{% endblock %} {% block title %}Prospecting Dashboard{% endblock %}
@@ -14,7 +14,7 @@
<div x-show="!loading && !error" class="space-y-6"> <div x-show="!loading && !error" class="space-y-6">
<!-- KPI Cards --> <!-- KPI Cards -->
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4"> <div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500"> <div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('globe', 'w-5 h-5')"></span> <span x-html="$icon('globe', 'w-5 h-5')"></span>
</div> </div>
@@ -23,7 +23,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_prospects || 0"></p> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_prospects || 0"></p>
</div> </div>
</div> </div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500"> <div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('cursor-click', 'w-5 h-5')"></span> <span x-html="$icon('cursor-click', 'w-5 h-5')"></span>
</div> </div>
@@ -32,7 +32,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.top_priority || 0"></p> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.top_priority || 0"></p>
</div> </div>
</div> </div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500"> <div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('chart-bar', 'w-5 h-5')"></span> <span x-html="$icon('chart-bar', 'w-5 h-5')"></span>
</div> </div>
@@ -41,7 +41,7 @@
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.avg_score ? stats.avg_score.toFixed(1) : '—'"></p> <p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.avg_score ? stats.avg_score.toFixed(1) : '—'"></p>
</div> </div>
</div> </div>
<div class="flex items-center p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500"> <div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('phone', 'w-5 h-5')"></span> <span x-html="$icon('phone', 'w-5 h-5')"></span>
</div> </div>
@@ -53,41 +53,44 @@
</div> </div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="flex flex-wrap gap-3 mb-6"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
{{ section_header('Quick Actions', icon='cursor-click') }}
<div class="flex flex-wrap gap-3">
<a href="/admin/prospecting/capture" <a href="/admin/prospecting/capture"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"> class="inline-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">
<span x-html="$icon('phone', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('phone', 'w-4 h-4 mr-2')"></span>
Quick Capture Quick Capture
</a> </a>
<button @click="runBatchScan()" <button type="button" @click="runBatchScan()"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
<span x-html="$icon('globe-alt', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('globe-alt', 'w-4 h-4 mr-2')"></span>
Run Batch Scan Run Batch Scan
</button> </button>
<button @click="showImportModal = true" <button type="button" @click="showImportModal = true"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
<span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
Import Domains Import Domains
</button> </button>
</div> </div>
</div>
<!-- Leads by Tier --> <!-- Leads by Tier + By Channel -->
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Leads by Tier</h3> {{ section_header('Leads by Tier', icon='chart-bar') }}
<div class="space-y-3"> <div class="space-y-3">
<template x-for="tier in ['top_priority', 'quick_win', 'strategic', 'low_priority']" :key="tier"> <template x-for="tier in ['top_priority', 'quick_win', 'strategic', 'low_priority']" :key="tier">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="tier.replace('_', ' ')"></span> <span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="tier.replace('_', ' ')"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="tierBadgeClass(tier)" :class="tierBadgeClass(tier)"
x-text="stats.leads_by_tier?.[tier] || 0"></span> x-text="stats.leads_by_tier?.[tier] || 0"></span>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">By Channel</h3> {{ section_header('By Channel', icon='globe') }}
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Digital (Domain Scan)</span> <span class="text-sm text-gray-600 dark:text-gray-400">Digital (Domain Scan)</span>
@@ -102,21 +105,22 @@
</div> </div>
<!-- Common Issues --> <!-- Common Issues -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Common Issues</h3> {{ section_header('Common Issues', icon='exclamation') }}
<div class="space-y-2"> <div class="space-y-3">
<template x-for="issue in stats.common_issues || []" :key="issue.flag"> <template x-for="issue in stats.common_issues || []" :key="issue.flag">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="issueLabel(issue.flag)"></span> <span class="text-sm text-gray-600 dark:text-gray-400" x-text="issueLabel(issue.flag)"></span>
<div class="flex items-center"> <div class="flex items-center">
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700 mr-2"> <div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700 mr-3">
<div class="h-2 bg-red-500 rounded-full" <div class="h-2 bg-red-500 rounded-full transition-all duration-300"
:style="'width: ' + (issue.count / (stats.total_prospects || 1) * 100) + '%'"></div> :style="'width: ' + (issue.count / (stats.total_prospects || 1) * 100) + '%'"></div>
</div> </div>
<span class="text-xs text-gray-500" x-text="issue.count"></span> <span class="text-xs font-medium text-gray-500 dark:text-gray-400 w-8 text-right" x-text="issue.count"></span>
</div> </div>
</div> </div>
</template> </template>
<p x-show="!stats.common_issues?.length" class="text-sm text-gray-400 text-center py-4">No issues data available</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %} {% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Leads{% endblock %} {% block title %}Leads{% endblock %}
@@ -10,38 +11,39 @@
{% block content %} {% block content %}
{{ page_header('Leads', action_label='Export CSV', action_onclick='exportCSV()', action_icon='download') }} {{ page_header('Leads', action_label='Export CSV', action_onclick='exportCSV()', action_icon='download') }}
{{ loading_state('Loading leads...') }}
{{ error_state('Error loading leads') }}
<!-- Filters --> <!-- Filters -->
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="grid gap-4 md:grid-cols-5"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <!-- Min Score -->
<label class="text-sm text-gray-600 dark:text-gray-400">Min Score</label> <div class="flex items-center gap-3">
<input type="number" x-model.number="minScore" @change="loadLeads()" min="0" max="100" {# noqa: FE008 - score filter, not a quantity stepper #} <label class="text-sm font-medium text-gray-700 dark:text-gray-400 whitespace-nowrap">Min Score</label>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> <input type="number" x-model.number="minScore" @change="pagination.page = 1; loadLeads()" min="0" max="100" {# noqa: FE008 - score filter, not a quantity stepper #}
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div>
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label> <!-- Filters -->
<select x-model="filterTier" @change="loadLeads()" <div class="flex flex-wrap gap-3">
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> <select x-model="filterTier" @change="pagination.page = 1; loadLeads()"
<option value="">All</option> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Tiers</option>
<option value="top_priority">Top Priority</option> <option value="top_priority">Top Priority</option>
<option value="quick_win">Quick Win</option> <option value="quick_win">Quick Win</option>
<option value="strategic">Strategic</option> <option value="strategic">Strategic</option>
</select> </select>
</div>
<div> <select x-model="filterChannel" @change="pagination.page = 1; loadLeads()"
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<select x-model="filterChannel" @change="loadLeads()" <option value="">All Channels</option>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="digital">Digital</option> <option value="digital">Digital</option>
<option value="offline">Offline</option> <option value="offline">Offline</option>
</select> </select>
</div>
<div> <select x-model="filterIssue" @change="pagination.page = 1; loadLeads()"
<label class="text-sm text-gray-600 dark:text-gray-400">Issue</label> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<select x-model="filterIssue" @change="loadLeads()" <option value="">All Issues</option>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Any</option>
<option value="no_ssl">No SSL</option> <option value="no_ssl">No SSL</option>
<option value="very_slow">Very Slow</option> <option value="very_slow">Very Slow</option>
<option value="not_mobile_friendly">Not Mobile Friendly</option> <option value="not_mobile_friendly">Not Mobile Friendly</option>
@@ -49,12 +51,10 @@
<option value="no_website">No Website</option> <option value="no_website">No Website</option>
<option value="uses_gmail">Uses Gmail</option> <option value="uses_gmail">Uses Gmail</option>
</select> </select>
</div>
<div> <select x-model="filterHasEmail" @change="pagination.page = 1; loadLeads()"
<label class="text-sm text-gray-600 dark:text-gray-400">Has Email</label> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<select x-model="filterHasEmail" @change="loadLeads()" <option value="">Has Email</option>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">Any</option>
<option value="true">Yes</option> <option value="true">Yes</option>
<option value="false">No</option> <option value="false">No</option>
</select> </select>
@@ -62,39 +62,44 @@
</div> </div>
</div> </div>
{{ loading_state('Loading leads...') }}
{{ error_state('Error loading leads') }}
<!-- Leads Table --> <!-- Leads Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow"> <div x-show="!loading && !error">
<div class="w-full overflow-x-auto"> {% call table_wrapper() %}
<table class="w-full whitespace-nowrap"> {{ table_header(['Business / Domain', 'Score', 'Tier', 'Issues', 'Contact', 'Actions']) }}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business / Domain</th>
<th class="px-4 py-3">Score</th>
<th class="px-4 py-3">Tier</th>
<th class="px-4 py-3">Issues</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No leads found', x_message="filterTier || filterChannel || filterIssue || filterHasEmail || minScore > 0 ? 'Try adjusting your filters' : 'No scored leads yet'", show_condition='leads.length === 0', icon='user-group') }}
<template x-for="lead in leads" :key="lead.id"> <template x-for="lead in leads" :key="lead.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"> <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Business / Domain with Avatar -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<p class="font-semibold text-sm" x-text="lead.business_name || lead.domain_name"></p> <div class="flex items-center text-sm">
<p class="text-xs text-gray-500" x-show="lead.domain_name && lead.business_name" x-text="lead.domain_name"></p> <div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100"
x-text="(lead.business_name || lead.domain_name)?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div>
<p class="font-semibold" x-text="lead.business_name || lead.domain_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="lead.domain_name && lead.business_name" x-text="lead.domain_name"></p>
</div>
</div>
</td> </td>
<!-- Score -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="text-lg font-bold" :class="scoreColor(lead.score)" <span class="text-lg font-bold" :class="scoreColor(lead.score)"
x-text="lead.score"></span> x-text="lead.score ?? '—'"></span>
</td> </td>
<!-- Tier Badge -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="tierBadgeClass(lead.lead_tier)" :class="tierBadgeClass(lead.lead_tier)"
x-text="lead.lead_tier?.replace('_', ' ')"></span> x-text="lead.lead_tier?.replace('_', ' ')"></span>
</td> </td>
<!-- Issues -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<template x-for="flag in (lead.reason_flags || []).slice(0, 3)" :key="flag"> <template x-for="flag in (lead.reason_flags || []).slice(0, 3)" :key="flag">
@@ -103,23 +108,34 @@
</template> </template>
</div> </div>
</td> </td>
<!-- Contact -->
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span x-text="lead.primary_email || lead.primary_phone || '—'" class="text-xs"></span> <span x-text="lead.primary_email || lead.primary_phone || '—'" class="text-xs"></span>
</td> </td>
<td class="px-4 py-3 text-sm space-x-2">
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a :href="'/admin/prospecting/prospects/' + lead.id" <a :href="'/admin/prospecting/prospects/' + lead.id"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400">View</a> class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a>
<button @click="sendCampaign(lead)" <button @click="sendCampaign(lead)"
class="text-green-600 hover:text-green-900 dark:text-green-400">Campaign</button> class="flex items-center justify-center p-2 text-green-600 rounded-lg hover:bg-green-50 dark:text-green-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Send campaign">
<span x-html="$icon('mail', 'w-5 h-5')"></span>
</button>
</div>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> {% endcall %}
</div>
</div>
{{ pagination() }} {{ pagination() }}
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import detail_page_header, section_header, tab_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Prospect Detail{% endblock %} {% block title %}Prospect Detail{% endblock %}
@@ -12,128 +13,132 @@
<div x-show="!loading && !error && prospect" class="space-y-6"> <div x-show="!loading && !error && prospect" class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex flex-col sm:flex-row sm:items-center justify-between my-6 gap-4">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="/admin/prospecting/prospects" <a href="/admin/prospecting/prospects"
class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"> class="flex items-center justify-center p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Back to prospects">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span> <span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a> </a>
<div class="relative hidden w-10 h-10 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-sm font-semibold text-purple-600 dark:text-purple-100"
x-text="(prospect.business_name || prospect.domain_name)?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div> <div>
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200" <h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
x-text="prospect.business_name || prospect.domain_name"></h2> x-text="prospect.business_name || prospect.domain_name"></h2>
<p class="text-sm text-gray-500" x-show="prospect.domain_name && prospect.business_name" <p class="text-sm text-gray-500 dark:text-gray-400" x-show="prospect.domain_name && prospect.business_name"
x-text="prospect.domain_name"></p> x-text="prospect.domain_name"></p>
</div> </div>
<span class="px-3 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="prospect.channel === 'digital' ? 'text-blue-700 bg-blue-100' : 'text-purple-700 bg-purple-100'" :class="prospect.channel === 'digital'
? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700'
: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
x-text="prospect.channel"></span> x-text="prospect.channel"></span>
</div> </div>
<div class="flex items-center space-x-3">
<!-- Score Badge --> <!-- Score Badge -->
<div x-show="prospect.score" class="text-center"> <div x-show="prospect.score" class="text-center">
<div class="text-3xl font-bold" :class="scoreColor(prospect.score?.score)" <div class="text-3xl font-bold" :class="scoreColor(prospect.score?.score)"
x-text="prospect.score?.score"></div> x-text="prospect.score?.score"></div>
<div class="text-xs text-gray-500 uppercase" <div class="text-xs text-gray-500 dark:text-gray-400 uppercase"
x-text="prospect.score?.lead_tier?.replace('_', ' ')"></div> x-text="prospect.score?.lead_tier?.replace('_', ' ')"></div>
</div> </div>
</div> </div>
</div>
<!-- Status Bar --> <!-- Status Bar -->
<div class="flex items-center space-x-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<label class="text-sm text-gray-600 dark:text-gray-400">Status:</label> <div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-700 dark:text-gray-400">Status:</label>
<select x-model="prospect.status" @change="updateStatus()" <select x-model="prospect.status" @change="updateStatus()"
class="text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="contacted">Contacted</option> <option value="contacted">Contacted</option>
<option value="converted">Converted</option> <option value="converted">Converted</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</select> </select>
<button @click="runEnrichment()" x-show="prospect.channel === 'digital'" </div>
class="ml-auto px-3 py-1 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700"> <button type="button" @click="runEnrichment()" x-show="prospect.channel === 'digital'"
<span x-html="$icon('globe-alt', 'w-4 h-4 inline mr-1')"></span> class="sm:ml-auto inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
<span x-html="$icon('globe-alt', 'w-4 h-4 mr-2')"></span>
Run Scan Run Scan
</button> </button>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700"> {{ tab_header([
<nav class="flex -mb-px space-x-8"> {'id': 'overview', 'label': 'Overview', 'icon': 'eye'},
<template x-for="tab in tabs" :key="tab.id"> {'id': 'interactions', 'label': 'Interactions', 'icon': 'chat'},
<button @click="activeTab = tab.id" {'id': 'campaigns', 'label': 'Campaigns', 'icon': 'mail'},
:class="activeTab === tab.id ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'" ], active_var='activeTab') }}
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
x-text="tab.label"></button>
</template>
</nav>
</div>
<!-- Tab: Overview --> <!-- Tab: Overview -->
<div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2"> <div x-show="activeTab === 'overview'" class="grid gap-6 md:grid-cols-2">
<!-- Contact Info --> <!-- Contact Info -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3> {{ section_header('Contact Info', icon='phone') }}
<template x-for="c in prospect.contacts || []" :key="c.id"> <template x-for="c in prospect.contacts || []" :key="c.id">
<div class="flex items-center justify-between py-2 border-b dark:border-gray-700 last:border-0"> <div class="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span class="text-xs text-gray-500 uppercase" x-text="c.contact_type"></span> <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" x-text="c.contact_type"></span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="c.value"></span> <span class="text-sm text-gray-700 dark:text-gray-300" x-text="c.value"></span>
</div> </div>
</template> </template>
<p x-show="!prospect.contacts?.length" class="text-sm text-gray-400">No contacts found</p> <p x-show="!prospect.contacts?.length" class="text-sm text-gray-400 text-center py-4">No contacts found</p>
</div> </div>
<!-- Score Breakdown --> <!-- Score Breakdown -->
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Score Breakdown</h3> {{ section_header('Score Breakdown', icon='chart-bar') }}
<template x-if="prospect.score"> <template x-if="prospect.score">
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Technical Health</span> <span class="text-gray-600 dark:text-gray-400">Technical Health</span>
<span class="font-semibold" x-text="prospect.score.technical_health_score + '/40'"></span> <span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.technical_health_score + '/40'"></span>
</div> </div>
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Modernity</span> <span class="text-gray-600 dark:text-gray-400">Modernity</span>
<span class="font-semibold" x-text="prospect.score.modernity_score + '/25'"></span> <span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.modernity_score + '/25'"></span>
</div> </div>
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Business Value</span> <span class="text-gray-600 dark:text-gray-400">Business Value</span>
<span class="font-semibold" x-text="prospect.score.business_value_score + '/25'"></span> <span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.business_value_score + '/25'"></span>
</div> </div>
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Engagement</span> <span class="text-gray-600 dark:text-gray-400">Engagement</span>
<span class="font-semibold" x-text="prospect.score.engagement_score + '/10'"></span> <span class="font-semibold text-gray-700 dark:text-gray-200" x-text="prospect.score.engagement_score + '/10'"></span>
</div> </div>
<div class="pt-3 border-t dark:border-gray-700"> <div class="pt-3 border-t border-gray-100 dark:border-gray-700">
<h4 class="text-xs font-semibold text-gray-500 uppercase mb-2">Issues</h4> <h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Issues</h4>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<template x-for="flag in prospect.score.reason_flags || []" :key="flag"> <template x-for="flag in prospect.score.reason_flags || []" :key="flag">
<span class="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full dark:bg-red-900 dark:text-red-300" <span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full dark:bg-red-900 dark:text-red-300"
x-text="flag.replace('_', ' ')"></span> x-text="flag.replace('_', ' ')"></span>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<p x-show="!prospect.score" class="text-sm text-gray-400">Not scored yet</p> <p x-show="!prospect.score" class="text-sm text-gray-400 text-center py-4">Not scored yet</p>
</div> </div>
<!-- Tech Profile Summary --> <!-- Tech Profile Summary -->
<div x-show="prospect.tech_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div x-show="prospect.tech_profile" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Technology</h3> {{ section_header('Technology', icon='code') }}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<template x-for="[key, val] in techProfileEntries()" :key="key"> <template x-for="[key, val] in techProfileEntries()" :key="key">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400 capitalize" x-text="key.replace('_', ' ')"></span> <span class="text-gray-600 dark:text-gray-400" x-text="key"></span>
<span class="text-gray-700 dark:text-gray-300" x-text="val"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="val"></span>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<!-- Performance Summary --> <!-- Performance Summary -->
<div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div x-show="prospect.performance_profile" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Performance</h3> {{ section_header('Performance', icon='chart-bar') }}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Performance Score</span> <span class="text-gray-600 dark:text-gray-400">Performance Score</span>
@@ -143,11 +148,12 @@
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Mobile Friendly</span> <span class="text-gray-600 dark:text-gray-400">Mobile Friendly</span>
<span x-text="prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'" <span x-text="prospect.performance_profile?.is_mobile_friendly ? 'Yes' : 'No'"
:class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600' : 'text-red-600'"></span> :class="prospect.performance_profile?.is_mobile_friendly ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
class="font-medium"></span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">SEO Score</span> <span class="text-gray-600 dark:text-gray-400">SEO Score</span>
<span x-text="prospect.performance_profile?.seo_score ?? '—'"></span> <span class="font-medium text-gray-700 dark:text-gray-300" x-text="prospect.performance_profile?.seo_score ?? '—'"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -156,25 +162,25 @@
<!-- Tab: Interactions --> <!-- Tab: Interactions -->
<div x-show="activeTab === 'interactions'" class="space-y-4"> <div x-show="activeTab === 'interactions'" class="space-y-4">
<div class="flex justify-end"> <div class="flex justify-end">
<button @click="showInteractionModal = true" <button type="button" @click="showInteractionModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"> class="inline-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">
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span> <span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
Log Interaction Log Interaction
</button> </button>
</div> </div>
<!-- Timeline --> <!-- Timeline -->
<div class="space-y-4"> <div class="space-y-4">
<template x-for="interaction in interactions" :key="interaction.id"> <template x-for="interaction in interactions" :key="interaction.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800 border-l-4" <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border-l-4 transition-shadow hover:shadow-md"
:class="interaction.outcome === 'positive' ? 'border-green-500' : interaction.outcome === 'negative' ? 'border-red-500' : 'border-gray-300'"> :class="interaction.outcome === 'positive' ? 'border-green-500' : interaction.outcome === 'negative' ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
x-text="interaction.interaction_type.replace('_', ' ')"></span> x-text="interaction.interaction_type.replace('_', ' ')"></span>
<span class="text-xs text-gray-500" x-text="new Date(interaction.created_at).toLocaleDateString()"></span> <span class="text-xs text-gray-500 dark:text-gray-400" x-text="new Date(interaction.created_at).toLocaleDateString()"></span>
</div> </div>
<p x-show="interaction.subject" class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="interaction.subject"></p> <p x-show="interaction.subject" class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="interaction.subject"></p>
<p x-show="interaction.notes" class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="interaction.notes"></p> <p x-show="interaction.notes" class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="interaction.notes"></p>
<p x-show="interaction.next_action" class="text-xs text-purple-600 mt-2"> <p x-show="interaction.next_action" class="text-xs text-purple-600 dark:text-purple-400 mt-2">
Next: <span x-text="interaction.next_action"></span> Next: <span x-text="interaction.next_action"></span>
<span x-show="interaction.next_action_date" x-text="' — ' + interaction.next_action_date"></span> <span x-show="interaction.next_action_date" x-text="' — ' + interaction.next_action_date"></span>
</p> </p>
@@ -187,44 +193,36 @@
<!-- Tab: Campaigns --> <!-- Tab: Campaigns -->
<div x-show="activeTab === 'campaigns'" class="space-y-4"> <div x-show="activeTab === 'campaigns'" class="space-y-4">
<div class="flex justify-end"> <div class="flex justify-end">
<button @click="showSendCampaignModal = true" <button type="button" @click="showSendCampaignModal = true"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
<span x-html="$icon('mail', 'w-4 h-4 inline mr-1')"></span> <span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Send Campaign Send Campaign
</button> </button>
</div> </div>
<template x-for="send in campaignSends" :key="send.id"> <template x-for="send in campaignSends" :key="send.id">
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800"> <div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="send.rendered_subject || 'No subject'"></span> <span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="send.rendered_subject || 'No subject'"></span>
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="send.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'" :class="send.status === 'sent'
? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="send.status"></span> x-text="send.status"></span>
</div> </div>
<p class="text-xs text-gray-500 mt-1" x-text="send.sent_at ? new Date(send.sent_at).toLocaleString() : 'Draft'"></p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1" x-text="send.sent_at ? new Date(send.sent_at).toLocaleString() : 'Draft'"></p>
</div> </div>
</template> </template>
<p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p> <p x-show="campaignSends.length === 0" class="text-sm text-gray-400 text-center py-8">No campaigns sent yet</p>
</div> </div>
</div> </div>
<!-- Interaction Modal --> {# noqa: FE-004 #} <!-- Interaction Modal -->
<div x-show="showInteractionModal" x-cloak {% call modal('interactionModal', 'Log Interaction', show_var='showInteractionModal', size='md', show_footer=false) %}
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showInteractionModal = false">
<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"
@keydown.escape.window="showInteractionModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Log Interaction</h3>
<button @click="showInteractionModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Type</label>
<select x-model="newInteraction.interaction_type" <select x-model="newInteraction.interaction_type"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="note">Note</option> <option value="note">Note</option>
<option value="call">Phone Call</option> <option value="call">Phone Call</option>
<option value="email_sent">Email Sent</option> <option value="email_sent">Email Sent</option>
@@ -235,19 +233,19 @@
</select> </select>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Subject</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Subject</label>
<input type="text" x-model="newInteraction.subject" <input type="text" x-model="newInteraction.subject"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Notes</label>
<textarea x-model="newInteraction.notes" rows="3" <textarea x-model="newInteraction.notes" rows="3"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Outcome</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Outcome</label>
<select x-model="newInteraction.outcome" <select x-model="newInteraction.outcome"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="">Not specified</option> <option value="">Not specified</option>
<option value="positive">Positive</option> <option value="positive">Positive</option>
<option value="neutral">Neutral</option> <option value="neutral">Neutral</option>
@@ -256,23 +254,25 @@
</select> </select>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Next Action</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Next Action</label>
<input type="text" x-model="newInteraction.next_action" placeholder="Follow up by..." <input type="text" x-model="newInteraction.next_action" placeholder="Follow up by..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
</div> </div>
<footer class="flex justify-end mt-6 space-x-3">
<button @click="showInteractionModal = false" <!-- Footer Actions -->
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> <div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<button type="button" @click="showInteractionModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel Cancel
</button> </button>
<button @click="createInteraction()" <button type="button" @click="createInteraction()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple transition-colors duration-150">
Save <span x-html="$icon('check', 'w-4 h-4 mr-2')"></span>
Save Interaction
</button> </button>
</footer>
</div> </div>
</div> {% endcall %}
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -1,8 +1,9 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_header, table_empty_state %} {% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %} {% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/modals.html' import modal %}
{% block title %}Prospects{% endblock %} {% block title %}Prospects{% endblock %}
@@ -11,41 +12,46 @@
{% block content %} {% block content %}
{{ page_header('Prospects', action_label='New Prospect', action_onclick='showCreateModal = true', action_icon='plus') }} {{ page_header('Prospects', action_label='New Prospect', action_onclick='showCreateModal = true', action_icon='plus') }}
<!-- Filters --> {{ loading_state('Loading prospects...') }}
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800"> {{ error_state('Error loading prospects') }}
<div class="grid gap-4 md:grid-cols-4">
<div> <!-- Search and Filters Bar -->
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label> <div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<input type="text" x-model="search" @input.debounce.300ms="loadProspects()" <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
placeholder="Domain or business name..." <!-- Search Input -->
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 focus:border-purple-400 focus:ring-purple-300"> <div class="flex-1 max-w-md">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input type="text" x-model="search" @input.debounce.300ms="pagination.page = 1; loadProspects()"
placeholder="Search by domain or business name..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> </div>
<label class="text-sm text-gray-600 dark:text-gray-400">Channel</label>
<select x-model="filterChannel" @change="loadProspects()" <!-- Filters -->
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> <div class="flex flex-wrap gap-3">
<option value="">All</option> <select x-model="filterChannel" @change="pagination.page = 1; loadProspects()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<option value="">All Channels</option>
<option value="digital">Digital</option> <option value="digital">Digital</option>
<option value="offline">Offline</option> <option value="offline">Offline</option>
</select> </select>
</div>
<div> <select x-model="filterStatus" @change="pagination.page = 1; loadProspects()"
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<select x-model="filterStatus" @change="loadProspects()" <option value="">All Status</option>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="contacted">Contacted</option> <option value="contacted">Contacted</option>
<option value="converted">Converted</option> <option value="converted">Converted</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</select> </select>
</div>
<div> <select x-model="filterTier" @change="pagination.page = 1; loadProspects()"
<label class="text-sm text-gray-600 dark:text-gray-400">Tier</label> class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none">
<select x-model="filterTier" @change="loadProspects()" <option value="">All Tiers</option>
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
<option value="">All</option>
<option value="top_priority">Top Priority</option> <option value="top_priority">Top Priority</option>
<option value="quick_win">Quick Win</option> <option value="quick_win">Quick Win</option>
<option value="strategic">Strategic</option> <option value="strategic">Strategic</option>
@@ -55,102 +61,105 @@
</div> </div>
</div> </div>
{{ loading_state('Loading prospects...') }}
{{ error_state('Error loading prospects') }}
<!-- Prospects Table --> <!-- Prospects Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow"> <div x-show="!loading && !error">
<div class="w-full overflow-x-auto"> {% call table_wrapper() %}
<table class="w-full whitespace-nowrap"> {{ table_header(['Business / Domain', 'Channel', 'Status', 'Score', 'Tier', 'Contact', 'Actions']) }}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Business / Domain</th>
<th class="px-4 py-3">Channel</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Score</th>
<th class="px-4 py-3">Tier</th>
<th class="px-4 py-3">Contact</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(7, title='No prospects found', show_condition='prospects.length === 0') }} {{ table_empty_state(7, title='No prospects found', x_message="search || filterChannel || filterStatus || filterTier ? 'Try adjusting your search or filters' : 'Create your first prospect to get started'", show_condition='prospects.length === 0', icon='user-group') }}
<template x-for="p in prospects" :key="p.id"> <template x-for="p in prospects" :key="p.id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"> <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<!-- Business / Domain with Avatar -->
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center text-sm"> <div class="flex items-center text-sm">
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-600 flex items-center justify-center">
<span class="text-xs font-semibold text-purple-600 dark:text-purple-100"
x-text="(p.business_name || p.domain_name)?.charAt(0).toUpperCase() || '?'"></span>
</div>
</div>
<div> <div>
<p class="font-semibold" x-text="p.business_name || p.domain_name"></p> <p class="font-semibold" x-text="p.business_name || p.domain_name"></p>
<p class="text-xs text-gray-600 dark:text-gray-400" x-show="p.domain_name && p.business_name" x-text="p.domain_name"></p> <p class="text-xs text-gray-600 dark:text-gray-400" x-show="p.domain_name && p.business_name" x-text="p.domain_name"></p>
</div> </div>
</div> </div>
</td> </td>
<!-- Channel Badge -->
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="p.channel === 'digital' ? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700' : 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'" :class="p.channel === 'digital'
? 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700'
: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700'"
x-text="p.channel"></span> x-text="p.channel"></span>
</td> </td>
<!-- Status Badge -->
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="statusBadgeClass(p.status)" :class="statusBadgeClass(p.status)"
x-text="p.status"></span> x-text="p.status"></span>
</td> </td>
<!-- Score -->
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span x-text="p.score?.score ?? '—'" class="font-semibold" <span x-text="p.score?.score ?? '—'" class="font-semibold"
:class="scoreColor(p.score?.score)"></span> :class="scoreColor(p.score?.score)"></span>
</td> </td>
<!-- Tier Badge -->
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<span x-show="p.score?.lead_tier" <span x-show="p.score?.lead_tier"
class="px-2 py-1 text-xs font-semibold rounded-full" class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="tierBadgeClass(p.score?.lead_tier)" :class="tierBadgeClass(p.score?.lead_tier)"
x-text="p.score?.lead_tier?.replace('_', ' ')"></span> x-text="p.score?.lead_tier?.replace('_', ' ')"></span>
<span x-show="!p.score?.lead_tier" class="text-gray-400"></span> <span x-show="!p.score?.lead_tier" class="text-gray-400"></span>
</td> </td>
<!-- Contact -->
<td class="px-4 py-3 text-sm"> <td class="px-4 py-3 text-sm">
<template x-if="p.primary_email"> <template x-if="p.primary_email">
<span class="text-xs" x-text="p.primary_email"></span> <span class="text-xs" x-text="p.primary_email"></span>
</template> </template>
<span x-show="!p.primary_email" class="text-gray-400"></span> <span x-show="!p.primary_email" class="text-gray-400"></span>
</td> </td>
<td class="px-4 py-3 text-sm">
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
<a :href="'/admin/prospecting/prospects/' + p.id" <a :href="'/admin/prospecting/prospects/' + p.id"
class="text-purple-600 hover:text-purple-900 dark:text-purple-400 dark:hover:text-purple-300"> class="flex items-center justify-center p-2 text-purple-600 rounded-lg hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-gray-700 focus:outline-none transition-colors"
View title="View details">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</a> </a>
</div>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> {% endcall %}
</div>
</div> {{ pagination() }}
</div>
{{ pagination() }}
<!-- Create Prospect Modal --> {# noqa: FE-004 #}
<div x-show="showCreateModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showCreateModal = false">
<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"
@keydown.escape.window="showCreateModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">New Prospect</h3>
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<!-- Create Prospect Modal -->
{% call modal('createProspectModal', 'New Prospect', show_var='showCreateModal', size='md', show_footer=false) %}
<!-- Channel Toggle --> <!-- Channel Toggle -->
<div class="flex mb-4 space-x-2"> <div class="flex mb-5 space-x-2">
<button @click="newProspect.channel = 'digital'" <button type="button" @click="newProspect.channel = 'digital'"
:class="newProspect.channel === 'digital' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'" :class="newProspect.channel === 'digital'
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg"> ? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg border transition-colors duration-150">
<span x-html="$icon('globe', 'w-4 h-4 inline mr-1')"></span>
Digital (Domain) Digital (Domain)
</button> </button>
<button @click="newProspect.channel = 'offline'" <button type="button" @click="newProspect.channel = 'offline'"
:class="newProspect.channel === 'offline' ? 'bg-purple-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'" :class="newProspect.channel === 'offline'
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg"> ? 'bg-purple-600 text-white border-purple-600'
: 'bg-white text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'"
class="flex-1 px-4 py-2 text-sm font-medium rounded-lg border transition-colors duration-150">
<span x-html="$icon('office-building', 'w-4 h-4 inline mr-1')"></span>
Offline (Manual) Offline (Manual)
</button> </button>
</div> </div>
@@ -158,40 +167,44 @@
<!-- Digital Fields --> <!-- Digital Fields -->
<div x-show="newProspect.channel === 'digital'" class="space-y-4"> <div x-show="newProspect.channel === 'digital'" class="space-y-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Domain Name</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Domain Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="newProspect.domain_name" placeholder="example.lu" <input type="text" x-model="newProspect.domain_name" placeholder="example.lu"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
</div> </div>
<!-- Offline Fields --> <!-- Offline Fields -->
<div x-show="newProspect.channel === 'offline'" class="space-y-4"> <div x-show="newProspect.channel === 'offline'" class="space-y-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Business Name *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Business Name <span class="text-red-500">*</span>
</label>
<input type="text" x-model="newProspect.business_name" placeholder="Business name" <input type="text" x-model="newProspect.business_name" placeholder="Business name"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Phone</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Phone</label>
<input type="tel" x-model="newProspect.phone" placeholder="+352..." <input type="tel" x-model="newProspect.phone" placeholder="+352..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Email</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Email</label>
<input type="email" x-model="newProspect.email" placeholder="contact@..." <input type="email" x-model="newProspect.email" placeholder="contact@..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">City</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">City</label>
<input type="text" x-model="newProspect.city" placeholder="Luxembourg" <input type="text" x-model="newProspect.city" placeholder="Luxembourg"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Source</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Source</label>
<select x-model="newProspect.source" <select x-model="newProspect.source"
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300">
<option value="street">Street</option> <option value="street">Street</option>
<option value="networking_event">Networking Event</option> <option value="networking_event">Networking Event</option>
<option value="referral">Referral</option> <option value="referral">Referral</option>
@@ -199,26 +212,27 @@
</select> </select>
</div> </div>
<div> <div>
<label class="text-sm text-gray-600 dark:text-gray-400">Notes</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Notes</label>
<textarea x-model="newProspect.notes" rows="3" placeholder="Any notes about this business..." <textarea x-model="newProspect.notes" rows="3" placeholder="Any notes about this business..."
class="w-full mt-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"></textarea> class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray dark:bg-gray-700 dark:text-gray-300"></textarea>
</div> </div>
</div> </div>
<footer class="flex justify-end mt-6 space-x-3"> <!-- Footer Actions -->
<button @click="showCreateModal = false" <div class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 dark:text-gray-300 dark:bg-gray-700"> <button type="button" @click="showCreateModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Cancel Cancel
</button> </button>
<button @click="createProspect()" <button type="button" @click="createProspect()"
:disabled="creating" :disabled="creating"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"> class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150">
<span x-show="!creating">Create</span> <span x-show="!creating" x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
<span x-show="creating">Creating...</span> <span x-show="creating" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-text="creating ? 'Creating...' : 'Create Prospect'"></span>
</button> </button>
</footer>
</div> </div>
</div> {% endcall %}
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -1,6 +1,7 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %} {% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %} {% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% from 'shared/macros/pagination.html' import pagination %} {% from 'shared/macros/pagination.html' import pagination %}
{% block title %}Scan Jobs{% endblock %} {% block title %}Scan Jobs{% endblock %}
@@ -11,82 +12,79 @@
{{ page_header('Scan Jobs') }} {{ page_header('Scan Jobs') }}
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="mb-6 flex flex-wrap gap-3"> <div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<button @click="startBatchJob('http_check')" <div class="flex flex-wrap gap-3">
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"> <button type="button" @click="startBatchJob('http_check')"
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none">
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
HTTP Check HTTP Check
</button> </button>
<button @click="startBatchJob('tech_scan')" <button type="button" @click="startBatchJob('tech_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none">
<span x-html="$icon('code', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('code', 'w-4 h-4 mr-2')"></span>
Tech Scan Tech Scan
</button> </button>
<button @click="startBatchJob('performance_scan')" <button type="button" @click="startBatchJob('performance_scan')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-orange-600 border border-transparent rounded-lg hover:bg-orange-700 focus:outline-none">
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
Performance Scan Performance Scan
</button> </button>
<button @click="startBatchJob('contact_scrape')" <button type="button" @click="startBatchJob('contact_scrape')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"> class="inline-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">
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span>
Contact Scrape Contact Scrape
</button> </button>
<button @click="startBatchJob('score_compute')" <button type="button" @click="startBatchJob('score_compute')"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700"> class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Compute Scores Compute Scores
</button> </button>
</div>
</div> </div>
{{ loading_state('Loading scan jobs...') }} {{ loading_state('Loading scan jobs...') }}
{{ error_state('Error loading scan jobs') }} {{ error_state('Error loading scan jobs') }}
<!-- Jobs Table --> <!-- Jobs Table -->
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow"> <div x-show="!loading && !error">
<div class="w-full overflow-x-auto"> {% call table_wrapper() %}
<table class="w-full whitespace-nowrap"> {{ table_header(['Job Type', 'Status', 'Progress', 'Started', 'Duration', 'Result']) }}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Job Type</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Progress</th>
<th class="px-4 py-3">Started</th>
<th class="px-4 py-3">Duration</th>
<th class="px-4 py-3">Result</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800"> <tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No scan jobs found', x_message="'Run a batch job above to get started'", show_condition='jobs.length === 0', icon='code') }}
<template x-for="job in jobs" :key="job.id"> <template x-for="job in jobs" :key="job.id">
<tr class="text-gray-700 dark:text-gray-400"> <tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-4 py-3 text-sm font-semibold" x-text="job.job_type.replace('_', ' ')"></td> <td class="px-4 py-3 text-sm font-semibold capitalize" x-text="job.job_type.replace(/_/g, ' ')"></td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="px-2 py-1 text-xs font-semibold rounded-full" <span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
:class="jobStatusClass(job.status)" :class="jobStatusClass(job.status)"
x-text="job.status"></span> x-text="job.status"></span>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-24 h-2 bg-gray-200 rounded-full dark:bg-gray-700"> <div class="w-24 h-2 bg-gray-200 rounded-full dark:bg-gray-700">
<div class="h-2 bg-blue-500 rounded-full" :style="'width: ' + (job.progress_percent || 0) + '%'"></div> <div class="h-2 rounded-full transition-all duration-300"
:class="job.status === 'completed' ? 'bg-green-500' : job.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'"
:style="'width: ' + (job.progress_percent || 0) + '%'"></div>
</div> </div>
<span class="text-xs" x-text="job.processed_items + '/' + job.total_items"></span> <span class="text-xs text-gray-500" x-text="job.processed_items + '/' + job.total_items"></span>
</div> </div>
</td> </td>
<td class="px-4 py-3 text-xs" x-text="job.started_at ? new Date(job.started_at).toLocaleString() : '—'"></td> <td class="px-4 py-3 text-xs" x-text="job.started_at ? new Date(job.started_at).toLocaleString() : '—'"></td>
<td class="px-4 py-3 text-xs" x-text="formatDuration(job)"></td> <td class="px-4 py-3 text-xs" x-text="formatDuration(job)"></td>
<td class="px-4 py-3 text-xs"> <td class="px-4 py-3 text-xs">
<span x-show="job.failed_items > 0" class="text-red-600" x-text="job.failed_items + ' failed'"></span> <div class="flex items-center space-x-2">
<span x-show="job.skipped_items > 0" class="text-yellow-600 ml-1" x-text="job.skipped_items + ' skipped'"></span> <span x-show="job.failed_items > 0" class="text-red-600 dark:text-red-400" x-text="job.failed_items + ' failed'"></span>
<span x-show="job.skipped_items > 0" class="text-yellow-600 dark:text-yellow-400" x-text="job.skipped_items + ' skipped'"></span>
<span x-show="!job.failed_items && !job.skipped_items && job.status === 'completed'" class="text-green-600 dark:text-green-400">OK</span>
</div>
</td> </td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> {% endcall %}
</div>
</div>
{{ pagination() }} {{ pagination() }}
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -222,15 +222,18 @@
- message: Empty state message - message: Empty state message
- show_condition: Alpine.js condition (default: 'true') - show_condition: Alpine.js condition (default: 'true')
- has_filters: Whether to show filter hint (default: true) - has_filters: Whether to show filter hint (default: true)
- x_message: Alpine.js expression for dynamic message (rendered via x-text)
#} #}
{% macro table_empty_state(colspan, icon='inbox', title='No data found', message='', show_condition='true', has_filters=true) %} {% macro table_empty_state(colspan, icon='inbox', title='No data found', message='', show_condition='true', has_filters=true, x_message='') %}
<template x-if="{{ show_condition }}"> <template x-if="{{ show_condition }}">
<tr> <tr>
<td colspan="{{ colspan }}" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400"> <td colspan="{{ colspan }}" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span x-html="$icon('{{ icon }}', 'w-12 h-12 mb-2 text-gray-300')"></span> <span x-html="$icon('{{ icon }}', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">{{ title }}</p> <p class="font-medium">{{ title }}</p>
{% if message %} {% if x_message %}
<p class="text-xs mt-1" x-text="{{ x_message }}"></p>
{% elif message %}
<p class="text-xs mt-1">{{ message }}</p> <p class="text-xs mt-1">{{ message }}</p>
{% elif has_filters %} {% elif has_filters %}
<p class="text-xs mt-1">Try adjusting your search or filters</p> <p class="text-xs mt-1">Try adjusting your search or filters</p>

View File

@@ -160,6 +160,7 @@ testpaths = [
"app/modules/monitoring/tests", "app/modules/monitoring/tests",
"app/modules/analytics/tests", "app/modules/analytics/tests",
"app/modules/prospecting/tests", "app/modules/prospecting/tests",
"app/modules/hosting/tests",
] ]
python_files = ["test_*.py", "*_test.py"] python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"] python_classes = ["Test*"]
@@ -223,6 +224,7 @@ markers = [
"dev_tools: marks tests related to developer tools module", "dev_tools: marks tests related to developer tools module",
"analytics: marks tests related to analytics module", "analytics: marks tests related to analytics module",
"prospecting: marks tests related to prospecting and lead generation module", "prospecting: marks tests related to prospecting and lead generation module",
"hosting: marks tests related to hosting and POC website module",
"inventory_module: marks tests related to inventory module", "inventory_module: marks tests related to inventory module",
# Component markers # Component markers
"service: marks tests for service layer", "service: marks tests for service layer",