Zensile is a gym management platform: member check-ins, company/staff management, role-based access control, scheduling, POS/checkout, payroll, and third-party integrations (Stripe, Mindbody, KISI). It ships as a Next.js 15 app (this codebase, zensile-app) backed by a MySQL/REST API, plus an Expo mobile/ app — both consuming a shared, UI-free TypeScript domain layer, zensile-sdk.
This is reference documentation for the software itself — architecture, UI conventions, and the full domain-layer API — written for anyone extending or integrating with Zensile. It's split into two parts:
| Part | Pages | Covers |
|---|---|---|
| Application | 3 pages | How the Next.js app is put together: routing, the component library, and the display-panel/CRUD conventions used throughout. |
| SDK Reference | 11 pages | Every domain class in zensile-sdk — fields, methods, endpoints — organized by business area (members, scheduling, payroll, …). |
Use the sidebar to jump to a topic, or the prev/next links at the bottom of each page to read in order.
┌─────────────────────────┐
│ zensile-app (Next.js) │
│ mobile/ (Expo) │
│ — UI only, no business │
│ logic │
└───────────┬───────────────┘
│ imports zensile-sdk/*
┌───────────▼───────────────┐
│ zensile-sdk │
│ │
│ Domain classes │
│ (Member, Checkout, Class…) │──── extend DataStructure<T>
│ │ │
│ ▼ │
│ APIRequest<T>() (helper.ts) │──── auth, retry, timeout,
│ │ │ MySQL DATETIME rewrite
│ ▼ │
│ fetch() → REST API │
└───────────┬───────────────────┘
│ HTTP (cookie session)
┌───────────▼───────────────┐
│ MySQL/REST backend (AWS) │
└─────────────────────────────┘The UI layer (zensile-app, mobile/) holds no business logic — it renders components and calls methods on domain classes. All of that logic lives in zensile-sdk, checked into this repo at zensile-sdk/ and aliased as the zensile-sdk/* import path. See the SDK reference pages for the domain layer, or the Using Zensile guides for how the app looks and works day to day.
The abstract base class every domain entity (Member, Checkout, Class, …) extends. It provides:
cache: Map<string, T> keyed by iddata(getFullIfLazy?) returns cached lazy data unless a full fetch is explicitly requestedLazyData — synchronous getter for whatever's currently cached (may be undefined, may be partial)reload() — discards cache and refetches full data; invalidate() — clears cache without refetchingsubscribe(fn) fires on every setData() call, and auto-triggers the initial fetch — this is what the app's useData/useMany React hooks build on via useSyncExternalStoreSubclasses must implement cache, fetch(lazy?), save(), and delete(). create() and query() are conventional statics, not enforced by the base class.
setData({ ...this._data, field: newValue }) so subscribers see a changed reference:// Wrong — no notification, useSyncExternalStore sees the same reference
this._data.quantity += amount;
// Correct — new reference, subscribers notified
this.setData({ ...this._data, quantity: newQty });The single choke point for all HTTP calls, in helper.ts:
APIRequest<T>(
endpoint: string,
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
body?: any[] | any,
needUser = true,
): Promise<SimpleResult<T> | EventData<T>>ss-user-id cookie; if needUser and it's missing, short-circuits with 401 before any network call408 instead of hangingDate values in the request body into MySQL's DATETIME format before sendingThe standard response envelope every call resolves to — always check status before trusting data:
interface SimpleResult<T> {
message?: string;
status: number; // HTTP status code
data?: T;
total?: number; // present on list/query responses
}EventData<T> extends this with type, a required message, and timestamp — the audit-trail fields the backend attaches to every write.
The shape accepted by every class's static query():
interface QueryRequest<T extends string = string> {
filter?: Array<{ field: T; condition: FilterCondition; value: string | number | string[] | Date }>;
logic?: "and" | "or";
sort?: { field: T; order: "asc" | "desc" };
limit?: number;
offset?: number;
lazy?: boolean;
includeDeleted?: boolean;
}FilterCondition = "=" | "!=" | "<" | ">" | ">=" | "<=" | "like" | "not like" | "in". Use "in" with a string[] to match a set of values.
const result = await Member.query(company, {
filter: [{ field: "userEmail", condition: "like", value: "%@gym.com" }],
sort: { field: "lastName", order: "asc" },
limit: 25,
});
if (result.status === 200) {
console.log(result.data, result.total);
}Shared by all three check-in endpoints (CreateVisits, Booking.CheckIn, Appointment.CheckIn):
interface CheckInResponse {
message: string;
statusCode?: 200 | 202;
alert?: string;
alerts?: string[];
}status === 202 means the check-in succeeded but the member has an alert (outstanding balance, expired waiver, …) that must be surfaced to the operator — never treat 202 as failure.Auth is purely cookie-based (ss-user-id, ss-company-id, ss-token-expire) — there's no separate auth provider. See Auth & Session for the full session lifecycle.
Permissions are bitwise flags (READ=1, EDIT=2, CREATE=4, REMOVE=8, ALL=15) scoped across 16 modules (Members, Visits, Scheduling, Checkouts, …). See Management for Role/Permission and the module list.
company.GetListOfUsers(), member.reload()) rather than importing standalone functions directly._data in place — go through setData({ ...this._data, ... }).line.product) must cache the constructed instance in a private field so the same object reference is returned every call — required for subscriptions to work.query() filtered by the parent's id (e.g. coupon.promotionalCodes(opts), staff.rates(opts)).202 for a successful check-in with an alert — never treat it as an error.