| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Room Price Calculator</title>
- <link rel="preconnect" href="https://fonts.googleapis.com" />
- <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;800&display=swap" rel="stylesheet" />
- <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
- <style>
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- :root {
- --bg: #0e0f11;
- --surface: #16181c;
- --border: #2a2d35;
- --accent: #c8f060;
- --accent2: #60c8f0;
- --accent3: #c060f0;
- --text: #e8eaf0;
- --muted: #6b7080;
- --danger: #f06080;
- --radius: 12px;
- --mono: 'DM Mono', monospace;
- --display: 'Syne', sans-serif;
- }
- body {
- background: var(--bg);
- color: var(--text);
- font-family: var(--mono);
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 48px 16px 80px;
- }
- /* Noise overlay */
- body::before {
- content: '';
- position: fixed;
- inset: 0;
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
- pointer-events: none;
- z-index: 0;
- opacity: 0.6;
- }
- #app { position: relative; z-index: 1; width: 100%; max-width: 1080px; }
- header {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- margin-bottom: 32px;
- }
- .service-bar {
- width: 100%;
- max-width: 1080px;
- margin: 0 auto 28px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 16px 20px;
- display: flex;
- align-items: center;
- gap: 16px;
- position: relative;
- z-index: 1;
- }
- .service-bar label { flex-shrink: 0; }
- .service-bar select { max-width: 360px; }
- .header-tag {
- font-family: var(--mono);
- font-size: 11px;
- letter-spacing: 0.2em;
- color: var(--accent);
- text-transform: uppercase;
- border: 1px solid var(--accent);
- padding: 3px 10px;
- border-radius: 4px;
- margin-bottom: 14px;
- opacity: 0.85;
- }
- h1 {
- font-family: var(--display);
- font-weight: 800;
- font-size: clamp(2rem, 6vw, 3.2rem);
- line-height: 1.05;
- letter-spacing: -0.02em;
- }
- h1 span { color: var(--accent); }
- .subtitle {
- margin-top: 8px;
- font-size: 13px;
- color: var(--muted);
- }
- .grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 20px;
- }
- @media (max-width: 880px) { .grid { grid-template-columns: 1fr 1fr; } }
- @media (max-width: 560px) { .grid { grid-template-columns: 1fr; } }
- .card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 28px;
- display: flex;
- flex-direction: column;
- gap: 20px;
- position: relative;
- overflow: hidden;
- transition: border-color 0.2s;
- }
- .card:hover { border-color: #3a3d48; }
- .card-accent-bar {
- position: absolute;
- top: 0; left: 0; right: 0;
- height: 2px;
- }
- .card.green .card-accent-bar { background: var(--accent); }
- .card.blue .card-accent-bar { background: var(--accent2); }
- .card.purple .card-accent-bar { background: var(--accent3); }
- .card-title {
- font-family: var(--display);
- font-weight: 600;
- font-size: 1.05rem;
- letter-spacing: -0.01em;
- }
- .card.green .card-title { color: var(--accent); }
- .card.blue .card-title { color: var(--accent2); }
- .card.purple .card-title { color: var(--accent3); }
- .card-direction {
- font-size: 11px;
- color: var(--muted);
- margin-top: 2px;
- }
- .field { display: flex; flex-direction: column; gap: 6px; }
- label { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; }
- input, select {
- background: #1e2028;
- border: 1px solid var(--border);
- color: var(--text);
- font-family: var(--mono);
- font-size: 14px;
- padding: 10px 14px;
- border-radius: 8px;
- outline: none;
- transition: border-color 0.15s, box-shadow 0.15s;
- width: 100%;
- appearance: none;
- }
- input:focus, select:focus {
- border-color: #50546a;
- box-shadow: 0 0 0 3px rgba(100,110,160,0.12);
- }
- input[type="number"]::-webkit-inner-spin-button { opacity: 0.4; }
- select {
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7080' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 12px center;
- padding-right: 36px;
- cursor: pointer;
- }
- button {
- font-family: var(--display);
- font-weight: 600;
- font-size: 13px;
- letter-spacing: 0.04em;
- padding: 11px 20px;
- border: none;
- border-radius: 8px;
- cursor: pointer;
- transition: opacity 0.15s, transform 0.1s;
- color: #0e0f11;
- }
- button:active { transform: scale(0.97); }
- button:disabled { opacity: 0.45; cursor: not-allowed; }
- .btn-green { background: var(--accent); }
- .btn-blue { background: var(--accent2); }
- .btn-purple { background: var(--accent3); }
- .result-box {
- border-radius: 8px;
- padding: 14px 16px;
- font-size: 13px;
- min-height: 62px;
- transition: all 0.3s;
- }
- .result-box.idle {
- background: #1a1c22;
- border: 1px dashed #2a2d35;
- color: var(--muted);
- display: flex;
- align-items: center;
- }
- .result-box.success.green { background: #161d12; border: 1px solid #3a5020; }
- .result-box.success.blue { background: #121a1d; border: 1px solid #20454a; }
- .result-box.success.purple { background: #1a121d; border: 1px solid #45204a; }
- .result-box.success .result-label {
- font-size: 11px;
- color: var(--muted);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- margin-bottom: 6px;
- }
- .result-box.success .result-value {
- font-family: var(--display);
- font-size: 1.6rem;
- font-weight: 800;
- letter-spacing: -0.02em;
- }
- .result-box.success.green .result-value { color: var(--accent); }
- .result-box.success.blue .result-value { color: var(--accent2); }
- .result-box.success.purple .result-value { color: var(--accent3); }
- .result-box.success .result-meta {
- margin-top: 6px;
- font-size: 11px;
- color: var(--muted);
- }
- .result-extra {
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid #22252d;
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- }
- .result-extra-label {
- font-size: 11px;
- color: var(--muted);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- }
- .result-extra-value {
- font-family: var(--display);
- font-size: 1.05rem;
- font-weight: 600;
- color: var(--text);
- }
- .result-box.error {
- background: #1d1216;
- border: 1px solid #4a2030;
- color: var(--danger);
- display: flex;
- align-items: center;
- }
- .spinner {
- display: inline-block;
- width: 14px; height: 14px;
- border: 2px solid currentColor;
- border-top-color: transparent;
- border-radius: 50%;
- animation: spin 0.7s linear infinite;
- margin-right: 8px;
- flex-shrink: 0;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- .details-toggle {
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid #22252d;
- font-size: 11px;
- color: var(--muted);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- cursor: pointer;
- user-select: none;
- }
- .details-toggle:hover { color: var(--text); }
- .details-table {
- width: 100%;
- border-collapse: collapse;
- margin-top: 14px;
- font-size: 12px;
- }
- .details-table tr { border-top: 1px solid #22252d; }
- .details-table tr:first-child { border-top: 1px solid #2e3240; }
- .dt-key {
- color: var(--muted);
- padding: 5px 0;
- width: 50%;
- letter-spacing: 0.04em;
- }
- .dt-val {
- color: var(--text);
- padding: 5px 0;
- text-align: right;
- font-weight: 500;
- }
- .result-box.success.blue .dt-val { color: #b8e8f8; }
- .result-box.success.purple .dt-val { color: #e8b8f8; }
- .result-box.placeholder { opacity: 0.45; }
- .result-box.placeholder .result-value { font-style: italic; }
- </style>
- </head>
- <body>
- <div id="app">
- <header>
- <h1>Room Price<br/><span>Calculator</span></h1>
- </header>
- <div class="service-bar">
- <label>Service</label>
- <select v-model="selectedService">
- <option v-if="services.length === 0" disabled value="">Loading…</option>
- <option v-for="svc in services" :key="svc.key" :value="svc.key">{{ svc.name }}</option>
- </select>
- </div>
- <div class="grid">
- <div v-for="conv in conversions" :key="conv.endpoint" class="card" :class="conv.color">
- <div class="card-accent-bar"></div>
- <div>
- <div class="card-title">{{ conv.endpoint }}</div>
- <div class="card-direction">{{ labelFor(conv.from) }} → {{ labelFor(conv.to) }}</div>
- </div>
- <div class="field">
- <label>Amount</label>
- <input type="number" v-model.number="states[conv.endpoint].amount" placeholder="e.g. 68.00" step="1.00"
- @blur="onAmountBlur(conv.endpoint)" @keyup.enter="onAmountBlur(conv.endpoint)" />
- </div>
- <button :class="`btn-${conv.color}`" @click="call(conv.endpoint)" :disabled="states[conv.endpoint].loading">
- <span v-if="states[conv.endpoint].loading"><span class="spinner"></span>Calling…</span>
- <span v-else>→ Convert</span>
- </button>
- <div class="result-box"
- :class="[resultClass(states[conv.endpoint], conv.color), { placeholder: states[conv.endpoint].isPlaceholder }]">
- <template v-if="states[conv.endpoint].loading">
- <span class="spinner"></span> Fetching result…
- </template>
- <template v-else-if="states[conv.endpoint].error">{{ states[conv.endpoint].error }}</template>
- <template v-else-if="states[conv.endpoint].result !== null">
- <div class="result-label">{{ labelFor(conv.to) }}</div>
- <div class="result-value">${{ states[conv.endpoint].result }}</div>
- <div class="result-meta">Service: {{ states[conv.endpoint].displayServiceName }}</div>
- <div v-for="extra in extrasFor(conv, states[conv.endpoint])" :key="extra.key" class="result-extra">
- <span class="result-extra-label">{{ extra.label }}</span>
- <span class="result-extra-value">${{ extra.value }}</span>
- </div>
- <template v-if="states[conv.endpoint].details">
- <div
- class="details-toggle"
- @click="states[conv.endpoint].showDetails = !states[conv.endpoint].showDetails"
- >
- {{ states[conv.endpoint].showDetails ? '▾ Hide details' : '▸ Show details' }}
- </div>
- <table v-if="states[conv.endpoint].showDetails" class="details-table">
- <tr v-for="(val, key) in states[conv.endpoint].details" :key="key">
- <td class="dt-key">{{ key }}</td>
- <td class="dt-val">${{ val }}</td>
- </tr>
- </table>
- </template>
- </template>
- <template v-else>Result will appear here</template>
- </div>
- </div>
- </div>
- </div>
- <script>
- const { createApp, reactive, ref, watch, onMounted } = Vue;
- const CONVERSIONS = [
- { endpoint: 'net_to_list', from: 'net', to: 'list', color: 'green' },
- { endpoint: 'list_to_net', from: 'list', to: 'net', color: 'purple' },
- { endpoint: 'list_to_retail', from: 'list', to: 'retail', color: 'blue' },
- { endpoint: 'retail_to_list', from: 'retail', to: 'list', color: 'green' },
- { endpoint: 'net_to_retail', from: 'net', to: 'retail', color: 'blue' },
- { endpoint: 'retail_to_net', from: 'retail', to: 'net', color: 'purple' },
- ];
- const FIELD_LABELS = { list: 'List', retail: 'Retail', net: 'Net' };
- function labelFor(key) { return FIELD_LABELS[key] || key; }
- createApp({
- setup() {
- const API_BASE = 'https://zi3.xyz/room'; // mirrors URL_PREFIX on the backend
- const services = ref([]);
- const selectedService = ref('');
- const makeState = () => reactive({
- amount: null,
- displayServiceName: null,
- result: '0.00',
- values: {},
- details: null,
- error: null,
- loading: false,
- isPlaceholder: false,
- showDetails: false,
- });
- const states = reactive({});
- for (const conv of CONVERSIONS) states[conv.endpoint] = makeState();
- async function fetchServices() {
- try {
- const res = await fetch(`${API_BASE}/services`);
- const list = await res.json();
- if (Array.isArray(list) && list.length > 0) {
- services.value = list;
- selectedService.value = list[0].key;
- }
- } catch (err) {
- console.error('Failed to load services:', err);
- }
- }
- async function call(endpoint) {
- const s = states[endpoint];
- const conv = CONVERSIONS.find(c => c.endpoint === endpoint);
- if (s.amount === null || s.amount === '' || s.amount === 0) {
- s.error = 'Please enter a non-zero amount.';
- s.result = null;
- return;
- }
- const serviceKey = selectedService.value;
- const pendingServiceName = (services.value.find(sv => sv.key === serviceKey) || {}).name || serviceKey;
- s.loading = true;
- s.error = null;
- s.result = null;
- s.values = { list: null, retail: null, net: null };
- s.details = null;
- try {
- const url = new URL(`${API_BASE}/${endpoint}`);
- url.searchParams.set('amount', s.amount);
- url.searchParams.set('service', serviceKey);
- const res = await fetch(url.toString());
- const json = await res.json();
- if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
- s.result = json[conv.to] ?? null;
- s.values = {
- list: json.list ?? null,
- retail: json.retail ?? null,
- net: json.net ?? null,
- };
- s.details = json.details ?? null;
- s.displayServiceName = pendingServiceName;
- s.isPlaceholder = false;
- } catch (err) {
- s.error = `Error: ${err.message}`;
- } finally {
- s.loading = false;
- }
- }
- function onAmountBlur(endpoint) {
- const s = states[endpoint];
- if (s.amount !== null && s.amount !== '' && s.amount !== 0) call(endpoint);
- }
- function resultClass(s, color) {
- if (s.loading) return 'idle';
- if (s.error) return 'error';
- if (s.result !== null) return `success ${color}`;
- return 'idle';
- }
- function extrasFor(conv, s) {
- if (!s.values) return [];
- const extras = [];
- for (const k of ['list', 'retail', 'net']) {
- if (k === conv.to) continue;
- if (s.values[k] != null) extras.push({ key: k, label: labelFor(k), value: s.values[k] });
- }
- return extras;
- }
- onMounted(fetchServices);
- // When the shared service changes, re-run any card that already has an amount.
- watch(selectedService, () => {
- for (const conv of CONVERSIONS) {
- const s = states[conv.endpoint];
- if (s.amount !== null && s.amount !== '' && s.amount !== 0) call(conv.endpoint);
- }
- });
- return {
- services,
- selectedService,
- states,
- conversions: CONVERSIONS,
- call,
- resultClass,
- onAmountBlur,
- labelFor,
- extrasFor,
- };
- }
- }).mount('#app');
- </script>
- </body>
- </html>
|