feat(hosting,prospecting): add hosting unit tests and fix template bugs
All checks were successful
All checks were successful
- 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:
@@ -14,7 +14,7 @@ from .admin_services import router as admin_services_router
|
||||
from .admin_sites import router as admin_sites_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_services_router, tags=["hosting-services"])
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -11,37 +12,35 @@
|
||||
{{ page_header('Client Services') }}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Service Type</label>
|
||||
<select x-model="filterType" @change="loadServices()"
|
||||
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>
|
||||
<div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<select x-model="filterType" @change="pagination.page = 1; 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">
|
||||
<option value="">All Types</option>
|
||||
<option value="domain">Domain</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="hosting">Hosting</option>
|
||||
<option value="website_maintenance">Website Maintenance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
|
||||
<select x-model="filterStatus" @change="loadServices()"
|
||||
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>
|
||||
|
||||
<select x-model="filterStatus" @change="pagination.page = 1; 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">
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button @click="showExpiringOnly = !showExpiringOnly; loadServices()"
|
||||
:class="showExpiringOnly ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg">
|
||||
<span x-html="$icon('clock', 'w-4 h-4 inline mr-1')"></span>
|
||||
|
||||
<button type="button" @click="showExpiringOnly = !showExpiringOnly; pagination.page = 1; loadServices()"
|
||||
:class="showExpiringOnly
|
||||
? 'bg-red-600 text-white border-red-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="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
|
||||
</button>
|
||||
</div>
|
||||
@@ -52,95 +51,87 @@
|
||||
{{ error_state('Error loading services') }}
|
||||
|
||||
<!-- Services Table -->
|
||||
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<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>
|
||||
<div x-show="!loading && !error">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Service', 'Type', 'Status', 'Price', 'Expires', 'Site']) }}
|
||||
<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">
|
||||
<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' : ''">
|
||||
<td class="px-4 py-3 text-sm font-semibold" x-text="svc.name"></td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
|
||||
: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>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="svc.price_cents ? '€' + (svc.price_cents / 100).toFixed(2) : '—'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'"
|
||||
:class="isExpiringSoon(svc) ? 'text-red-600 dark:text-red-400 font-semibold' : ''"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a :href="'/admin/hosting/sites/' + svc.hosted_site_id"
|
||||
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"
|
||||
title="View site">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<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"
|
||||
: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">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
x-text="svc.service_type.replace('_', ' ')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 text-xs font-semibold 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'"
|
||||
x-text="svc.status"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="svc.price_cents ? '€' + (svc.price_cents / 100).toFixed(2) : '—'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="svc.expires_at ? new Date(svc.expires_at).toLocaleDateString() : '—'"
|
||||
:class="isExpiringSoon(svc) ? 'text-red-600 font-semibold' : ''"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a :href="'/admin/hosting/sites/' + svc.hosted_site_id"
|
||||
class="text-teal-600 hover:text-teal-900 dark:text-teal-400">
|
||||
View Site
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div x-show="services.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500">
|
||||
No services found
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination_controls() }}
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function hostingClientsList() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-clients',
|
||||
loading: true,
|
||||
error: false,
|
||||
error: null,
|
||||
services: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
pages: 0,
|
||||
filterType: '',
|
||||
filterStatus: '',
|
||||
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() {
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
this.error = null;
|
||||
try {
|
||||
// Use the sites endpoint to get all services across all sites
|
||||
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.filterStatus) params.set('status', this.filterStatus);
|
||||
|
||||
// Get sites and flatten their services
|
||||
const resp = await fetch('/api/admin/hosting/sites?' + params);
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
const data = await resp.json();
|
||||
const response = await apiClient.get('/admin/hosting/sites?' + params);
|
||||
const sites = response.items || [];
|
||||
|
||||
// Collect all services from all sites
|
||||
let allServices = [];
|
||||
for (const site of data.items) {
|
||||
const svcResp = await fetch('/api/admin/hosting/sites/' + site.id + '/services');
|
||||
if (svcResp.ok) {
|
||||
const svcs = await svcResp.json();
|
||||
allServices = allServices.concat(svcs);
|
||||
}
|
||||
for (const site of sites) {
|
||||
try {
|
||||
const svcs = await apiClient.get('/admin/hosting/sites/' + site.id + '/services');
|
||||
const svcList = Array.isArray(svcs) ? svcs : (svcs.items || []);
|
||||
allServices = allServices.concat(svcList.map(s => ({ ...s, hosted_site_id: site.id })));
|
||||
} catch (e) { /* skip */ }
|
||||
}
|
||||
|
||||
// Apply client-side filters
|
||||
@@ -149,16 +140,47 @@ function hostingClientsList() {
|
||||
if (this.showExpiringOnly) allServices = allServices.filter(s => this.isExpiringSoon(s));
|
||||
|
||||
this.services = allServices;
|
||||
this.total = allServices.length;
|
||||
} catch (e) { this.error = true; }
|
||||
finally { this.loading = false; }
|
||||
this.pagination.total = allServices.length;
|
||||
this.pagination.pages = Math.ceil(allServices.length / this.pagination.per_page) || 1;
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
isExpiringSoon(svc) {
|
||||
if (!svc.expires_at || svc.status !== 'active') return false;
|
||||
const daysLeft = (new Date(svc.expires_at) - new Date()) / (1000 * 60 * 60 * 24);
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% 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 %}
|
||||
|
||||
{% block title %}Hosting Dashboard{% endblock %}
|
||||
@@ -14,7 +14,7 @@
|
||||
<div x-show="!loading && !error" class="space-y-6">
|
||||
<!-- KPI Cards -->
|
||||
<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">
|
||||
<span x-html="$icon('globe', 'w-5 h-5')"></span>
|
||||
</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>
|
||||
</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">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</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>
|
||||
</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">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</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>
|
||||
</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">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
@@ -53,41 +53,44 @@
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<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">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span>
|
||||
New Site
|
||||
</a>
|
||||
<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">
|
||||
<span x-html="$icon('globe', 'w-4 h-4 mr-2')"></span>
|
||||
All Sites
|
||||
</a>
|
||||
<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">
|
||||
<span x-html="$icon('cube', 'w-4 h-4 mr-2')"></span>
|
||||
All Services
|
||||
</a>
|
||||
<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"
|
||||
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>
|
||||
New Site
|
||||
</a>
|
||||
<a href="/admin/hosting/sites"
|
||||
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>
|
||||
All Sites
|
||||
</a>
|
||||
<a href="/admin/hosting/clients"
|
||||
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>
|
||||
All Services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites by Status + Services by Type -->
|
||||
<!-- Sites by Status + Active Services -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Sites by Status</h3>
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Sites by Status', icon='globe') }}
|
||||
<div class="space-y-3">
|
||||
<template x-for="status in ['draft', 'poc_ready', 'proposal_sent', 'accepted', 'live', 'suspended', 'cancelled']" :key="status">
|
||||
<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="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)"
|
||||
x-text="stats.sites_by_status?.[status] || 0"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Active Services</h3>
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Active Services', icon='cube') }}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Total Active</span>
|
||||
@@ -100,7 +103,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<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"
|
||||
@@ -116,16 +119,16 @@
|
||||
<script>
|
||||
function hostingDashboard() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-dashboard',
|
||||
loading: true,
|
||||
error: false,
|
||||
error: null,
|
||||
stats: {},
|
||||
async init() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/hosting/stats/dashboard');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
this.stats = await resp.json();
|
||||
this.stats = await apiClient.get('/admin/hosting/stats/dashboard');
|
||||
} catch (e) {
|
||||
this.error = true;
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% 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/modals.html' import modal %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
{% block title %}Site Detail{% endblock %}
|
||||
@@ -13,122 +14,124 @@
|
||||
|
||||
<div x-show="!loading && !error && site" class="space-y-6">
|
||||
<!-- 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">
|
||||
<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>
|
||||
</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>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200"
|
||||
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>
|
||||
<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)"
|
||||
x-text="site.status.replace('_', ' ')"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lifecycle Actions -->
|
||||
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<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">
|
||||
<div class="flex flex-wrap gap-3 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<button type="button" x-show="site.status === 'draft'" @click="doAction('mark-poc-ready')"
|
||||
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
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" x-show="site.status === 'poc_ready'" @click="showProposalModal = true"
|
||||
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
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" x-show="site.status === 'proposal_sent'" @click="showAcceptModal = true"
|
||||
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
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" x-show="site.status === 'accepted'" @click="showGoLiveModal = true"
|
||||
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
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" x-show="site.status === 'live'" @click="doAction('suspend')"
|
||||
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
|
||||
</button>
|
||||
<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">
|
||||
<button type="button" x-show="site.status === 'suspended'" @click="doAction('go-live', {domain: site.live_domain})"
|
||||
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
|
||||
</button>
|
||||
<button x-show="site.status !== 'cancelled' && site.status !== 'live'"
|
||||
<button type="button" x-show="site.status !== 'cancelled' && site.status !== 'live'"
|
||||
@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
|
||||
</button>
|
||||
<a x-show="['poc_ready', 'proposal_sent'].includes(site.status)"
|
||||
: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">
|
||||
<span x-html="$icon('eye', 'w-4 h-4 inline mr-1')"></span>
|
||||
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 mr-1')"></span>
|
||||
Preview POC
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex -mb-px space-x-8">
|
||||
<template x-for="tab in tabs" :key="tab.id">
|
||||
<button @click="activeTab = tab.id"
|
||||
: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'"
|
||||
class="py-4 px-1 border-b-2 font-medium text-sm whitespace-nowrap"
|
||||
x-text="tab.label"></button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
{{ tab_header([
|
||||
{'id': 'overview', 'label': 'Overview', 'icon': 'eye'},
|
||||
{'id': 'services', 'label': 'Services', 'icon': 'cube'},
|
||||
{'id': 'store', 'label': 'Store', 'icon': 'shopping-bag'},
|
||||
], active_var='activeTab') }}
|
||||
|
||||
<!-- Tab: Overview -->
|
||||
<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">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Contact Info</h3>
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Contact Info', icon='phone') }}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<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 class="flex justify-between">
|
||||
<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 class="flex justify-between">
|
||||
<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 class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Timeline</h3>
|
||||
<div class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Timeline', icon='clock') }}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<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 class="flex justify-between">
|
||||
<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 class="flex justify-between">
|
||||
<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 class="flex justify-between">
|
||||
<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 class="p-4 bg-white rounded-lg shadow 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>
|
||||
<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">
|
||||
{{ section_header('Notes', icon='document-text') }}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,25 +140,25 @@
|
||||
<!-- Tab: Services -->
|
||||
<div x-show="activeTab === 'services'" class="space-y-4">
|
||||
<div class="flex justify-end">
|
||||
<button @click="showServiceModal = true"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-teal-600 rounded-lg hover:bg-teal-700">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 inline mr-1')"></span>
|
||||
<button type="button" @click="showServiceModal = true"
|
||||
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>
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs font-semibold 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'"
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
|
||||
: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>
|
||||
</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.domain_name" x-text="svc.domain_name"></span>
|
||||
<span x-show="svc.expires_at" x-text="'Expires: ' + new Date(svc.expires_at).toLocaleDateString()"></span>
|
||||
@@ -167,17 +170,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab: Store -->
|
||||
<div x-show="activeTab === 'store'" class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase">Store Info</h3>
|
||||
<div x-show="activeTab === 'store'" class="p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
{{ section_header('Store Info', icon='shopping-bag') }}
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<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 class="mt-4">
|
||||
<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>
|
||||
Open in Store Admin
|
||||
</a>
|
||||
@@ -185,152 +188,115 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Proposal Modal --> {# noqa: FE-004 #}
|
||||
<div x-show="showProposalModal" x-cloak
|
||||
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>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Proposal Notes</label>
|
||||
<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>
|
||||
</div>
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<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>
|
||||
<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>
|
||||
</footer>
|
||||
<!-- Send Proposal Modal -->
|
||||
{% call modal('proposalModal', 'Send Proposal', show_var='showProposalModal', size='md', show_footer=false) %}
|
||||
<div>
|
||||
<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"
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<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 class="flex justify-end mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
|
||||
<button type="button" @click="showProposalModal = 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="sendProposal()"
|
||||
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">
|
||||
<span x-html="$icon('mail', 'w-4 h-4 mr-2')"></span> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Go Live Modal --> {# noqa: FE-004 #}
|
||||
<div x-show="showGoLiveModal" x-cloak
|
||||
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>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Domain *</label>
|
||||
<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">
|
||||
</div>
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<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>
|
||||
<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>
|
||||
</footer>
|
||||
<!-- Accept Proposal Modal -->
|
||||
{% call modal('acceptModal', 'Accept Proposal', show_var='showAcceptModal', size='md', show_footer=false) %}
|
||||
<div>
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
<!-- 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>
|
||||
</header>
|
||||
<div class="space-y-4">
|
||||
<!-- 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"
|
||||
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="showGoLiveModal = 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="goLive()"
|
||||
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">
|
||||
<span x-html="$icon('check', 'w-4 h-4 mr-2')"></span> Go Live
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Add Service Modal -->
|
||||
{% call modal('serviceModal', 'Add Service', show_var='showServiceModal', size='md', show_footer=false) %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Type</label>
|
||||
<select x-model="newService.service_type"
|
||||
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="email">Email</option>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="hosting">Hosting</option>
|
||||
<option value="website_maintenance">Website Maintenance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
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="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Type</label>
|
||||
<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">
|
||||
<option value="domain">Domain</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="hosting">Hosting</option>
|
||||
<option value="website_maintenance">Website Maintenance</option>
|
||||
<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)') }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Billing</label>
|
||||
<select x-model="newService.billing_period"
|
||||
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="annual">Annual</option>
|
||||
<option value="one_time">One-time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Name</label>
|
||||
<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">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Price (cents)</label>
|
||||
{{ number_stepper(model='newService.price_cents', min=0, step=100, size='sm', label='Price (cents)') }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Billing</label>
|
||||
<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">
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="annual">Annual</option>
|
||||
<option value="one_time">One-time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="flex justify-end mt-6 space-x-3">
|
||||
<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>
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
</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="showServiceModal = 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="addService()"
|
||||
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">
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-2')"></span> Add
|
||||
</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function hostingSiteDetail(siteId) {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-sites',
|
||||
loading: true,
|
||||
error: false,
|
||||
error: null,
|
||||
site: null,
|
||||
activeTab: 'overview',
|
||||
tabs: [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'services', label: 'Services' },
|
||||
{ id: 'store', label: 'Store' },
|
||||
],
|
||||
showProposalModal: false,
|
||||
showAcceptModal: false,
|
||||
showGoLiveModal: false,
|
||||
@@ -342,23 +308,23 @@ function hostingSiteDetail(siteId) {
|
||||
async init() { await this.loadSite(); },
|
||||
async loadSite() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const resp = await fetch('/api/admin/hosting/sites/' + siteId);
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
this.site = await resp.json();
|
||||
} catch (e) { this.error = true; }
|
||||
finally { this.loading = false; }
|
||||
this.site = await apiClient.get('/admin/hosting/sites/' + siteId);
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async doAction(action, body = {}) {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Action failed'); return; }
|
||||
await apiClient.post('/admin/hosting/sites/' + siteId + '/' + action, body);
|
||||
Utils.showToast('Action completed', 'success');
|
||||
await this.loadSite();
|
||||
} catch (e) { alert('Action failed'); }
|
||||
} catch (e) {
|
||||
Utils.showToast(e.message || 'Action failed', 'error');
|
||||
}
|
||||
},
|
||||
async sendProposal() {
|
||||
await this.doAction('send-proposal', { notes: this.proposalNotes });
|
||||
@@ -376,16 +342,14 @@ function hostingSiteDetail(siteId) {
|
||||
},
|
||||
async addService() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/hosting/sites/' + siteId + '/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.newService),
|
||||
});
|
||||
if (!resp.ok) { const err = await resp.json(); alert(err.detail || 'Failed'); return; }
|
||||
await apiClient.post('/admin/hosting/sites/' + siteId + '/services', this.newService);
|
||||
Utils.showToast('Service added', 'success');
|
||||
this.showServiceModal = false;
|
||||
this.newService = { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' };
|
||||
await this.loadSite();
|
||||
} catch (e) { alert('Failed to add service'); }
|
||||
} catch (e) {
|
||||
Utils.showToast(e.message || 'Failed to add service', 'error');
|
||||
}
|
||||
},
|
||||
statusBadgeClass(status) {
|
||||
const classes = {
|
||||
|
||||
@@ -6,67 +6,71 @@
|
||||
{% block alpine_data %}hostingSiteNew(){% endblock %}
|
||||
|
||||
{% 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="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="space-y-6">
|
||||
<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="space-y-5">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Business Name *</label>
|
||||
<input type="text" x-model="form.business_name" placeholder="Acme Luxembourg SARL"
|
||||
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 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" 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 class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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"
|
||||
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>
|
||||
<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"
|
||||
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>
|
||||
<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 ..."
|
||||
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>
|
||||
<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..."
|
||||
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>
|
||||
|
||||
<!-- Prospect Selector -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Create from Prospect (optional)</label>
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<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">
|
||||
<input type="number" x-model="prospectId" placeholder="Prospect ID"
|
||||
class="flex-1 text-sm rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<button @click="createFromProspect()"
|
||||
<input type="number" x-model="prospectId" placeholder="Prospect ID" {# noqa: FE008 - prospect ID input #}
|
||||
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 type="button" @click="createFromProspect()"
|
||||
: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
|
||||
</button>
|
||||
</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"
|
||||
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
|
||||
</a>
|
||||
<button @click="createSite()"
|
||||
<button type="button" @click="createSite()"
|
||||
: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">
|
||||
<span x-show="!creating">Create Site</span>
|
||||
<span x-show="creating">Creating...</span>
|
||||
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" x-html="$icon('plus', 'w-4 h-4 mr-2')"></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>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +83,8 @@
|
||||
<script>
|
||||
function hostingSiteNew() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-sites',
|
||||
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
|
||||
prospectId: '',
|
||||
creating: false,
|
||||
@@ -87,27 +93,25 @@ function hostingSiteNew() {
|
||||
this.creating = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch('/api/admin/hosting/sites', {
|
||||
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();
|
||||
const site = await apiClient.post('/admin/hosting/sites', this.form);
|
||||
window.location.href = '/admin/hosting/sites/' + site.id;
|
||||
} catch (e) { this.errorMsg = 'Failed to create site'; }
|
||||
finally { this.creating = false; }
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message || 'Failed to create site';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
async createFromProspect() {
|
||||
this.creating = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch('/api/admin/hosting/sites/from-prospect/' + this.prospectId, { method: 'POST' });
|
||||
if (!resp.ok) { const err = await resp.json(); this.errorMsg = err.detail || 'Failed to create'; return; }
|
||||
const site = await resp.json();
|
||||
const site = await apiClient.post('/admin/hosting/sites/from-prospect/' + this.prospectId);
|
||||
window.location.href = '/admin/hosting/sites/' + site.id;
|
||||
} catch (e) { this.errorMsg = 'Failed to create from prospect'; }
|
||||
finally { this.creating = false; }
|
||||
} catch (e) {
|
||||
this.errorMsg = e.message || 'Failed to create from prospect';
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_header, table_empty %}
|
||||
{% 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 %}Hosted Sites{% endblock %}
|
||||
|
||||
{% block alpine_data %}hostingSitesList(){% endblock %}
|
||||
|
||||
{% 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 -->
|
||||
<div class="mb-6 p-4 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Search</label>
|
||||
<input type="text" x-model="search" @input.debounce.300ms="loadSites()"
|
||||
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">
|
||||
<div x-show="!loading && !error" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<!-- Search -->
|
||||
<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; loadSites()"
|
||||
placeholder="Business name, email, domain..."
|
||||
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>
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400">Status</label>
|
||||
<select x-model="filterStatus" @change="loadSites()"
|
||||
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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<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="poc_ready">POC Ready</option>
|
||||
<option value="proposal_sent">Proposal Sent</option>
|
||||
@@ -33,10 +39,9 @@
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
|
||||
<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>
|
||||
Create from Prospect
|
||||
</a>
|
||||
@@ -48,92 +53,125 @@
|
||||
{{ error_state('Error loading sites') }}
|
||||
|
||||
<!-- Sites Table -->
|
||||
<div x-show="!loading && !error" class="w-full overflow-hidden rounded-lg shadow">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<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">
|
||||
<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">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<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>
|
||||
<div x-show="!loading && !error">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Business', 'Status', 'Contact', 'Domain', 'Created', 'Actions']) }}
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="statusBadgeClass(s.status)"
|
||||
x-text="s.status.replace('_', ' ')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="s.contact_email || '—'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="s.live_domain || '—'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="new Date(s.created_at).toLocaleDateString()" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="statusBadgeClass(s.status)"
|
||||
x-text="s.status.replace('_', ' ')"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="s.contact_email || '—'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="s.live_domain || '—'" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="new Date(s.created_at).toLocaleDateString()" class="text-xs"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<a :href="'/admin/hosting/sites/' + s.id"
|
||||
class="text-teal-600 hover:text-teal-900 dark:text-teal-400 dark:hover:text-teal-300">
|
||||
View
|
||||
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"
|
||||
title="View details">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ table_empty('No hosted sites found') }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination_controls() }}
|
||||
{{ pagination() }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function hostingSitesList() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'hosting-sites',
|
||||
loading: true,
|
||||
error: false,
|
||||
error: null,
|
||||
sites: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
pages: 0,
|
||||
search: '',
|
||||
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() {
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
this.error = null;
|
||||
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.filterStatus) params.set('status', this.filterStatus);
|
||||
const resp = await fetch('/api/admin/hosting/sites?' + params);
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
const data = await resp.json();
|
||||
this.sites = data.items;
|
||||
this.total = data.total;
|
||||
this.pages = data.pages;
|
||||
} catch (e) { this.error = true; }
|
||||
finally { this.loading = false; }
|
||||
const response = await apiClient.get('/admin/hosting/sites?' + params);
|
||||
this.sites = response.items || [];
|
||||
this.pagination.total = response.total || 0;
|
||||
this.pagination.pages = response.pages || 0;
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
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) {
|
||||
const classes = {
|
||||
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
|
||||
|
||||
0
app/modules/hosting/tests/__init__.py
Normal file
0
app/modules/hosting/tests/__init__.py
Normal file
186
app/modules/hosting/tests/conftest.py
Normal file
186
app/modules/hosting/tests/conftest.py
Normal 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
|
||||
0
app/modules/hosting/tests/unit/__init__.py
Normal file
0
app/modules/hosting/tests/unit/__init__.py
Normal file
219
app/modules/hosting/tests/unit/test_client_service_service.py
Normal file
219
app/modules/hosting/tests/unit/test_client_service_service.py
Normal 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
|
||||
325
app/modules/hosting/tests/unit/test_hosted_site_service.py
Normal file
325
app/modules/hosting/tests/unit/test_hosted_site_service.py
Normal 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)
|
||||
66
app/modules/hosting/tests/unit/test_stats_service.py
Normal file
66
app/modules/hosting/tests/unit/test_stats_service.py
Normal 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"]
|
||||
Reference in New Issue
Block a user