feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
CI / pytest (push) Failing after 49m20s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
CI / ruff (push) Successful in 10s

- Add complete hosting module (models, routes, schemas, services, templates, migrations)
- Add HostWizard platform to init_production seed (code=hosting, domain=hostwizard.lu)
- Fix cms_002 migration down_revision to z_unique_subdomain_domain
- Fix prospecting_001 migration to chain after cms_002 (remove branch label)
- Add hosting/prospecting version_locations to alembic.ini
- Fix admin_services delete endpoint to use proper response model
- Add hostwizard.lu to deployment docs (DNS, Caddy, Cloudflare)
- Add hosting and prospecting user journey docs to mkdocs nav

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:34:56 +01:00
parent 784bcb9d23
commit 8b147f53c6
46 changed files with 3907 additions and 13 deletions

View File

@@ -0,0 +1,165 @@
{% 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 %}
{% block title %}Client Services{% endblock %}
{% block alpine_data %}hostingClientsList(){% endblock %}
{% block content %}
{{ 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>
<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>
<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>
Expiring Soon
</button>
</div>
</div>
</div>
{{ loading_state('Loading services...') }}
{{ 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>
</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>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script>
function hostingClientsList() {
return {
loading: true,
error: false,
services: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
filterType: '',
filterStatus: '',
showExpiringOnly: false,
async init() { await this.loadServices(); },
async loadServices() {
this.loading = true;
this.error = false;
try {
// Use the sites endpoint to get all services across all sites
const params = new URLSearchParams({ page: this.page, per_page: this.perPage });
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();
// 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);
}
}
// Apply client-side filters
if (this.filterType) allServices = allServices.filter(s => s.service_type === this.filterType);
if (this.filterStatus) allServices = allServices.filter(s => s.status === this.filterStatus);
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; }
},
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(); },
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,148 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Hosting Dashboard{% endblock %}
{% block alpine_data %}hostingDashboard(){% endblock %}
{% block content %}
{{ page_header('Hosting Dashboard') }}
{{ loading_state('Loading dashboard...') }}
{{ error_state('Error loading dashboard') }}
<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="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>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Sites</p>
<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="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>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Live Sites</p>
<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="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>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">POC Sites</p>
<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="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>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Upcoming Renewals</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.upcoming_renewals || 0"></p>
</div>
</div>
</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('server', 'w-4 h-4 mr-2')"></span>
All Services
</a>
</div>
<!-- Sites by Status + Services by Type -->
<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="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"
: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="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Active</span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active_services || 0"></span>
</div>
<template x-for="[type, count] in Object.entries(stats.services_by_type || {})" :key="type">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400 capitalize" x-text="type.replace('_', ' ')"></span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="count"></span>
</div>
</template>
</div>
<div class="mt-4 pt-4 border-t 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"
x-text="'€' + ((stats.monthly_revenue_cents || 0) / 100).toFixed(2)"></span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingDashboard() {
return {
loading: true,
error: false,
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();
} catch (e) {
this.error = true;
} finally {
this.loading = false;
}
},
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,405 @@
{% 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/inputs.html' import number_stepper %}
{% block title %}Site Detail{% endblock %}
{% block alpine_data %}hostingSiteDetail({{ site_id }}){% endblock %}
{% block content %}
{{ loading_state('Loading site...') }}
{{ error_state('Error loading site') }}
<div x-show="!loading && !error && site" class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<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">
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
</a>
<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>
</div>
<span class="px-3 py-1 text-xs font-semibold 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">
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">
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">
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">
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">
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">
Reactivate
</button>
<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">
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>
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: 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="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>
</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>
</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>
</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="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>
</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>
</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>
</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>
</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 x-show="site.proposal_notes" class="mb-3">
<p class="text-xs text-gray-500 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-sm text-gray-700 dark:text-gray-300" x-text="site.internal_notes"></p>
</div>
</div>
</div>
<!-- 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>
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="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"
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'"
x-text="svc.status"></span>
</div>
<div class="mt-2 flex gap-4 text-xs text-gray-500">
<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>
<span x-show="svc.mailbox_count" x-text="svc.mailbox_count + ' mailboxes'"></span>
</div>
</div>
</template>
<p x-show="!site.client_services?.length" class="text-sm text-gray-400 text-center py-8">No services yet</p>
</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 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>
</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">
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
Open in Store Admin
</a>
</div>
</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>
</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>
</div>
<!-- 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>
</div>
</div>
<!-- Add Service Modal --> {# noqa: FE-004 #}
<div x-show="showServiceModal" x-cloak
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
@click.self="showServiceModal = false">
<div class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-xl"
@keydown.escape.window="showServiceModal = false">
<header class="flex justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add Service</h3>
<button @click="showServiceModal = false" class="text-gray-400 hover:text-gray-600">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</header>
<div class="space-y-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>
</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>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSiteDetail(siteId) {
return {
loading: true,
error: false,
site: null,
activeTab: 'overview',
tabs: [
{ id: 'overview', label: 'Overview' },
{ id: 'services', label: 'Services' },
{ id: 'store', label: 'Store' },
],
showProposalModal: false,
showAcceptModal: false,
showGoLiveModal: false,
showServiceModal: false,
proposalNotes: '',
acceptMerchantId: '',
goLiveDomain: '',
newService: { service_type: 'domain', name: '', price_cents: null, billing_period: 'monthly' },
async init() { await this.loadSite(); },
async loadSite() {
this.loading = true;
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; }
},
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 this.loadSite();
} catch (e) { alert('Action failed'); }
},
async sendProposal() {
await this.doAction('send-proposal', { notes: this.proposalNotes });
this.showProposalModal = false;
},
async acceptProposal() {
const body = {};
if (this.acceptMerchantId) body.merchant_id = parseInt(this.acceptMerchantId);
await this.doAction('accept', body);
this.showAcceptModal = false;
},
async goLive() {
await this.doAction('go-live', { domain: this.goLiveDomain });
this.showGoLiveModal = false;
},
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; }
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'); }
},
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,115 @@
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% block title %}New Hosted Site{% endblock %}
{% block alpine_data %}hostingSiteNew(){% endblock %}
{% block content %}
{{ page_header('New Hosted Site') }}
<div class="max-w-2xl">
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="space-y-6">
<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">
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">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">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">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">
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">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">
</div>
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">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>
</div>
<!-- Prospect Selector -->
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">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()"
: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">
Create from Prospect
</button>
</div>
</div>
</div>
<div class="flex justify-end mt-8 space-x-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">
Cancel
</a>
<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>
</button>
</div>
<div x-show="errorMsg" class="mt-4 p-3 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-900 dark:text-red-300" x-text="errorMsg"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSiteNew() {
return {
form: { business_name: '', contact_name: '', contact_email: '', contact_phone: '', internal_notes: '' },
prospectId: '',
creating: false,
errorMsg: '',
async createSite() {
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();
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = '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();
window.location.href = '/admin/hosting/sites/' + site.id;
} catch (e) { this.errorMsg = 'Failed to create from prospect'; }
finally { this.creating = false; }
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% 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 %}
{% 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') }}
<!-- 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>
<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>
<option value="draft">Draft</option>
<option value="poc_ready">POC Ready</option>
<option value="proposal_sent">Proposal Sent</option>
<option value="accepted">Accepted</option>
<option value="live">Live</option>
<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">
<span x-html="$icon('cursor-click', 'w-4 h-4 mr-2')"></span>
Create from Prospect
</a>
</div>
</div>
</div>
{{ loading_state('Loading sites...') }}
{{ 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>
</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">
<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
</a>
</td>
</tr>
</template>
</tbody>
</table>
</div>
{{ table_empty('No hosted sites found') }}
</div>
{{ pagination_controls() }}
{% endblock %}
{% block extra_scripts %}
<script>
function hostingSitesList() {
return {
loading: true,
error: false,
sites: [],
total: 0,
page: 1,
perPage: 20,
pages: 0,
search: '',
filterStatus: '',
async init() { await this.loadSites(); },
async loadSites() {
this.loading = true;
this.error = false;
try {
const params = new URLSearchParams({ page: this.page, per_page: this.perPage });
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; }
},
goToPage(p) { this.page = p; this.loadSites(); },
statusBadgeClass(status) {
const classes = {
draft: 'text-gray-700 bg-gray-100 dark:text-gray-300 dark:bg-gray-700',
poc_ready: 'text-blue-700 bg-blue-100 dark:text-blue-100 dark:bg-blue-700',
proposal_sent: 'text-yellow-700 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-700',
accepted: 'text-purple-700 bg-purple-100 dark:text-purple-100 dark:bg-purple-700',
live: 'text-green-700 bg-green-100 dark:text-green-100 dark:bg-green-700',
suspended: 'text-red-700 bg-red-100 dark:text-red-100 dark:bg-red-700',
cancelled: 'text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-600',
};
return classes[status] || 'text-gray-700 bg-gray-100';
},
};
}
</script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site.business_name }} - Preview by HostWizard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.hw-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background: linear-gradient(135deg, #0D9488, #14B8A6);
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
justify-content: space-between;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.hw-banner-left { display: flex; align-items: center; gap: 12px; }
.hw-banner-logo { font-weight: 700; font-size: 16px; }
.hw-banner-text { opacity: 0.9; }
.hw-banner-right { display: flex; align-items: center; gap: 12px; }
.hw-banner-link {
color: white;
text-decoration: none;
padding: 6px 16px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 6px;
font-size: 13px;
transition: background 0.2s;
}
.hw-banner-link:hover { background: rgba(255,255,255,0.15); }
.hw-iframe-container {
position: fixed;
top: 48px;
left: 0;
right: 0;
bottom: 0;
}
.hw-iframe-container iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
</head>
<body>
<div class="hw-banner">
<div class="hw-banner-left">
<span class="hw-banner-logo">HostWizard</span>
<span class="hw-banner-text">Preview for {{ site.business_name }}</span>
</div>
<div class="hw-banner-right">
<a href="https://hostwizard.lu" class="hw-banner-link" target="_blank">hostwizard.lu</a>
</div>
</div>
<div class="hw-iframe-container">
<iframe src="{{ store_url }}" title="Site preview"></iframe>
</div>
</body>
</html>