air2list.html 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <title>Price Comparison</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com" />
  8. <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;800&display=swap" rel="stylesheet" />
  9. <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  10. <style>
  11. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  12. :root {
  13. --bg: #0e0f11;
  14. --surface: #16181c;
  15. --border: #2a2d35;
  16. --accent: #c8f060;
  17. --accent2: #60c8f0;
  18. --text: #e8eaf0;
  19. --muted: #6b7080;
  20. --danger: #f06080;
  21. --mono: 'DM Mono', monospace;
  22. --display: 'Syne', sans-serif;
  23. }
  24. body {
  25. background: var(--bg);
  26. color: var(--text);
  27. font-family: var(--mono);
  28. min-height: 100vh;
  29. display: flex;
  30. justify-content: center;
  31. padding: 64px 16px;
  32. }
  33. body::before {
  34. content: '';
  35. position: fixed;
  36. inset: 0;
  37. 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");
  38. pointer-events: none;
  39. z-index: 0;
  40. opacity: 0.6;
  41. }
  42. #app {
  43. position: relative;
  44. z-index: 1;
  45. width: 100%;
  46. max-width: 420px;
  47. }
  48. .input-wrap {
  49. display: flex;
  50. align-items: center;
  51. background: #1e2028;
  52. border: 1px solid var(--border);
  53. border-radius: 10px;
  54. padding: 0 16px;
  55. margin-bottom: 16px;
  56. transition: border-color 0.15s, box-shadow 0.15s;
  57. }
  58. .input-wrap:focus-within {
  59. border-color: var(--accent);
  60. box-shadow: 0 0 0 3px rgba(200, 240, 96, 0.1);
  61. }
  62. .prefix {
  63. font-size: 12px;
  64. color: var(--muted);
  65. letter-spacing: 0.08em;
  66. text-transform: uppercase;
  67. flex-shrink: 0;
  68. margin-right: 10px;
  69. }
  70. .dollar {
  71. font-family: var(--display);
  72. font-size: 1.8rem;
  73. font-weight: 800;
  74. color: var(--accent);
  75. flex-shrink: 0;
  76. line-height: 1;
  77. }
  78. input[type="number"] {
  79. background: transparent;
  80. border: none;
  81. color: var(--text);
  82. font-family: var(--display);
  83. font-weight: 800;
  84. font-size: 1.8rem;
  85. padding: 14px 10px;
  86. outline: none;
  87. flex: 1;
  88. min-width: 0;
  89. }
  90. input[type="number"]::-webkit-inner-spin-button,
  91. input[type="number"]::-webkit-outer-spin-button { display: none; }
  92. input[type="number"] { -moz-appearance: textfield; }
  93. .results {
  94. display: flex;
  95. flex-direction: column;
  96. gap: 3px;
  97. }
  98. .result-row {
  99. display: flex;
  100. align-items: center;
  101. justify-content: space-between;
  102. padding: 14px 20px;
  103. background: var(--surface);
  104. border: 1px solid var(--border);
  105. border-radius: 8px;
  106. }
  107. .row-label {
  108. font-size: 12px;
  109. color: var(--muted);
  110. letter-spacing: 0.08em;
  111. text-transform: uppercase;
  112. }
  113. .row-value {
  114. font-family: var(--display);
  115. font-size: 1.5rem;
  116. font-weight: 800;
  117. letter-spacing: -0.02em;
  118. }
  119. .result-row.net .row-value { color: var(--accent); }
  120. .result-row.svc .row-value { color: var(--accent2); }
  121. .result-row.net.live { border-color: #3a5020; background: #131a0f; }
  122. .result-row.svc.live { border-color: #20454a; background: #0f1a1d; }
  123. .spinner {
  124. display: inline-block;
  125. width: 14px; height: 14px;
  126. border: 2px solid var(--muted);
  127. border-top-color: transparent;
  128. border-radius: 50%;
  129. animation: spin 0.7s linear infinite;
  130. }
  131. @keyframes spin { to { transform: rotate(360deg); } }
  132. .global-error {
  133. font-size: 12px;
  134. color: var(--danger);
  135. margin-bottom: 12px;
  136. padding: 0 4px;
  137. }
  138. </style>
  139. </head>
  140. <body>
  141. <div id="app">
  142. <div class="input-wrap">
  143. <span class="prefix">AirBnB</span>
  144. <span class="dollar">$</span>
  145. <input
  146. type="number"
  147. v-model.number="airbnbPrice"
  148. placeholder="0.00"
  149. step="1"
  150. min="0"
  151. @keyup.enter="calculate"
  152. />
  153. </div>
  154. <div v-if="globalError" class="global-error">{{ globalError }}</div>
  155. <div v-if="net !== null || loading || anyServiceLoading" class="results">
  156. <div class="result-row net" :class="{ live: net !== null && !loading }">
  157. <span class="row-label">Net</span>
  158. <span v-if="loading" class="spinner"></span>
  159. <span v-else class="row-value">${{ net }}</span>
  160. </div>
  161. <div
  162. v-for="svc in otherServices"
  163. :key="svc.key"
  164. class="result-row svc"
  165. :class="{ live: svc.list !== null && !svc.loading }"
  166. >
  167. <span class="row-label">{{ svc.name }}</span>
  168. <span v-if="svc.loading" class="spinner"></span>
  169. <span v-else-if="svc.list !== null" class="row-value">${{ svc.list }}</span>
  170. </div>
  171. </div>
  172. </div>
  173. <script>
  174. const { createApp, ref, reactive, computed, watch, onMounted } = Vue;
  175. createApp({
  176. setup() {
  177. const API_BASE = 'https://zi3.xyz/room';
  178. const airbnbPrice = ref(null);
  179. const loading = ref(false);
  180. const globalError = ref(null);
  181. const net = ref(null);
  182. const otherServices = reactive([]);
  183. let debounceTimer = null;
  184. const anyServiceLoading = computed(() => otherServices.some(s => s.loading));
  185. async function fetchServices() {
  186. try {
  187. const res = await fetch(`${API_BASE}/services`);
  188. const list = await res.json();
  189. otherServices.splice(0, otherServices.length,
  190. ...list.filter(s => s.key !== 'airbnb').map(s => ({
  191. ...s, list: null, loading: false, error: null,
  192. }))
  193. );
  194. } catch {
  195. globalError.value = 'Failed to load services.';
  196. }
  197. }
  198. async function calculate() {
  199. if (!airbnbPrice.value || airbnbPrice.value <= 0) {
  200. net.value = null;
  201. globalError.value = null;
  202. for (const svc of otherServices) { svc.list = null; svc.error = null; }
  203. return;
  204. }
  205. loading.value = true;
  206. globalError.value = null;
  207. let netAmount;
  208. try {
  209. const url = new URL(`${API_BASE}/list_to_net`);
  210. url.searchParams.set('service', 'airbnb');
  211. url.searchParams.set('amount', airbnbPrice.value);
  212. const res = await fetch(url.toString());
  213. const json = await res.json();
  214. if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
  215. netAmount = json.net;
  216. net.value = json.net;
  217. } catch (err) {
  218. globalError.value = `Error: ${err.message}`;
  219. loading.value = false;
  220. return;
  221. }
  222. loading.value = false;
  223. await Promise.all(otherServices.map(async svc => {
  224. svc.loading = true;
  225. svc.list = null;
  226. svc.error = null;
  227. try {
  228. const url = new URL(`${API_BASE}/net_to_list`);
  229. url.searchParams.set('service', svc.key);
  230. url.searchParams.set('amount', netAmount);
  231. const res = await fetch(url.toString());
  232. const json = await res.json();
  233. if (!res.ok || json.error) throw new Error(json.error || `HTTP ${res.status}`);
  234. svc.list = json.list;
  235. } catch (err) {
  236. svc.error = err.message;
  237. } finally {
  238. svc.loading = false;
  239. }
  240. }));
  241. }
  242. watch(airbnbPrice, () => {
  243. clearTimeout(debounceTimer);
  244. debounceTimer = setTimeout(calculate, 500);
  245. });
  246. onMounted(fetchServices);
  247. return { airbnbPrice, loading, globalError, net, otherServices, anyServiceLoading, calculate };
  248. }
  249. }).mount('#app');
  250. </script>
  251. </body>
  252. </html>