npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mantaray-digital/plato-sdk

v0.5.0

Published

Official SDK for the Plato restaurant SaaS. Build a custom storefront against the Plato backend.

Readme

@mantaray-digital/plato-sdk

Official SDK for the Plato restaurant SaaS. Use it to build a custom-branded customer-facing storefront on top of a restaurant's Plato backend — delivery, pickup, takeaway, and dine-in QR ordering. Every order placed through this SDK appears in the restaurant's Plato dashboard, KDS, and reporting exactly like an in-house order.

  • HTTP-first: one-shot requests over Convex's HTTP client. No WebSocket. React hooks fetch once and expose refetch().
  • Typed: full TypeScript surface. No any in your storefront.
  • React-friendly: optional React hooks subpath (@mantaray-digital/plato-sdk/react) for one-line data fetching with a provider.
  • All order types: delivery, pickup, takeaway, and dine-in via printed table QR codes (see §10).
  • COD-only in v1: every order is created with paymentMethod: "cash" and paymentStatus: "pending". The restaurant collects payment in person / on delivery. Online payments will arrive in a future release.
  • Guest or logged-in: place orders with OTP login (recommended for repeat customers) or as a guest by passing customerPhone + customerName on each call. See §9.

1. Installation

pnpm add @mantaray-digital/plato-sdk convex react
# or with npm:
npm install @mantaray-digital/plato-sdk convex react

react is only required if you use the @mantaray-digital/plato-sdk/react subpath. Pure-JS callers can skip it.


2. Configuration

You need two values from the Plato dashboard:

  1. Convex URL — shown at the top of the Integrations → SDK API Keys page. Looks like https://wise-warbler-42.convex.cloud.
  2. API key — click Generate New Key. The plain key is shown once in a modal; copy it immediately. It will look like plato_sk_AbCd1234....

Both are intended to live on the client (storefront browser bundle). They are public per-restaurant credentials, not service secrets. Put them in NEXT_PUBLIC_* env vars (or your framework's equivalent) so the storefront has access.

# .env.local
NEXT_PUBLIC_PLATO_CONVEX_URL=https://wise-warbler-42.convex.cloud
NEXT_PUBLIC_PLATO_API_KEY=plato_sk_AbCd1234ExampleEFGH56789ijklmnopQRS

Security note. The API key gates every SDK call. Anyone who reads it from your bundle can place orders / send OTP texts on the restaurant's behalf. This is the same exposure model as Supabase anon keys or Convex deployment URLs. Mitigations (per-key rate limits, scoped permissions) are on the roadmap. Don't reuse a production storefront key in untrusted environments.


3. Quick start

Create a single shared store instance and reuse it across the app:

// src/lib/plato.ts
import { PlatoStore } from "@mantaray-digital/plato-sdk";

export const plato = new PlatoStore({
  convexUrl: process.env.NEXT_PUBLIC_PLATO_CONVEX_URL!,
  apiKey: process.env.NEXT_PUBLIC_PLATO_API_KEY!,
});

Fetch the menu:

const categories = await plato.menu.categories();
const items = await plato.menu.items({ branchId });

Or, with React, wrap your tree with the provider and use the hooks:

// app/layout.tsx
"use client";
import { PlatoProvider } from "@mantaray-digital/plato-sdk/react";

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <PlatoProvider
      config={{
        convexUrl: process.env.NEXT_PUBLIC_PLATO_CONVEX_URL!,
        apiKey: process.env.NEXT_PUBLIC_PLATO_API_KEY!,
      }}
    >
      {children}
    </PlatoProvider>
  );
}
// app/menu/page.tsx
"use client";
import { usePlatoMenu } from "@mantaray-digital/plato-sdk/react";

export default function MenuPage() {
  const { categories, items, loading, refetch } = usePlatoMenu();
  if (loading) return <div>Loading…</div>;
  return (
    <div>
      <button onClick={refetch}>Refresh</button>
      {categories?.map((c) => (
        <section key={c._id}>
          <h2>{c.name}</h2>
          {items?.filter((i) => i.categoryId === c._id).map((i) => (
            <div key={i._id}>{i.name} — {i.price} {/* EGP */}</div>
          ))}
        </section>
      ))}
    </div>
  );
}

Hooks fetch once on mount. Call refetch() to pull fresh data — e.g. after the user toggles a filter, or on a setInterval for an order-tracking page that should refresh every 15 s.


4. Core concepts

4.1 The store

new PlatoStore(config) constructs a single client with these modules:

| Module | What it covers | |---|---| | plato.restaurant | Branch / location list for the API key | | plato.menu | Categories, items, modifier groups | | plato.customer | OTP login, profile, addresses | | plato.cart | Pricing preview (no DB write) | | plato.delivery | Zone list + per-address fee check | | plato.promo | Promo code validation | | plato.orders | Create / read / list / cancel orders | | plato.tables | Resolve a dine-in table by ID — used in the QR flow (see §10) |

All methods return Promises. All errors are instances of PlatoError (see §10).

Reuse one PlatoStore per app — don't construct a new one for every component.

4.2 Customer sessions

Customers sign in with phone + OTP. After verifyOtp succeeds, the SDK gets a long-lived (90-day) session token and auto-persists it to localStorage. Subsequent app loads pick up the token automatically; the customer stays signed in until they logout, or the token expires/is revoked.

The token lives on PlatoStore (not on the customer module):

plato.getCustomerToken();   // current token or null
plato.setCustomerToken(t);  // manual override (e.g. hydrate from cookie)
plato.clearCustomerToken(); // logout without server roundtrip

To use a different storage strategy (sessionStorage, AsyncStorage on React Native, an HTTP-only cookie via your own server, etc.), pass a storage adapter to the constructor:

import { PlatoStore, memoryStorage, type TokenStorage } from "@mantaray-digital/plato-sdk";

const cookieStorage: TokenStorage = {
  get: () => readCookie("plato_token"),
  set: (t) => writeCookie("plato_token", t),
  clear: () => deleteCookie("plato_token"),
};

export const plato = new PlatoStore({
  convexUrl: ...,
  apiKey: ...,
  storage: cookieStorage,         // or memoryStorage(), or "none"
});

4.3 Order source and payments

Orders created via the SDK are tagged by source:

  • orderSource: "online" — delivery, pickup, takeaway orders (default).
  • orderSource: "qr_table" — dine-in orders placed by scanning a printed table QR code (see §10). The order is automatically attached to an active tableSession for that table and shows up on the POS table view + KDS station feed with the table number.

Two payment paths are supported, matching the built-in storefront:

  • Cash on delivery / on collectionorders.create(...) lands the order with paymentMethod: "cash", paymentStatus: "pending", settled in person (driver, pickup counter, or POS for dine-in).
  • Online card (pay-first)payments.initiateOnlineOrder(...) prices the cart, returns a hosted MyFatoorah payment URL, and only creates the real order once the charge clears (abandoned payments leave no orphan order). See §10A. Check payments.availability() first; the restaurant must have a validated gateway configured.

Dine-in tabs add a third path — pay the running tab by card (session.payOnline) or request a waiter to take cash (session.requestCash). See §10B.

4.4 Branches

The Plato backend is multi-branch. Every menu read and order create takes a branchId. Fetch the list once and let the customer pick (or pre-pick by geolocation):

const branches = await plato.restaurant.getBranches();

In v1 each API key authorizes one restaurant, so getBranches() returns at most one entry.


5. Customer auth flow

End-to-end OTP sign-in. The example assumes React; the imperative API works the same in vanilla JS.

"use client";
import { useState } from "react";
import { useCustomer } from "@mantaray-digital/plato-sdk/react";

export default function SignIn() {
  const { customer, isLoggedIn, requestOtp, verifyOtp, logout } = useCustomer();
  const [phone, setPhone] = useState("");
  const [code, setCode] = useState("");
  const [step, setStep] = useState<"phone" | "code">("phone");

  if (isLoggedIn) {
    return (
      <div>
        Hi {customer?.name ?? customer?.phone}!
        <button onClick={() => logout()}>Sign out</button>
      </div>
    );
  }

  if (step === "phone") {
    return (
      <form
        onSubmit={async (e) => {
          e.preventDefault();
          await requestOtp(phone);
          setStep("code");
        }}
      >
        <input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="01012345678" />
        <button>Send code</button>
      </form>
    );
  }

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        await verifyOtp(phone, code);
        // useCustomer auto-rerenders with isLoggedIn=true.
      }}
    >
      <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" />
      <button>Verify</button>
    </form>
  );
}

Notes

  • Phone numbers are normalized to E.164 server-side. Local Egyptian formats (01012345678) are accepted and converted to +201012345678.
  • A second requestOtp call for the same phone within 10 minutes counts toward a rate limit (max 3 sends). Surface the error if requestOtp throws.
  • verifyOtp revokes any prior active session for that phone in this restaurant and mints a fresh one. Old devices get logged out.

6. Cart pricing

plato.cart.calculate(input) returns a server-computed breakdown without creating an order. Use it to render an accurate cart total, including tax, service charge, additional fees, delivery, and promo discount.

const breakdown = await plato.cart.calculate({
  branchId,
  orderType: "delivery",
  items: [
    { menuItemId, quantity: 2 },
    { menuItemId: id2, quantity: 1, modifiers: [{ groupName: "Size", optionName: "Large", priceAdjustment: 10 }] },
  ],
  addressLatitude: 30.0444,
  addressLongitude: 31.2357,
  promoCode: "WELCOME10",
});

// {
//   currency: "EGP",
//   lines: [...],
//   subtotal: 120,
//   tax: 16.8,                 // 14% Egyptian VAT
//   serviceCharge: 0,
//   additionalFees: [],
//   additionalFeesTotal: 0,
//   deliveryFee: 25,
//   delivery: { inZone: true, distance: 2.4, estimatedTime: 35, zoneName: "Downtown" },
//   promoDiscount: 12,
//   promo: { code: "WELCOME10", name: "Welcome 10%", type: "percentage", ... },
//   promoError: null,
//   total: 149.8,
//   meetsDeliveryMin: true,
// }

With React, use usePlatoCart — it re-runs every time the input changes:

import { usePlatoCart } from "@mantaray-digital/plato-sdk/react";

const { data: breakdown, loading } = usePlatoCart(cartItems.length === 0 ? null : {
  branchId,
  orderType: "delivery",
  items: cartItems,
  addressLatitude: selectedAddress?.latitude,
  addressLongitude: selectedAddress?.longitude,
  promoCode: enteredPromo,
});

Pass null (or an empty items array) to disable the hook when the cart is empty.


7. Delivery

// All active delivery zones (for an info dialog / map overlay)
const zones = await plato.delivery.zones();

// Check whether a specific address is deliverable + what it costs
const check = await plato.delivery.check(30.0444, 31.2357);
if (check.success) {
  // { success: true, inZone: true, deliveryFee, distance, estimatedTime, zoneName?, minOrderAmount? }
} else {
  // { success: false, reason: "outside_zones" | "outside_radius" | "delivery_disabled" | ..., distance }
}

React hooks: useDeliveryZones(), useDeliveryCheck(coords | null).


8. Promo codes

const result = await plato.promo.validate({
  code: "WELCOME10",
  subtotal: 120,
  orderType: "delivery",
  customerPhone: "+201012345678",  // optional — enables per-customer usage limit checks
});

if (result.valid) {
  // { valid: true, code, name, type, discountAmount, isFreeDelivery, ... }
} else {
  // { valid: false, error: "This promo code has expired" }
}

This is a pure read — usage is recorded automatically when you place the order via plato.orders.create. Don't call validate and then expect totalUsed to bump; only an actual order does that.


9. Orders

Create — logged-in

The SDK auto-injects the customer token from storage once verifyOtp succeeds.

const { orderId, orderNumber, total, paymentMethod, estimatedReadyAt } =
  await plato.orders.create({
    branchId,
    orderType: "delivery",          // "dine_in" | "takeaway" | "pickup" | "delivery"
    items: [
      { menuItemId, quantity: 2 },
      { menuItemId: id2, quantity: 1, modifiers: [...], notes: "no onion" },
    ],
    addressId: savedAddressId,      // logged-in only
    // — or, raw fields (always usable):
    deliveryAddress: "12 Tahrir St, Cairo",
    deliveryLatitude: 30.0444,
    deliveryLongitude: 31.2357,
    deliveryNotes: "Ring the bell twice",
    promoCode: "WELCOME10",
    notes: "Birthday — please add a candle",
  });

The server recomputes every price from menu data + restaurant settings — never trust client-side totals. If a price changed since your last cart calculation, orders.create will use the new price. Show the user the final total from the response before they navigate away.

paymentMethod will always be "cash" in this release.

Create — guest checkout

Customers can place orders without an OTP login. Pass customerPhone (and optionally customerName) in place of the token:

const { orderId, orderNumber, total } = await plato.orders.create({
  branchId,
  orderType: "delivery",
  items: [{ menuItemId, quantity: 1 }],
  customerPhone: "01012345678",       // required for guests; E.164-normalized server-side
  customerName: "Sara A.",             // optional but recommended
  deliveryAddress: "12 Tahrir St, Cairo",
  deliveryLatitude: 30.0444,
  deliveryLongitude: 31.2357,
});

Behavior:

  • The server upserts a customers row keyed by (restaurantId, phone) — the restaurant's CRM, KDS, and reporting see the guest exactly like a returning customer.
  • Saved addresses (addressId) are not available to guests. Use raw deliveryAddress + lat/lng fields.
  • The guest receives no session token. To check or cancel the order later they must pass the same customerPhone again (see below).

Get / list / cancel — logged-in

import { usePlatoOrder, usePlatoOrders } from "@mantaray-digital/plato-sdk/react";

// Single order — status, ETA, items. Call refetch() to refresh.
const { data: order, refetch } = usePlatoOrder(orderId);
// order.status changes:
//   "pending" → "in_progress" → "waiting_for_pickup" → "out_for_delivery" → "delivered"
// (or "cancelled" if the customer or restaurant cancels)

// Order history
const { data: orders } = usePlatoOrders({ limit: 20 });
const activeOnly = usePlatoOrders({ status: "in_progress" });

Imperative: plato.orders.get(orderId), plato.orders.list({ status?, limit? }), plato.orders.cancel(orderId, { reason }).

Get / list / cancel — guest

Pass { customerPhone } as the second arg. The server verifies the phone matches the order's customerPhone (server-side E.164 normalization).

// Imperative
const order = await plato.orders.get(orderId, { customerPhone: "01012345678" });

const orders = await plato.orders.list({
  customerPhone: "01012345678",
  status: "out_for_delivery",
  limit: 5,
});

await plato.orders.cancel(orderId, {
  customerPhone: "01012345678",
  reason: "Wrong address",
});
// React
const { data: order } = usePlatoOrder(orderId, { customerPhone });
const { data: history } = usePlatoOrders({ customerPhone, limit: 20 });

Order-tracking pages: since this SDK is polling, not WebSocket-based, an order-status screen should call refetch() on a setInterval (e.g. every 15 seconds). Example:

useEffect(() => {
  const id = setInterval(refetch, 15_000);
  return () => clearInterval(id);
}, [refetch]);

Cancel

Allowed only while status === "pending" (i.e. before the kitchen fires the ticket). After firing, the customer cannot self-cancel — they must contact the restaurant.


10. Dine-in QR ordering

The Plato dashboard prints a QR code for every table. Each QR encodes a URL on your storefront in the form:

{your-storefront-url}/table/{tableId}

The base URL comes from Integrations → SDK API Keys → Storefront URL in the Plato dashboard — set it once and every printed QR code points at your site. Customers scan the code at the table, your storefront pre-binds the order to that table, and the order lands in the kitchen tagged Dine-in · {tableNumber}.

10.1 Resolve the table after the scan

When the storefront loads at /table/{tableId}, call plato.tables.resolve(tableId) first. This:

  • Validates the table belongs to your restaurant (no cross-tenant leakage).
  • Returns the human-readable table number to display to the customer.
  • Returns the branchId you must pass when creating the order.
import { plato } from "@/lib/plato";

const table = await plato.tables.resolve(tableId);
if (!table) {
  // Unknown table or wrong restaurant — show "Table not found".
  return;
}
// table.tableNumber  → "5"
// table.branchId     → use this for cart preview + order create
// table.branchName   → optional UX hint ("Ordering at Downtown branch")
// table.status       → "vacant" | "occupied" | "reserved" | "cleaning"

If table.status === "cleaning" you should block ordering and show a friendly "this table is being cleaned" message — the backend will reject the order anyway, but failing fast is better UX.

With React:

import { useState, useEffect } from "react";
import { usePlatoStore } from "@mantaray-digital/plato-sdk/react";
import type { ResolveTableResult } from "@mantaray-digital/plato-sdk";

export function useTable(tableId: string | null) {
  const plato = usePlatoStore();
  const [table, setTable] = useState<ResolveTableResult | null | undefined>(undefined);
  useEffect(() => {
    if (!tableId) return;
    plato.tables.resolve(tableId).then(setTable);
  }, [tableId, plato]);
  return table; // undefined = loading, null = not found, object = resolved
}

10.2 Place the dine-in order

Pass orderType: "dine_in" + tableId to plato.orders.create(...). No delivery address needed.

const { orderId, orderNumber, total } = await plato.orders.create({
  branchId: table.branchId,         // from resolveTable
  orderType: "dine_in",
  tableId: table.tableId,           // from resolveTable
  items: [
    { menuItemId, quantity: 2 },
    { menuItemId: id2, quantity: 1, modifiers: [...], notes: "no onion" },
  ],
  customerPhone: "01012345678",     // required (E.164-normalized server-side)
  customerName: "Sara A.",          // optional but recommended
  notes: "Allergic to nuts",
});

What happens server-side:

  1. The backend verifies the table belongs to your API key's restaurant and to the branchId you passed.
  2. If there's no active session for this table, one is opened automatically — every subsequent QR order from the same table accrues to the same session/bill until POS closes it for cleaning.
  3. The table flips to occupied (if it wasn't already) and currentOrderId is updated to your new order.
  4. The order is tagged orderSource: "qr_table", type: "dine_in", and stamped with tableNumber. The KDS station feed displays Dine-in · 5 next to the items.
  5. The customer can still call plato.orders.get(orderId, { customerPhone }) to track status, just like any other order.

10.3 Validation rules

  • orderType: "dine_in" requires tableId. Omitting it throws.
  • tableId is rejected for non-dine-in orders (delivery, pickup, takeaway) — pass it only with dine_in.
  • The table must belong to the same branchId you pass. Mixed branch/table will error with cross_tenant_access.
  • Tables in status: "cleaning" reject orders until the POS marks the cleanup complete.

10.4 Call the waiter

Guests sitting at a dine-in table can summon the waiter from the storefront — for the bill, water, a question, anything. Wire a single button:

await plato.tables.callWaiter(table.tableId);

What happens server-side:

  1. The backend verifies the table belongs to your API key's restaurant.
  2. If a pending call_waiter notification already exists for this table, the same id is returned with deduped: true and no new row is created — the waiter is already on the way.
  3. Otherwise a Pending notification of type call_waiter is inserted with message "Table {N} needs help". It's routed to the table's assigned waiter (if any) and shows up on the waiter app's bell + notifications list as a red "Customer calling" entry. Tapping it acknowledges the call and jumps the waiter to the table.
const { notificationId, deduped } = await plato.tables.callWaiter(table.tableId);
// deduped === true means an in-flight call was already pending — show a
//   "Waiter is on the way" message rather than another "Sent" toast.

No rate-limit error is thrown — the dedupe makes a "Call waiter" button safe to tap repeatedly. The badge only clears when the waiter acknowledges from the waiter app.

10.5 Multiple guests at the same table

The first scan opens the session; every subsequent order from any device that scans the same QR joins the same session and the orders are billed together. There's no separate "join" call — passing the same tableId automatically merges. Each customerPhone shows up as a distinct customer in the restaurant's CRM, but the bill is one.

If the restaurant wants a fresh session (new party of guests sitting down), the waiter clears the table via POS — which closes the previous session and the next scan starts a new one.


10A. Online card payments (pay-first)

Card payment uses a pay-first flow: the cart is priced and stashed server-side, the customer pays on a hosted MyFatoorah page, and the real order is created only once the charge clears. Abandoned payments never leave an orphan order.

// 1. Only offer card if the restaurant has a gateway configured.
const { available } = await plato.payments.availability();

// 2. Start the payment. Logged-in: token auto-injected. Guest: pass customerPhone.
const { paymentUrl, pendingOrderId } = await plato.payments.initiateOnlineOrder({
  branchId,
  orderType: "delivery",
  items,                                   // same shape as orders.create
  deliveryAddress, deliveryLatitude, deliveryLongitude,
  customerPhone: "01012345678",            // guest checkout
  language: "en",                          // hosted-page language
});

// 3. Stash the id and redirect to the hosted page.
localStorage.setItem("pendingOrderId", pendingOrderId);
window.location.href = paymentUrl;

// 4. On return, poll until the order is created (or payment failed).
const pending = await plato.payments.getPendingOrder(pendingOrderId);   // guest: (id, { customerPhone })
if (pending?.createdOrderId) {
  // Paid — forward to tracking.
  const order = await plato.payments.getOrderTracking(pending.createdOrderId);
} else if (pending?.lastFailureAt) {
  // Declined / abandoned — offer a retry (no re-pricing).
  const { paymentUrl } = await plato.payments.retryPayment(pendingOrderId);
}

getOrderTracking(orderId) returns the live order with the full breakdown and the out-for-delivery driver reveal: driverName / driverNameAr / driverPhone are null until the order is out for delivery (a server-side privacy gate), then populated so you can show "Your driver is on the way" with a call button. Poll it on an interval for a live tracking screen.

10B. Dine-in running tab & split billing

Beyond placing a single dine-in order (§10), the SDK exposes the full running tab: multiple guests at one table, a live split bill, and settlement by card or cash — mirroring the built-in /tab page.

// Scan → start (or resume) the table's session.
const { sessionId, token } = await plato.session.start(tableId);

// Identify each guest so their items + split shares are attributed.
const { guestId } = await plato.session.join(tableId, { name: "Sara", phone: "01012345678" });

// …place orders with orders.create({ orderType: "dine_in", tableId, ... }) …

// Live split bill. splitMode: "by_items" (default) or "equal".
const bill = await plato.session.guestBalances(sessionId);
//  → { guests: [{ guestId, guestName, balance, paid, remaining, paymentStatus }], totalRemaining, allPaid, ... }

// Settle a guest's share (or the whole tab) by card…
const { paymentUrl } = await plato.session.payOnline({
  sessionId, guestId,
  amount: bill.guests[0].remaining,
  customerName: "Sara",
});
window.location.href = paymentUrl;

// …or ask a waiter to collect cash.
await plato.session.requestCash({ sessionId, guestId, amount: bill.guests[0].remaining });

// "Pay whole table" concurrency lock (so two phones don't both pay the full tab).
await plato.session.lockFullTab(sessionId, guestId);
// …pay…
await plato.session.unlockFullTab(sessionId, guestId);

Other reads: session.resolveByToken(token) (from the QR/"view my bill" link), session.resolveTable(tableId) ({ status: "active" | "inactive" | "not_found" }), session.guests(sessionId), and session.balance(sessionId) (whole-tab total/paid/remaining).

10C. Pickup branches & cart reconcile

// Nearest pickup branches (multi-branch brands), sorted by distance when a
// location is given — each with distance, open status, minutes-until-open, ETA.
const branches = await plato.restaurant.pickupBranches({ latitude, longitude });
//  → [{ slug, name, distanceKm, open, opensInMinutes, etaMinutes, isCurrent, isPrimary }]
//  navigate to a branch's `slug` to switch the storefront to it.

// Re-check a browsed cart against the CURRENT menu right before checkout.
const verdicts = await plato.cart.reconcile(
  cart.map((l) => ({ id: l.lineId, menuItemId: l.menuItemId, variationName: l.variationName, unitPrice: l.unitPrice })),
  branchId,
);
//  → [{ id, status: "available" | "unavailable" | "price_changed", currentPrice? }]

11. Error handling

Every method that hits the backend throws a PlatoError on failure:

import { PlatoError } from "@mantaray-digital/plato-sdk";

try {
  await plato.orders.create({ ... });
} catch (err) {
  if (err instanceof PlatoError) {
    switch (err.code) {
      case "customer_session_missing":
      case "customer_session_expired":
      case "customer_session_revoked":
        // Redirect to login
        break;
      case "invalid_api_key":
      case "revoked_api_key":
        // Configuration bug — surface to the dev, not the user
        break;
      case "cross_tenant_access":
        // Data does not belong to this restaurant/customer
        break;
      case "not_found":
        // Missing order / address / etc.
        break;
      default:
        // Other ConvexErrors come through with code: "unknown" and the
        // raw message on err.message. Examples: "delivery not available:
        // outside_zones", "promo code: This promo code has expired".
    }
  }
}

Error codes

| Code | Meaning | |---|---| | api_key_missing | No API key passed to new PlatoStore() | | invalid_api_key | Key not found in the backend (typo or deleted) | | revoked_api_key | Key was revoked from the dashboard | | customer_session_missing | Calling a customer-scoped method with no token | | customer_session_invalid | Token doesn't match any session | | customer_session_expired | 90-day TTL elapsed | | customer_session_revoked | Session revoked (logout or re-OTP from another device) | | cross_tenant_access | Token's restaurant ≠ API key's restaurant, or accessing another customer's data | | not_found | Address / order doesn't exist | | unknown | Any other backend error; check err.message |


12. Reference

new PlatoStore(config) → PlatoStore

interface PlatoStoreConfig {
  convexUrl: string;
  apiKey: string;
  storage?: TokenStorage | "none";  // default: browser localStorage
}

PlatoStore surface

class PlatoStore {
  constructor(config: PlatoStoreConfig);

  // Token state
  getCustomerToken(): string | null;
  setCustomerToken(token: string): void;
  clearCustomerToken(): void;
  onTokenChange(cb: (token: string | null) => void): () => void;

  // Restaurant
  restaurant: {
    getBranches(): Promise<Branch[]>;
    pickupBranches(location?: { latitude: number; longitude: number }): Promise<PickupBranch[]>;
  };

  // Menu
  menu: {
    categories(): Promise<MenuCategory[]>;
    items(opts?: { branchId?: string; categoryId?: string }): Promise<MenuItem[]>;
    item(itemId: string, opts?: { branchId?: string }): Promise<MenuItem>;
    modifiers(itemId: string): Promise<ModifierGroup[]>;
  };

  // Customer
  customer: {
    requestOtp(phone: string, channel?: "sms" | "whatsapp"): Promise<{ sent: true; usedChannel: string }>;
    verifyOtp(phone: string, code: string): Promise<{ customerToken: string; customer: Customer | null }>;
    me(): Promise<Customer | null>;
    updateProfile(input: UpdateProfileInput): Promise<Customer | null>;
    logout(): Promise<void>;
    isLoggedIn(): boolean;
    getAddresses(): Promise<CustomerAddress[]>;
    addAddress(input: AddAddressInput): Promise<CustomerAddress | null>;
    updateAddress(addressId: string, input: Partial<AddAddressInput>): Promise<CustomerAddress | null>;
    deleteAddress(addressId: string): Promise<void>;
  };

  // Pricing preview + reconcile
  cart: {
    calculate(input: CartCalculateInput): Promise<CartBreakdown>;
    reconcile(items: CartReconcileLine[], branchId?: string): Promise<CartReconcileVerdict[]>;
  };

  // Online card payments (pay-first MyFatoorah). See §10A.
  payments: {
    availability(): Promise<{ available: boolean }>;
    initiateOnlineOrder(input: InitiateOnlineOrderInput): Promise<InitiateOnlineOrderResult>;
    retryPayment(pendingOrderId: string, opts?: { customerName?: string; customerMobile?: string; language?: string }): Promise<RetryPaymentResult>;
    getPendingOrder(pendingOrderId: string, guest?: { customerPhone: string }): Promise<PendingOnlineOrder | null>;
    getOrderTracking(orderId: string, guest?: { customerPhone: string }): Promise<OrderTracking | null>;
  };

  // Dine-in running tab + split billing. See §10B.
  session: {
    resolveByToken(token: string): Promise<TableSessionInfo | null>;
    resolveTable(tableId: string): Promise<TableResolveResult>;
    guests(sessionId: string): Promise<SessionGuest[]>;
    balance(sessionId: string): Promise<SessionBalance>;
    guestBalances(sessionId: string, splitMode?: SplitMode): Promise<GuestBalances>;
    start(tableId: string): Promise<StartSessionResult>;
    join(tableId: string, guest: { name: string; phone: string }): Promise<JoinTableResult>;
    requestCash(input: { sessionId: string; guestId?: string; amount: number; tipAmount?: number }): Promise<{ notificationId: string }>;
    payOnline(input: { sessionId: string; guestId?: string; amount: number; tipAmount?: number; customerName: string; customerEmail?: string; customerMobile?: string; language?: string }): Promise<{ paymentUrl: string; invoiceId: number }>;
    lockFullTab(sessionId: string, guestId: string): Promise<{ success: boolean }>;
    unlockFullTab(sessionId: string, guestId: string): Promise<{ success: boolean }>;
  };

  // Delivery
  delivery: {
    zones(): Promise<DeliveryZone[]>;
    check(latitude: number, longitude: number): Promise<DeliveryCheckResult>;
  };

  // Promo codes
  promo: {
    validate(input: { code: string; subtotal: number; orderType?; customerPhone? }): Promise<PromoValidateResult>;
  };

  // Orders — each method works for logged-in or guest callers.
  // For dine-in QR orders, pass `orderType: "dine_in"` + `tableId` to create().
  orders: {
    create(input: CreateOrderInput): Promise<CreateOrderResult>;
    get(orderId: string, guest?: { customerPhone: string }): Promise<Order>;
    list(input?: { status?: OrderStatus; limit?: number; customerPhone?: string }): Promise<OrderHeader[]>;
    cancel(orderId: string, opts?: { reason?: string; customerPhone?: string }): Promise<void>;
  };

  // Tables — dine-in QR flow. See §10.
  tables: {
    resolve(tableId: string): Promise<ResolveTableResult | null>;
    callWaiter(tableId: string): Promise<CallWaiterResult>;
  };
}

React hooks (@mantaray-digital/plato-sdk/react)

Wrap your tree in <PlatoProvider config={...}> once. All hooks then read the store from context and return { data, error, loading, refetch } unless noted.

// Provider
<PlatoProvider config={{ apiKey, convexUrl }}>...</PlatoProvider>

// Store access
usePlatoStore(): PlatoStore                      // the underlying instance

// Restaurant / menu
usePlatoBranches()
usePlatoCategories()
usePlatoItems(opts?)
usePlatoItem(itemId | null, opts?)
usePlatoModifiers(itemId | null)
usePlatoMenu(opts?)                              // { categories, items, error, loading, refetch }

// Customer
useCustomer()                                    // { customer, isLoggedIn, loading, error, requestOtp, verifyOtp, logout, updateProfile, refetch }
useCustomerAddresses()

// Cart / delivery / promo
usePlatoCart(input | null)                       // null = disabled
useDeliveryZones()
useDeliveryCheck(coords | null)
usePromoValidate(input | null)

// Orders
usePlatoOrder(orderId | null)
usePlatoOrders({ status?, limit? }?)

All hooks fetch once on mount and re-run when their deps change. There are no WebSocket subscriptions. Use refetch() (returned from every hook) when you need a fresh read, or pair it with setInterval for an order-tracking screen.

All exported types

import type {
  PlatoStore, PlatoStoreConfig, PlatoError, PlatoErrorCode,
  TokenStorage,
  Branch,
  MenuCategory, MenuItem, MenuItemVariation, ModifierGroup, ModifierOption,
  Customer, CustomerAddress, OtpChannel,
  RequestOtpResult, VerifyOtpResult, UpdateProfileInput, AddAddressInput,
  CartOrderType, CartLineInput, CartCalculateInput, CartLine, CartAppliedFee, CartBreakdown,
  DeliveryZone, DeliveryCheckResult,
  PromoValidateResult, PromoValidateInput,
  OrderStatus, OrderPaymentStatus, OrderHeader, OrderItem, Order,
  CreateOrderInput, CreateOrderResult, ListOrdersInput,
  ResolveTableResult, CallWaiterResult,
  // Online card payments
  InitiateOnlineOrderInput, InitiateOnlineOrderResult, RetryPaymentResult,
  PendingOnlineOrder, OrderTracking,
  // Dine-in tab / split billing
  SplitMode, TableSessionInfo, TableResolveResult, SessionGuest,
  SessionBalance, GuestBalance, GuestBalances, StartSessionResult, JoinTableResult,
  // Pickup picker + cart reconcile
  PickupBranch, CartReconcileLine, CartReconcileVerdict,
} from "@mantaray-digital/plato-sdk";

13. Limitations (v1)

  • Payments: cash-on-delivery and online card (pay-first MyFatoorah) are supported (§10A); online card requires the restaurant to have a validated gateway configured. No Stripe/other gateways yet.
  • Live updates: this SDK is HTTP-only; there are no WebSocket subscriptions. Order tracking + dine-in tab balances should poll via refetch() on an interval.
  • Webhooks: Plato does not currently push events (e.g. order.created, order.delivered) to a URL of your choosing. Poll on an interval instead.
  • Rate limits: per-key rate limits are not yet enforced. Don't ship the key to untrusted environments.
  • Per-key scopes: every key has full storefront privileges. Read-only / scoped keys are future work.
  • Transports: convex peer dep is required for the underlying ConvexHttpClient. There is no REST or GraphQL transport in v1.

14. Troubleshooting

| Symptom | Cause / fix | |---|---| | PlatoError { code: "invalid_api_key" } on every call | Wrong key, or key was deleted. Regenerate from the Plato dashboard. | | PlatoError { code: "revoked_api_key" } | The restaurant revoked this key from the dashboard. Generate a new one. | | requestOtp works once then fails | Rate limit (3 sends per 10 min per phone). Wait, or use a different number for testing. | | verifyOtp succeeds but useCustomer shows isLoggedIn: false | The hook reads from localStorage. If you're using a custom storage adapter, make sure its get() is synchronous and returns the token immediately after set(). | | usePlatoStore must be used inside <PlatoProvider> | A hook was called outside the provider tree. Move the component under <PlatoProvider> or call the imperative API directly. | | Order status doesn't change automatically | The SDK is polling, not subscription-based. Call refetch() on a setInterval, or rely on user-triggered refresh. | | delivery not available: outside_zones | The customer's address is outside every delivery zone the restaurant has defined. Either ask the customer for a different address or extend the zones in the Plato dashboard. | | promo code: This promo code has expired | The promo's validUntil has passed. Use a different code. | | Order create says cannot cancel an order in status in_progress | Once the kitchen fires the ticket, the customer can't self-cancel. They have to contact the restaurant. | | plato.tables.resolve(tableId) returns null | Either the table doesn't exist or it belongs to a different restaurant than your API key. Verify the QR was printed from the matching Plato dashboard. | | dine_in orders require tableId | You called orders.create({ orderType: "dine_in" }) without a tableId. Resolve the table from the QR URL first. | | tableId is only valid for dine_in orders | You passed tableId with orderType: "delivery" (or other non-dine-in). Drop tableId from non-dine-in calls. | | table is being cleaned | The waiter has closed this table for cleaning. New orders are blocked until POS marks it vacant. |


15. Versioning

This SDK uses semver. 0.x.y versions are pre-1.0 and may include breaking changes. The Plato backend will keep older SDK versions working at least one minor cycle past their release.


16. Reporting issues

When something doesn't work, the Plato team will need:

  • The SDK version (pnpm list @mantaray-digital/plato-sdk)
  • The Convex URL you're hitting (not the API key)
  • The PlatoError.code and .message
  • A minimal reproduction (the input args to the failing method)