feat(hosting): add HostWizard platform module and fix migration chain
Some checks failed
Some checks failed
- 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:
405
app/modules/hosting/templates/hosting/admin/site-detail.html
Normal file
405
app/modules/hosting/templates/hosting/admin/site-detail.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user