Browse Source

first commit

ZiB 5 months ago
commit
7e11ccde91
7 changed files with 760 additions and 0 deletions
  1. BIN
      .DS_Store
  2. 200 0
      app.py
  3. 5 0
      global.json
  4. 13 0
      gunicorn.conf.py
  5. 2 0
      requirements.txt
  6. 518 0
      rpc.html
  7. 22 0
      services.json

BIN
.DS_Store


+ 200 - 0
app.py

@@ -0,0 +1,200 @@
+import json
+import os
+from flask import Flask, request, jsonify
+from flask_cors import CORS
+
+_url_prefix = os.environ.get("URL_PREFIX", "room").strip("/")
+_port = os.environ.get("FLASK_PORT", "5000").strip()
+_debug = os.environ.get("FLASK_DEBUG", "").strip()
+_here = os.path.dirname(__file__)
+DEBUG = True if _debug else False
+URL_PREFIX = f"/{_url_prefix}" if _url_prefix else ""
+PORT = int(_port)
+
+app = Flask(__name__)
+CORS(app)
+
+# Service config
+with open(os.path.join(_here, "services.json")) as f:
+  SERVICES = json.load(f)
+DEFAULT_SERVICE = next(iter(SERVICES))
+
+# Global config
+with open(os.path.join(_here, "global.json")) as f:
+  GLOBAL = json.load(f)
+if 'taxes' not in GLOBAL or 'sales' not in GLOBAL['taxes']:
+  raise ValueError('GLOBAL: Missing sales tax')
+SALES_TAX = GLOBAL['taxes']['sales']
+
+# Formatters
+def dollar_fmt(value: float) -> str:
+    return "{:.2f}".format(round(value, 2))
+def title_fmt(value: str) -> str:
+    return value.replace('_', ' ').title()
+def details_fmt(details: dict) -> dict:
+  fmt = {}
+  for k, v in details.items():
+    fmt[title_fmt(k)] = dollar_fmt(v)
+  return fmt
+
+# Request handlers
+def _request(request) -> dict:
+  _req = request.get_json(silent=True) or request.args
+  _data = { 'service_key': None }
+  if 'service' in _req:
+    _data['service_key'] = str(_req['service']).lower().strip()
+  if 'amount' in _req:
+    _data['amount'] = float(_req['amount'])
+  for k in [ 'service_key', 'amount' ]:
+    if k not in _data or not _data[k]:
+      raise ValueError(k)
+  _data['service'] = SERVICES.get(_data['service_key'])
+  if _data['service'] is None:
+    raise ValueError('service')
+  _data['details'] = {}
+  return _data
+
+def _response(**data) -> str:
+  _dict = { 'service_key' : data['service_key'] }
+  for k in [ 'listed', 'retail', 'net' ]:
+    if data.get(k) is not None:
+      _dict[k] = dollar_fmt(data.get(k))
+  if data.get('details') is not None:
+    _dict['details'] = details_fmt(data.get('details'))
+  return jsonify(_dict)
+
+# Actual math
+def wrong_tax_loss(listed: float, wrong_tax: float, details: dict = None) -> float:
+  if details is None:
+    details = {}
+  details['sales_tax'] = listed * SALES_TAX
+  details['wrong_tax'] = listed * wrong_tax
+  details['wrong_tax_loss'] = details['sales_tax'] - details['wrong_tax']
+  return details['wrong_tax_loss']
+
+def _n2l(data: dict) -> None:
+  svc = data['service']
+  det = data['details']
+  net = data['net'] if data.get('net') else data['amount']
+
+  comm_rate = svc.get('fees', {}).get('commission', 0)
+  wrong_rate = svc.get('taxes', {}).get('wrong', SALES_TAX)
+  listed = (net / (1 - comm_rate - SALES_TAX + wrong_rate))
+  if 'fees' in svc and 'commission' in svc['fees']:
+    det['commission'] = listed * comm_rate
+  if 'taxes' in svc and 'wrong' in svc['taxes']:
+    det['wrong_tax'] = listed * wrong_rate
+    det['sales_tax'] = listed * SALES_TAX
+    det['wrong_tax_loss'] = det['sales_tax'] - det['wrong_tax']
+  data['listed'] = listed
+def _l2n(data: dict) -> None:
+  svc = data['service']
+  det = data['details']
+  net = data['listed'] if data.get('listed') else data['amount']
+  if 'fees' in svc and 'commission' in svc['fees']:
+    det['commission'] = net * svc['fees']['commission']
+  if 'taxes' in svc and 'wrong' in svc['taxes']:
+    det['sales_tax'] = net * SALES_TAX
+    det['wrong_tax'] = net * svc['taxes']['wrong']
+    det['wrong_tax_loss'] = det['sales_tax'] - det['wrong_tax']
+  for k in [ 'commission', 'wrong_tax_loss' ]:
+    if k in det:
+      net -= det[k]
+  data['net'] = net
+
+def _l2r(data: dict) -> None:
+  svc = data['service']
+  det = data['details']
+  retail = data['listed'] if data.get('listed') else data['amount']
+  if 'fees' in svc and 'client_fee' in svc['fees']:
+    det['client_fee'] = retail * svc['fees']['client_fee']
+    retail += det['client_fee']
+  if 'taxes' in svc and 'wrong' in svc['taxes']:
+    det['wrong_tax'] = retail * svc['taxes']['wrong']
+    retail += det['wrong_tax']
+  else:
+    det['sales_tax'] = retail * SALES_TAX
+    retail += det['sales_tax']
+  if 'taxes' in svc and 'flat_tax' in svc['taxes']:
+    det['service_tax'] = svc['taxes']['flat_tax']
+    retail += det['service_tax']
+  data['retail'] = retail
+def _r2l(data: dict) -> None:
+  svc = data['service']
+  det = data['details']
+  listed = data['retail'] if data.get('retail') else data['amount']
+  if 'taxes' in svc and 'flat_tax' in svc['taxes']:
+    det['service_tax'] = svc['taxes']['flat_tax']
+    listed -= det['service_tax']
+  if 'taxes' in svc and 'wrong' in svc['taxes']:
+    det['wrong_tax'] = listed - (listed / (1 + svc['taxes']['wrong']))
+    listed -= det['wrong_tax']
+  else:
+    det['sales_tax'] = listed - (listed / (1 + SALES_TAX))
+    listed -= det['sales_tax']
+  if 'fees' in svc and 'client_fee' in svc['fees']:
+    det['client_fee'] = listed - (listed / (1 + svc['fees']['client_fee']))
+    listed -= det['client_fee']
+  data['listed'] = listed
+
+# Routing
+@app.route(f"{URL_PREFIX}/services", methods=['GET'])
+def list_services():
+  return jsonify([
+    {'key': key, 'name': cfg['name']}
+    for key, cfg in SERVICES.items()
+  ])
+@app.route(f"{URL_PREFIX}/net_to_list", methods=['GET', 'POST'])
+def net_to_list():
+  try:
+    data = _request(request)
+  except (TypeError, ValueError):
+    return jsonify({'error': 'Invalid or missing parameters'}), 400
+  _n2l(data)
+  return _response(**data)
+@app.route(f"{URL_PREFIX}/list_to_net", methods=['GET', 'POST'])
+def list_to_net():
+  try:
+    data = _request(request)
+  except (TypeError, ValueError):
+    return jsonify({'error': 'Invalid or missing parameters'}), 400
+  _l2n(data)
+  return _response(**data)
+@app.route(f"{URL_PREFIX}/list_to_retail", methods=['GET', 'POST'])
+def list_to_retail():
+  try:
+    data = _request(request)
+  except (TypeError, ValueError):
+    return jsonify({'error': 'Invalid or missing parameters'}), 400
+  _l2r(data)
+  return _response(**data)
+@app.route(f"{URL_PREFIX}/retail_to_list", methods=['GET', 'POST'])
+def retail_to_list():
+  try:
+    data = _request(request)
+  except (TypeError, ValueError):
+    return jsonify({'error': 'Invalid or missing parameters'}), 400
+  _r2l(data)
+  return _response(**data)
+@app.route(f"{URL_PREFIX}/net_to_retail", methods=['GET', 'POST'])
+def net_to_retail():
+  try:
+    data = _request(request)
+  except (TypeError, ValueError):
+    return jsonify({'error': 'Invalid or missing parameters'}), 400
+  _n2l(data)
+  _l2r(data)
+  return _response(**data)
+@app.route(f"{URL_PREFIX}/retail_to_net", methods=['GET', 'POST'])
+def retail_to_net():
+  try:
+    data = _request(request)
+  except (TypeError, ValueError):
+    return jsonify({'error': 'Invalid or missing parameters'}), 400
+  _r2l(data)
+  _l2n(data)
+  return _response(**data)
+
+# Main app
+if __name__ == '__main__':
+    app.run(debug=DEBUG, port=PORT)

+ 5 - 0
global.json

@@ -0,0 +1,5 @@
+{
+  "taxes": {
+    "sales": 0.157
+  }
+}

+ 13 - 0
gunicorn.conf.py

@@ -0,0 +1,13 @@
+import multiprocessing
+
+bind = ":6756"
+
+workers = max(int(multiprocessing.cpu_count() / 2), 2)
+#worker-class = "asgi"
+
+control_socket_disable = True
+accesslog = "-"
+access_log_format = '%({host}i)s %(r)s %(s)s %(b)s %(h)s'
+
+reload = True
+debug = False

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+Flask
+Flask-CORS

+ 518 - 0
rpc.html

@@ -0,0 +1,518 @@
+<!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: 'listed', color: 'green'  },
+  { endpoint: 'list_to_net',    from: 'listed', to: 'net',    color: 'purple' },
+  { endpoint: 'list_to_retail', from: 'listed', to: 'retail', color: 'blue'   },
+  { endpoint: 'retail_to_list', from: 'retail', to: 'listed', 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 = { listed: '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 apiBase = () => API_BASE;
+
+    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(`${apiBase()}/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 = { listed: null, retail: null, net: null };
+      s.details = null;
+      try {
+        const url = new URL(`${apiBase()}/${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 = {
+          listed: json.listed ?? 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 ['listed', '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>

+ 22 - 0
services.json

@@ -0,0 +1,22 @@
+{
+  "airbnb": {
+    "name": "AirBnB",
+    "taxes": {
+      "flat_tax": 4,
+      "provider_pays": true
+    },
+    "fees": {
+      "commission": 0.03,
+      "client_fee": 0.1411235955
+    }
+  },
+  "booking": {
+    "name": "Booking.com",
+    "taxes": {
+      "wrong": 0.145
+    },
+    "fees": {
+      "commission": 0.15
+    }
+  }
+}