| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Price Comparison</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;
- --text: #e8eaf0;
- --muted: #6b7080;
- --danger: #f06080;
- --mono: 'DM Mono', monospace;
- --display: 'Syne', sans-serif;
- }
- body {
- background: var(--bg);
- color: var(--text);
- font-family: var(--mono);
- min-height: 100vh;
- display: flex;
- justify-content: center;
- padding: 64px 16px;
- }
- 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: 420px;
- }
- .input-wrap {
- display: flex;
- align-items: center;
- background: #1e2028;
- border: 1px solid var(--border);
- border-radius: 10px;
- padding: 0 16px;
- margin-bottom: 16px;
- transition: border-color 0.15s, box-shadow 0.15s;
- }
- .input-wrap:focus-within {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px rgba(200, 240, 96, 0.1);
- }
- .prefix {
- font-size: 12px;
- color: var(--muted);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- flex-shrink: 0;
- margin-right: 10px;
- }
- .dollar {
- font-family: var(--display);
- font-size: 1.8rem;
- font-weight: 800;
- color: var(--accent);
- flex-shrink: 0;
- line-height: 1;
- }
- input[type="number"] {
- background: transparent;
- border: none;
- color: var(--text);
- font-family: var(--display);
- font-weight: 800;
- font-size: 1.8rem;
- padding: 14px 10px;
- outline: none;
- flex: 1;
- min-width: 0;
- }
- input[type="number"]::-webkit-inner-spin-button,
- input[type="number"]::-webkit-outer-spin-button { display: none; }
- input[type="number"] { -moz-appearance: textfield; }
- .results {
- display: flex;
- flex-direction: column;
- gap: 3px;
- }
- .result-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 14px 20px;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 8px;
- }
- .row-label {
- font-size: 12px;
- color: var(--muted);
- letter-spacing: 0.08em;
- text-transform: uppercase;
- }
- .row-value {
- font-family: var(--display);
- font-size: 1.5rem;
- font-weight: 800;
- letter-spacing: -0.02em;
- }
- .result-row.net .row-value { color: var(--accent); }
- .result-row.svc .row-value { color: var(--accent2); }
- .result-row.net.live { border-color: #3a5020; background: #131a0f; }
- .result-row.svc.live { border-color: #20454a; background: #0f1a1d; }
- .spinner {
- display: inline-block;
- width: 14px; height: 14px;
- border: 2px solid var(--muted);
- border-top-color: transparent;
- border-radius: 50%;
- animation: spin 0.7s linear infinite;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- .global-error {
- font-size: 12px;
- color: var(--danger);
- margin-bottom: 12px;
- padding: 0 4px;
- }
- </style>
- </head>
- <body>
- <div id="app">
- <div class="input-wrap">
- <span class="prefix">AirBnB</span>
- <span class="dollar">$</span>
- <input
- type="number"
- v-model.number="airbnbPrice"
- placeholder="0.00"
- step="1"
- min="0"
- @keyup.enter="calculate"
- />
- </div>
- <div v-if="globalError" class="global-error">{{ globalError }}</div>
- <div v-if="net !== null || loading || anyServiceLoading" class="results">
- <div class="result-row net" :class="{ live: net !== null && !loading }">
- <span class="row-label">Net</span>
- <span v-if="loading" class="spinner"></span>
- <span v-else class="row-value">${{ net }}</span>
- </div>
- <div
- v-for="svc in otherServices"
- :key="svc.key"
- class="result-row svc"
- :class="{ live: svc.list !== null && !svc.loading }"
- >
- <span class="row-label">{{ svc.name }}</span>
- <span v-if="svc.loading" class="spinner"></span>
- <span v-else-if="svc.list !== null" class="row-value">${{ svc.list }}</span>
- </div>
- </div>
- </div>
- <script>
- const { createApp, ref, reactive, computed, watch, onMounted } = Vue;
- createApp({
- setup() {
- const API_BASE = 'https://zi3.xyz/room';
- const airbnbPrice = ref(null);
- const loading = ref(false);
- const globalError = ref(null);
- const net = ref(null);
- const otherServices = reactive([]);
- let debounceTimer = null;
- const anyServiceLoading = computed(() => otherServices.some(s => s.loading));
- async function fetchServices() {
- try {
- const res = await fetch(`${API_BASE}/services`);
- const list = await res.json();
- otherServices.splice(0, otherServices.length,
- ...list.filter(s => s.key !== 'airbnb').map(s => ({
- ...s, list: null, loading: false, error: null,
- }))
- );
- } catch {
- globalError.value = 'Failed to load services.';
- }
- }
- async function calculate() {
- if (!airbnbPrice.value || airbnbPrice.value <= 0) {
- net.value = null;
- globalError.value = null;
- for (const svc of otherServices) { svc.list = null; svc.error = null; }
- return;
- }
- loading.value = true;
- globalError.value = null;
- let netAmount;
- try {
- const url = new URL(`${API_BASE}/list_to_net`);
- url.searchParams.set('service', 'airbnb');
- url.searchParams.set('amount', airbnbPrice.value);
- const res = await fetch(url.toString());
- const json = await res.json();
- if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
- netAmount = json.net;
- net.value = json.net;
- } catch (err) {
- globalError.value = `Error: ${err.message}`;
- loading.value = false;
- return;
- }
- loading.value = false;
- await Promise.all(otherServices.map(async svc => {
- svc.loading = true;
- svc.list = null;
- svc.error = null;
- try {
- const url = new URL(`${API_BASE}/net_to_list`);
- url.searchParams.set('service', svc.key);
- url.searchParams.set('amount', netAmount);
- const res = await fetch(url.toString());
- const json = await res.json();
- if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
- svc.list = json.list;
- } catch (err) {
- svc.error = err.message;
- } finally {
- svc.loading = false;
- }
- }));
- }
- watch(airbnbPrice, () => {
- clearTimeout(debounceTimer);
- debounceTimer = setTimeout(calculate, 500);
- });
- onMounted(fetchServices);
- return { airbnbPrice, loading, globalError, net, otherServices, anyServiceLoading, calculate };
- }
- }).mount('#app');
- </script>
- </body>
- </html>
|