# Thin-frontend example — salon shop

A complete, minimal MeshHub shop in three files, demonstrating the
recommended integration pattern:

```
browser (public/)  →  your backend (server.js)  →  MeshHub API
                       @meshhub/sdk + API key      merchant.meshhub.ai
```

**The API key and the SDK live only on the backend.** The browser talks
to your own `/api/*` routes and never sees the key — putting an
`X-MH-Key` header in frontend code would publish your key to every
visitor.

## What it shows

- **Search** (`POST /api/search`): `searchAndWait` handles the async
  task polling; the response's `field_schema` drives the booking form —
  the frontend renders inputs from it instead of hardcoding fields.
- **Book** (`POST /api/book`): rebuilds the collected dotted
  `end_user.*` / `extra.*` field keys into the nested `/book/` payload
  objects (`extra.shipper_details.contact_name` →
  `extra: {shipper_details: {contact_name}}`) and passes the offer's
  `price` through as `quoted_price`. An `enquiry`-mode offer books as a
  *request*: the order stays `pending` until the supplier confirms (via
  webhook), so the UI says "request sent" and skips payment. The
  booking confirmation guard is **server-side** (`confirm: true`
  required) — a frontend-only dialog can be bypassed.
- **Pay** (`POST /api/pay`): `payments.payOrder`; when
  `requires_action` is set the browser is redirected to the hosted
  checkout, otherwise the saved card was charged server-side.
- **Order history** (`GET /api/orders`): `orders.list` filtered by your
  own customer id — no local order database needed.
- **Profile** (`GET`/`PUT /api/profile`): the customer record lives on
  MeshHub too — `endUsers.list({external_id})` resolves your own user id
  to the stored profile, `PATCH`/`create` saves edits. No local customer
  database needed; your DB is for logins and sessions only.
- **Errors**: MeshHub failures throw typed `MeshHubError`s; the backend
  forwards the stable `slug` + message so the UI can show something
  useful.

## Run it

```bash
npm install

# Mint a free 7-day trial key (3/hour per IP — store and reuse it!):
curl -s -X POST https://merchant.meshhub.ai/api/v1/auth/trial-keys/

MESHHUB_API_KEY=mh_your_key node server.js
# open http://localhost:3000
```

On Replit or similar, put `MESHHUB_API_KEY` in the Secrets tool — never
in a committed file.

> ⚠️ Trial keys execute against **live suppliers** — a booking is a real
> booking. Search freely; book only what you mean.

## The code

### `package.json`

```json
{
  "name": "meshhub-thin-frontend-example",
  "private": true,
  "version": "1.0.0",
  "description": "Minimal reference shop: static frontend -> own Node backend -> MeshHub via @meshhub/sdk. The API key never reaches the browser.",
  "type": "module",
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "@meshhub/sdk": "^0.3.0"
  }
}
```

### `server.js`

```js
// MeshHub thin-frontend reference backend.
//
// The browser talks ONLY to this server; this server talks to MeshHub
// with @meshhub/sdk. The API key lives in the MESHHUB_API_KEY env var
// (a Replit/hosting secret, or a gitignored .env) and never reaches
// the frontend.
//
// Run:  MESHHUB_API_KEY=mh_... node server.js
// Then open http://localhost:3000

import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { dirname, extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";

import { MeshHubError, MeshHubClient } from "@meshhub/sdk";

const API_KEY = process.env.MESHHUB_API_KEY;
if (!API_KEY) {
  console.error("Set MESHHUB_API_KEY (mint one: POST /api/v1/auth/trial-keys/)");
  process.exit(1);
}

const meshhub = new MeshHubClient({
  apiKey: API_KEY,
  baseUrl: process.env.MESHHUB_BASE_URL ?? "https://merchant.meshhub.ai",
});

const PORT = Number(process.env.PORT ?? 3000);
const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "public");
const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" };

// One demo visitor id. A real shop would use its own session/user id so
// MeshHub can prefill repeat bookings and charge saved cards later.
const CUSTOMER_ID = "thin-frontend-demo";

const routes = {
  // Populate the city selector from live inventory.
  "GET /api/cities": async () => {
    const { results } = await meshhub.salons.cities();
    return { cities: results };
  },

  // Search salon services. field_schema tells the frontend which
  // booking inputs to render — no hardcoded form.
  "POST /api/search": async (body) => {
    const extra = {};
    if (body.query) extra.query = body.query;
    if (body.city) extra.city = body.city;
    const { offers, field_schema } = await meshhub.searchAndWait({
      category: "hair_salon",
      extra,
    });
    return { offers, field_schema };
  },

  // Book an offer. The frontend sends back exactly the field_schema
  // keys it collected; the dotted keys (end_user.email,
  // extra.shipper_details.contact_name, ...) map to NESTED objects in
  // the book payload — rebuild them, don't keep literal dotted keys.
  "POST /api/book": async (body) => {
    const { offer, fields, confirm } = body;
    // The booking guard lives HERE, server-side — a frontend-only
    // dialog can be bypassed, and a trial-key booking is a real one.
    if (confirm !== true) {
      throw Object.assign(new Error("Pass confirm: true after the user explicitly confirms."), {
        httpStatus: 409,
        slug: "confirmation_required",
      });
    }
    const end_user = {};
    const extra = {};
    for (const [key, value] of Object.entries(fields)) {
      const [ns, ...path] = key.split(".");
      if (ns === "end_user") setPath(end_user, path, value);
      if (ns === "extra") setPath(extra, path, value);
    }
    const order = await meshhub.bookAndWait({
      category: offer.category,
      supplier_id: offer.supplier_id,
      offer_id: offer.offer_id,
      end_user_external_id: CUSTOMER_ID,
      end_user,
      extra,
      quoted_price: offer.price, // price is a decimal string — pass it through
    });
    return { order };
  },

  // Take payment. If requires_action is set the customer must finish
  // checkout in the browser — hand the URL to the frontend.
  "POST /api/pay": async (body) => {
    const payment = await meshhub.payments.payOrder(body.supplier_order_id);
    return { payment };
  },

  // Order history straight from MeshHub — no local order store.
  "GET /api/orders": async () => {
    const page = await meshhub.orders.list({ customer: CUSTOMER_ID, limit: 20 });
    return { orders: page.results };
  },

  // Customer profile straight from MeshHub — no local customer store.
  // The exact-match external_id filter returns zero or one result.
  "GET /api/profile": async () => {
    const { results } = await meshhub.endUsers.list({ external_id: CUSTOMER_ID });
    return { profile: results[0] ?? null };
  },

  // Create-or-update the profile on MeshHub. PATCH needs the MeshHub
  // UUID, which the lookup above provides.
  "PUT /api/profile": async (body) => {
    const fields = {
      first_name: body.first_name ?? "",
      last_name: body.last_name ?? "",
      email: body.email ?? "",
      phone: body.phone ?? "",
    };
    const { results } = await meshhub.endUsers.list({ external_id: CUSTOMER_ID });
    const profile = results[0]
      ? await meshhub.endUsers.patch(results[0].id, fields)
      : await meshhub.endUsers.create({ external_id: CUSTOMER_ID, ...fields });
    return { profile };
  },
};

createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const handler = routes[`${req.method} ${url.pathname}`];

  if (handler) {
    try {
      const hasBody = req.method === "POST" || req.method === "PUT";
      const body = hasBody ? await readJson(req) : undefined;
      const result = await handler(body);
      return sendJson(res, 200, result);
    } catch (err) {
      if (err.httpStatus) {
        return sendJson(res, err.httpStatus, { error: err.slug, message: err.message });
      }
      // MeshHub errors carry a stable slug + human message — surface
      // those to the frontend instead of a bare 500.
      if (err instanceof MeshHubError) {
        return sendJson(res, 422, {
          error: err.slug,
          message: err.message,
          fields: err.fields ?? undefined,
        });
      }
      console.error(err);
      return sendJson(res, 500, { error: "internal", message: "Server error" });
    }
  }

  // Static frontend.
  const file = url.pathname === "/" ? "index.html" : normalize(url.pathname).replace(/^([/\\])+/, "");
  try {
    const content = await readFile(join(PUBLIC_DIR, file));
    res.writeHead(200, { "Content-Type": MIME[extname(file)] ?? "text/plain" });
    return res.end(content);
  } catch {
    return sendJson(res, 404, { error: "not_found", message: "Not found" });
  }
}).listen(PORT, () => console.log(`Shop running on http://localhost:${PORT}`));

function setPath(obj, path, value) {
  let node = obj;
  for (const part of path.slice(0, -1)) node = node[part] ??= {};
  node[path.at(-1)] = value;
}

async function readJson(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  return chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
}

function sendJson(res, status, payload) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(payload));
}
```

### `public/index.html`

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Salon shop — MeshHub thin-frontend example</title>
  <style>
    :root { font-family: system-ui, sans-serif; color: #1a1a1a; }
    body { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
    h1 { font-size: 1.4rem; }
    fieldset { border: 1px solid #ddd; border-radius: 8px; margin: 1rem 0; padding: 1rem; }
    label { display: block; margin: .5rem 0 .15rem; font-size: .9rem; }
    input, select { width: 100%; padding: .45rem; box-sizing: border-box; }
    button { margin-top: .75rem; padding: .5rem 1rem; cursor: pointer; }
    .offer { border: 1px solid #ddd; border-radius: 8px; padding: .75rem 1rem; margin: .5rem 0; }
    .offer h3 { margin: 0 0 .25rem; font-size: 1rem; }
    .price { font-weight: 600; }
    .muted { color: #666; font-size: .85rem; }
    #status { white-space: pre-wrap; background: #f6f6f6; border-radius: 8px; padding: .75rem 1rem; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <h1>💇 Salon shop <span class="muted">(MeshHub thin-frontend example)</span></h1>

  <fieldset>
    <legend>Find a service</legend>
    <label for="query">What are you looking for?</label>
    <input id="query" placeholder='e.g. "haircut" — or leave empty for everything' />
    <label for="city">City</label>
    <select id="city"><option value="">Any city</option></select>
    <button id="search">Search</button>
  </fieldset>

  <div id="offers"></div>

  <fieldset id="booking" class="hidden">
    <legend>Book: <span id="booking-title"></span></legend>
    <form id="booking-form"></form>
    <button id="book">Book</button>
  </fieldset>

  <p id="status" class="hidden"></p>

  <fieldset>
    <legend>My orders</legend>
    <button id="refresh-orders">Refresh</button>
    <div id="orders"></div>
  </fieldset>

  <fieldset>
    <legend>My profile <span class="muted">(stored on MeshHub)</span></legend>
    <label for="profile-first-name">First name</label>
    <input id="profile-first-name" />
    <label for="profile-last-name">Last name</label>
    <input id="profile-last-name" />
    <label for="profile-email">Email</label>
    <input id="profile-email" type="email" />
    <label for="profile-phone">Phone</label>
    <input id="profile-phone" placeholder="+370..." />
    <button id="save-profile">Save profile</button>
  </fieldset>

  <script src="/app.js"></script>
</body>
</html>
```

### `public/app.js`

```js
// Frontend for the MeshHub thin-frontend example. Talks only to the
// app's own backend (/api/*) — it never sees the MeshHub API key.

const $ = (id) => document.getElementById(id);
let selectedOffer = null;

async function api(method, path, body) {
  const res = await fetch(path, {
    method,
    headers: body ? { "Content-Type": "application/json" } : undefined,
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.message || data.error || res.statusText);
  return data;
}

function status(message) {
  $("status").classList.remove("hidden");
  $("status").textContent = message;
}

// --- Cities selector (live inventory) ---------------------------------
api("GET", "/api/cities")
  .then(({ cities }) => {
    for (const row of cities) {
      const opt = document.createElement("option");
      opt.value = row.city ?? row;
      opt.textContent = opt.value;
      $("city").appendChild(opt);
    }
  })
  .catch((err) => status(`Could not load cities: ${err.message}`));

// --- Search ------------------------------------------------------------
$("search").addEventListener("click", async () => {
  status("Searching…");
  $("offers").innerHTML = "";
  $("booking").classList.add("hidden");
  try {
    const { offers, field_schema } = await api("POST", "/api/search", {
      query: $("query").value.trim(),
      city: $("city").value,
    });
    if (!offers.length) {
      status("No offers matched — try another query or city.");
      return;
    }
    status(`${offers.length} offer(s) found.`);
    for (const offer of offers) {
      const card = document.createElement("div");
      card.className = "offer";
      const whitespace = /\s+/g; // supplier free text can carry raw \r\n
      card.innerHTML = `
        <h3>${escapeHtml(offer.summary).replace(whitespace, " ")}</h3>
        <div class="price">${offer.price} ${offer.currency}</div>
        <div class="muted">${escapeHtml(offer.extras?.supplier_name ?? offer.supplier_id)}
          · ${escapeHtml(offer.extras?.city ?? "")}
          · booking: ${offer.booking_mode}</div>
        <button>Select</button>`;
      card.querySelector("button").addEventListener("click", () => {
        selectedOffer = offer;
        renderBookingForm(offer, field_schema);
      });
      $("offers").appendChild(card);
    }
  } catch (err) {
    status(`Search failed: ${err.message}`);
  }
});

// --- Booking form, rendered from field_schema --------------------------
function renderBookingForm(offer, fieldSchema) {
  const form = $("booking-form");
  form.innerHTML = "";
  $("booking-title").textContent = offer.summary;
  for (const [name, field] of Object.entries(fieldSchema)) {
    const label = document.createElement("label");
    label.textContent = `${field.label}${field.required ? " *" : ""}`;
    label.htmlFor = name;
    const input = document.createElement("input");
    input.id = name;
    input.name = name;
    input.required = Boolean(field.required);
    if (field.format === "email") input.type = "email";
    if (field.format === "date-time") input.type = "datetime-local";
    if (field.description) input.placeholder = field.description;
    form.append(label, input);
  }
  $("booking").classList.remove("hidden");
  $("booking").scrollIntoView({ behavior: "smooth" });
}

// --- Book, then pay ------------------------------------------------------
$("book").addEventListener("click", async () => {
  const fields = {};
  for (const input of $("booking-form").elements) {
    if (!input.name) continue;
    if (input.required && !input.value) {
      status(`"${input.previousSibling.textContent}" is required.`);
      return;
    }
    if (input.value) fields[input.name] = input.value;
  }
  status("Booking…");
  try {
    // confirm reflects the user's explicit "Book" click; the backend
    // refuses to call MeshHub without it.
    const { order } = await api("POST", "/api/book", { offer: selectedOffer, fields, confirm: true });
    if (selectedOffer.booking_mode === "enquiry" && order.status === "pending") {
      // Enquiry-mode supplier: the order is a request, confirmed (and
      // payable) only after the supplier accepts — don't try to pay yet.
      status(`Request sent — order ${order.supplier_order_id} is awaiting salon confirmation.`);
      return;
    }
    status(`Booked! Order ${order.supplier_order_id} (${order.status}).`);
    const { payment } = await api("POST", "/api/pay", {
      supplier_order_id: order.supplier_order_id,
    });
    if (payment.requires_action && payment.checkout_url) {
      status("Redirecting to secure checkout…");
      window.location.href = payment.checkout_url;
    } else {
      status('Payment processed — check "My orders".');
    }
  } catch (err) {
    status(`Booking failed: ${err.message}`);
  }
});

// --- Orders --------------------------------------------------------------
$("refresh-orders").addEventListener("click", async () => {
  try {
    const { orders } = await api("GET", "/api/orders");
    $("orders").innerHTML = orders.length ? "" : '<p class="muted">No orders yet.</p>';
    for (const order of orders) {
      const row = document.createElement("div");
      row.className = "offer";
      row.textContent = `${order.supplier_order_id} · ${order.status} · ${order.price} ${order.currency}`;
      $("orders").appendChild(row);
    }
  } catch (err) {
    status(`Could not load orders: ${err.message}`);
  }
});

// --- Profile (stored on MeshHub, not in a local DB) ----------------------
const PROFILE_FIELDS = {
  first_name: "profile-first-name",
  last_name: "profile-last-name",
  email: "profile-email",
  phone: "profile-phone",
};

api("GET", "/api/profile")
  .then(({ profile }) => {
    if (!profile) return; // first visit — nothing stored yet
    for (const [field, id] of Object.entries(PROFILE_FIELDS)) {
      $(id).value = profile[field] ?? "";
    }
  })
  .catch((err) => status(`Could not load profile: ${err.message}`));

$("save-profile").addEventListener("click", async () => {
  const body = {};
  for (const [field, id] of Object.entries(PROFILE_FIELDS)) {
    body[field] = $(id).value.trim();
  }
  status("Saving profile…");
  try {
    await api("PUT", "/api/profile", body);
    status("Profile saved on MeshHub.");
  } catch (err) {
    status(`Could not save profile: ${err.message}`);
  }
});

function escapeHtml(text) {
  const div = document.createElement("div");
  div.textContent = text ?? "";
  return div.innerHTML;
}
```
