first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:48:14 +05:30
commit a1b870fa8a
242 changed files with 39483 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
import { apiRequest } from "@/lib/api/core-http"
import type { ClientAccessDevices } from "@/lib/models/access-device"
function getAccessDevices() {
return apiRequest<ClientAccessDevices>("client/access-devices")
}
export interface FacePhotoResult {
accessProfileId: number
facePhotoStatus: "None" | "Pending" | "Synced" | "Failed"
queuedToDevices: number
width: number
height: number
sizeBytes: number
}
/**
* Queues a face photo for enrolment into the gym's access terminals. Send a bare base64
* JPEG produced by normalizeFacePhoto — the image is never stored server-side, it only
* rides the device-command queue until a terminal confirms.
*/
function setCustomerFacePhoto(customerId: string, imageBase64: string) {
return apiRequest<FacePhotoResult>(`client/customers/${customerId}/face-photo`, {
method: "POST",
body: { imageBase64 },
})
}
export { getAccessDevices, setCustomerFacePhoto }
+23
View File
@@ -0,0 +1,23 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
AttendanceCount,
AttendanceRecord,
TodayAttendance,
} from "@/lib/models/attendance"
function getAllAttendance() {
return apiRequest<AttendanceRecord[]>("client/attendance")
}
function getTodayAttendance() {
return apiRequest<TodayAttendance[]>("client/attendance/today")
}
/** Dates as `yyyy-MM-dd`; both optional (defaults to current month on the server). */
function getAttendanceCounts(fromDate?: string, toDate?: string) {
return apiRequest<AttendanceCount[]>("client/attendance/counts", {
query: { fromDate, toDate },
})
}
export { getAllAttendance, getTodayAttendance, getAttendanceCounts }
+90
View File
@@ -0,0 +1,90 @@
import { authRequest, findField } from "@/lib/api/core-http"
import { setToken } from "@/lib/auth/token-service"
import type { LoginRequest, LoginResult } from "@/lib/models/auth"
async function login(request: LoginRequest): Promise<LoginResult> {
const data = await authRequest<unknown>("loginUser", {
method: "POST",
body: {
functionName: "loginUser",
payload: {
identifier: request.identifier,
password: request.password,
deviceName: request.deviceName ?? "WebApp",
},
reference: `LOGIN${Date.now()}`,
},
})
const accessToken = findField(data, ["accessToken", "AccessToken", "token"])
const refreshToken = findField(data, ["refreshToken", "RefreshToken"])
// Persisted here (not left to the caller) so every other api-* module can rely on a
// token already being stored the moment login() resolves successfully.
if (accessToken) setToken(accessToken)
return { accessToken, refreshToken }
}
/** Step 1 of forgot-password: sends an OTP to the account's email/phone. */
async function forgotPassword(identifier: string): Promise<{ referenceNumber: string }> {
const data = await authRequest<unknown>("forgotPassword", {
method: "POST",
body: {
functionName: "forgotPassword",
payload: { identifier, useResetLink: false, numberOfDigits: 6 },
reference: `FORGOT${Date.now()}`,
},
})
const referenceNumber = findField(data, ["referenceNumber", "ReferenceNumber"])
if (!referenceNumber) throw new Error("Couldn't start the password reset. Try again.")
return { referenceNumber }
}
/** Step 2: verifies the OTP so the account is allowed to reset its password. Goes through
* the generic recovery RPC endpoint (auth-service dispatches by functionName), same as
* resetPassword/resetPasswordWithToken below. */
async function verifyResetOtp(referenceNumber: string, otpCode: string): Promise<void> {
await authRequest<unknown>("recovery", {
method: "POST",
body: {
functionName: "VerifyOTP",
payload: { referenceNumber, otpCode },
reference: `VERIFY${Date.now()}`,
},
})
}
/** Step 3: sets the new password (requires the OTP to have been verified). */
async function resetPassword(
referenceNumber: string,
newPassword: string,
confirmPassword: string
): Promise<void> {
await authRequest<unknown>("recovery", {
method: "POST",
body: {
functionName: "resetPassword",
payload: { referenceNumber, newPassword, confirmPassword },
reference: `RESET${Date.now()}`,
},
})
}
/** Sets a new password from the emailed reset/welcome link (?token=...), no OTP involved. */
async function resetPasswordWithToken(
resetToken: string,
newPassword: string,
confirmPassword: string
): Promise<void> {
await authRequest<unknown>("recovery", {
method: "POST",
body: {
functionName: "resetPasswordWithToken",
payload: { resetToken, newPassword, confirmPassword },
reference: `RESETLINK${Date.now()}`,
},
})
}
export { login, forgotPassword, verifyResetOtp, resetPassword, resetPasswordWithToken }
+34
View File
@@ -0,0 +1,34 @@
import { apiRequest } from "@/lib/api/core-http"
import type { Booking, CreateGuestBookingInput } from "@/lib/models/booking"
function getSessionBookings(facilitySessionId: string) {
return apiRequest<Booking[]>(`client/bookings/session/${facilitySessionId}`)
}
/** Staff-initiated walk-in booking — same public endpoint the guest booking flow uses. */
function createGuestBooking(input: CreateGuestBookingInput) {
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
}
function checkInBooking(otpCode: string) {
return apiRequest<Booking>("client/bookings/check-in", { method: "POST", body: { otpCode } })
}
function markGuestBookingPaid(id: string, amountCharged: number) {
return apiRequest<Booking>(`client/bookings/${id}/mark-paid`, {
method: "POST",
body: { bookingId: id, amountCharged },
})
}
function cancelBooking(id: string) {
return apiRequest<Booking>(`client/bookings/${id}/cancel`, { method: "POST" })
}
export {
getSessionBookings,
createGuestBooking,
checkInBooking,
markGuestBookingPaid,
cancelBooking,
}
+191
View File
@@ -0,0 +1,191 @@
import { clearToken, getToken } from "@/lib/auth/token-service"
import type { ApiResponse } from "@/lib/models/api"
// Trimmed so a trailing slash on the env var (e.g. "http://localhost:5601/") can't
// produce a double-slash in any URL built below -- some routing layers 404 on that.
const CORE_API_URL = (process.env.NEXT_PUBLIC_CORE_API_URL ?? "http://localhost:5601").replace(/\/+$/, "")
/** auth-service is a separate process from core-api in this deployment (see AuthHex),
* not a path on the same API -- login/logout/forgot-password all go here instead. */
const AUTH_API_URL = (process.env.NEXT_PUBLIC_AUTH_API_URL ?? "http://localhost:5602").replace(/\/+$/, "")
export class ApiError extends Error {
constructor(
message: string,
public status: number
) {
super(message)
this.name = "ApiError"
}
}
interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
body?: unknown
formData?: FormData
query?: Record<string, string | number | boolean | undefined>
/** Public requests (guest booking, the swipe terminal) report 401/errors without
* clearing the session or redirecting -- there's no session to clear for them. */
authMode?: "session" | "public"
}
function redirectToLogin() {
if (typeof window === "undefined") return
const path = window.location.pathname + window.location.search
window.location.href = `/login?next=${encodeURIComponent(path)}`
}
/**
* core-api calls. `path` is relative to `/api/`, e.g. "client/facilities",
* "public/bookings/guest". Auth is a `MobileAuthToken: Bearer <token>` header (core-api's
* own convention -- see CustomMiddleware on the backend), not a cookie: there is no
* server-side session to refresh, so a 401 just clears the stored token and sends the
* user back to /login rather than attempting a silent refresh.
*/
async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, formData, query, authMode = "session" } = options
const url = new URL(`${CORE_API_URL}/api/${path}`)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) url.searchParams.set(key, String(value))
}
}
const headers: Record<string, string> = {}
if (body !== undefined) headers["Content-Type"] = "application/json"
const token = getToken()
if (token) headers["MobileAuthToken"] = `Bearer ${token}`
let res: Response
try {
res = await fetch(url, {
method,
headers,
body: formData ?? (body !== undefined ? JSON.stringify(body) : undefined),
})
} catch {
throw new ApiError(
"Couldn't reach the server. Check that the API is running and try again.",
0
)
}
const text = await res.text()
if (!res.ok) {
if (res.status === 401 && authMode === "session") {
clearToken()
redirectToLogin()
}
throw new ApiError(extractErrorMessage(text, res.status), res.status)
}
if (!text) return undefined as T
try {
const parsed = JSON.parse(text) as ApiResponse<T>
return parsed.data
} catch {
// A 2xx response with a body that isn't the expected JSON envelope (e.g. an upstream
// proxy/error page slipping through with a success status).
throw new ApiError("The server returned an unexpected response. Please try again.", res.status)
}
}
/** auth-service calls (login/logout/forgot-password) -- separate base URL, no stored
* token to attach (these calls establish or end the session, they don't use one). */
async function authRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "POST", body } = options
const url = new URL(`${AUTH_API_URL}/api/${path}`)
let res: Response
try {
res = await fetch(url, {
method,
headers: body !== undefined ? { "Content-Type": "application/json" } : {},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
} catch {
throw new ApiError(
"Couldn't reach the login server. Check that it's running and try again.",
0
)
}
const text = await res.text()
if (!res.ok) throw new ApiError(extractErrorMessage(text, res.status), res.status)
if (!text) return undefined as T
try {
const parsed = JSON.parse(text) as ApiResponse<T> & { success?: boolean }
if (parsed.success === false) throw new ApiError(parsed.message ?? "Request failed.", res.status)
return parsed.data
} catch (e) {
if (e instanceof ApiError) throw e
throw new ApiError("The login server returned an unexpected response. Please try again.", res.status)
}
}
/**
* Extracts a human message from a failed response body — tries the JSON envelope's
* `message` first, then falls back to the first line of whatever came back. The backend
* has a global exception handler, but this stays defensive in case a proxy/dev-exception
* page slips through instead of the JSON envelope.
*/
function extractErrorMessage(text: string, status: number): string {
let message = `Request failed (${status}).`
try {
const parsed = JSON.parse(text) as ApiResponse<unknown>
if (parsed?.message) message = parsed.message
} catch {
const firstLine = text.split("\n").find((line) => line.trim().length > 0)?.trim()
const dotnetExceptionMatch = firstLine?.match(/^[\w.]+Exception:\s*(.+)$/)
const cleaned = dotnetExceptionMatch?.[1] ?? firstLine
if (cleaned && cleaned.length < 200) message = cleaned
}
return message
}
/**
* Downloads a binary response (e.g. a generated PDF) as a browser file. Endpoints like
* this don't return the `{statusCode, message, data}` JSON envelope on success, so this
* can't go through `apiRequest` — but it mirrors the same error-message extraction on
* failure, so the real backend error survives instead of a hardcoded generic string.
*/
async function downloadFile(path: string, filename: string): Promise<void> {
const url = new URL(`${CORE_API_URL}/api/${path}`)
const token = getToken()
let res: Response
try {
res = await fetch(url, { headers: token ? { MobileAuthToken: `Bearer ${token}` } : {} })
} catch {
throw new ApiError("Couldn't reach the server. Check that the API is running and try again.", 0)
}
if (!res.ok) {
throw new ApiError(extractErrorMessage(await res.text(), res.status), res.status)
}
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(objectUrl)
}
/** Digs through a (possibly multiply-wrapped) object for the first matching key. */
function findField(obj: unknown, keys: string[], depth = 3): string | null {
if (!obj || typeof obj !== "object" || depth < 0) return null
const record = obj as Record<string, unknown>
for (const key of keys) {
if (typeof record[key] === "string") return record[key] as string
}
if (typeof record.data === "object") return findField(record.data, keys, depth - 1)
return null
}
export { apiRequest, authRequest, downloadFile, findField }
+133
View File
@@ -0,0 +1,133 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
MedicalDetails,
MemberDetail,
MemberFullDetail,
MemberInput,
MemberSubscription,
UnlinkedFingerprint,
} from "@/lib/models/customer"
function getMembers() {
return apiRequest<MemberSubscription[]>("client/customers")
}
function getMember(customerId: string) {
return apiRequest<MemberDetail>(`client/customers/${customerId}`)
}
/** Aggregated profile for the "More Info" view (targets, progress, followed plans). */
function getMemberFull(customerId: string) {
return apiRequest<MemberFullDetail>(`client/customers/${customerId}/full`)
}
/** Unlocks a member's medical details by re-verifying the owner's password. */
function unlockMedical(customerId: string, password: string) {
return apiRequest<MedicalDetails>(`client/customers/${customerId}/medical`, {
method: "POST",
body: { password },
})
}
/** Response carries the new subscription id when a plan was assigned (for the group flow). */
interface CreateMemberResult {
customer: { cusId: string }
subscriptionId?: string | null
invoiceId?: string | null
paymentLinkSent?: boolean
paymentLinkError?: string | null
/** True when the phone/email already belongs to an existing platform customer and nothing
* was written yet -- resubmit with confirmLink: true (after staff confirms) to actually link. */
requiresConfirmation?: boolean
message?: string
existingCustomer?: {
cusId: string
cusName: string | null
primaryPH: string | null
email: string | null
}
}
function createMember(input: MemberInput) {
return apiRequest<CreateMemberResult>("client/customers", { method: "POST", body: input })
}
function updateMember(customerId: string, input: Omit<MemberInput, "customerPlanId" | "workoutPlanIds" | "mealPlanIds">) {
return apiRequest<unknown>(`client/customers/${customerId}`, { method: "PUT", body: input })
}
function deleteMember(customerId: string) {
return apiRequest<unknown>(`client/customers/${customerId}`, { method: "DELETE" })
}
/** "Parent" path: starts a brand new subscription for a member who has no plan yet. */
interface AssignPlanResult {
subscriptionId?: string | null
invoiceId?: string | null
}
function assignCustomerPlan(customerId: string, params: {
customerPlanId: string
expiryDate?: string | null
chargePayment?: boolean
discountType?: "Percentage" | "Flat"
discountValue?: number
}) {
return apiRequest<AssignPlanResult>(`client/customers/${customerId}/subscription`, {
method: "POST",
body: params,
})
}
interface ChangeMembershipPlanResult {
subscriptionId?: string | null
invoiceId?: string | null
invoiceAmount?: number | null
/** "Pending" (owes the difference), "Paid" (settled exactly), or "Credit" (overpaid --
* negative invoiceAmount, applied automatically to their next bill). */
invoiceStatus?: string | null
}
/** Switches a member onto a different Individual-scope plan. */
function changeMembershipPlan(customerId: string, customerPlanId: string) {
return apiRequest<ChangeMembershipPlanResult>(`client/customers/${customerId}/subscription/plan`, {
method: "PUT",
body: { customerPlanId },
})
}
/** Drops the member's current plan back to unassigned (no new plan is assigned). */
function unassignMembership(customerId: string) {
return apiRequest<{ success: boolean }>(`client/customers/${customerId}/subscription`, {
method: "DELETE",
})
}
/** Fingerprints enrolled directly on the device that aren't linked to a Customer yet. */
function getUnlinkedFingerprints() {
return apiRequest<UnlinkedFingerprint[]>("client/customers/unlinked-fingerprints")
}
function linkFingerprint(customerId: string, unlinkedEnrollmentId: string) {
return apiRequest<{ accessProfileId: number; accessNumber: string; name: string | null }>(
`client/customers/${customerId}/link-fingerprint`,
{ method: "POST", body: { unlinkedEnrollmentId } }
)
}
export type { CreateMemberResult, ChangeMembershipPlanResult }
export {
getMembers,
getMember,
getMemberFull,
unlockMedical,
createMember,
updateMember,
deleteMember,
assignCustomerPlan,
changeMembershipPlan,
unassignMembership,
getUnlinkedFingerprints,
linkFingerprint,
}
+48
View File
@@ -0,0 +1,48 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
Facility,
FacilityInput,
FacilitySession,
FacilitySessionInput,
} from "@/lib/models/facility"
function getFacilities(includeInactive = true) {
return apiRequest<Facility[]>("client/facilities", { query: { includeInactive } })
}
function createFacility(input: FacilityInput) {
return apiRequest<Facility>("client/facilities", { method: "POST", body: input })
}
function updateFacility(id: string, input: Partial<FacilityInput>) {
return apiRequest<Facility>(`client/facilities/${id}`, { method: "PUT", body: input })
}
function setFacilityStatus(id: string, isActive: boolean) {
return apiRequest<Facility>(`client/facilities/${id}/status`, {
method: "PATCH",
body: { isActive },
})
}
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string } = {}) {
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
}
function createFacilitySession(input: FacilitySessionInput) {
return apiRequest<FacilitySession>("client/facilities/sessions", { method: "POST", body: input })
}
function cancelFacilitySession(id: string) {
return apiRequest<unknown>(`client/facilities/sessions/${id}`, { method: "DELETE" })
}
export {
getFacilities,
createFacility,
updateFacility,
setFacilityStatus,
getFacilitySessions,
createFacilitySession,
cancelFacilitySession,
}
+48
View File
@@ -0,0 +1,48 @@
import { apiRequest, ApiError } from "@/lib/api/core-http"
import type { CreateFeedInput, FeedListResult, FeedPost } from "@/lib/models/feed"
async function getFeed(): Promise<FeedPost[]> {
// ShowFeedByClient double-wraps: ApiResponse.data = { success, message, data: [...] }.
// "No feeds found" also reports success:false here, but that's a normal empty state
// (not a failure), so this intentionally does not throw on it — just falls back to [].
const result = await apiRequest<FeedListResult>("client/feed")
return result?.data ?? []
}
async function createFeedPost(input: CreateFeedInput) {
// CreateFeed reports real failures (e.g. a rejected document upload) as a 200 response
// with success:false instead of throwing — translate that into a real ApiError here so
// callers get the same throw-on-failure contract as every other endpoint.
const result = await apiRequest<{ success: boolean; message: string }>("client/feed", {
method: "POST",
body: input,
})
if (result?.success === false) throw new ApiError(result.message || "Couldn't publish the post.", 200)
return result
}
function deleteFeedPost(postId: string) {
return apiRequest<unknown>(`client/feed/${postId}`, { method: "DELETE" })
}
async function likePost(postId: string) {
const result = await apiRequest<{ success: boolean; message?: string }>(`client/feed/${postId}/like`, {
method: "POST",
})
if (result?.success === false) throw new ApiError(result.message || "Couldn't like the post.", 200)
return result
}
async function unlikePost(postId: string) {
const result = await apiRequest<{ success: boolean; message?: string }>(`client/feed/${postId}/like`, {
method: "DELETE",
})
if (result?.success === false) throw new ApiError(result.message || "Couldn't unlike the post.", 200)
return result
}
function isPostLiked(postId: string) {
return apiRequest<{ isLiked: boolean }>(`client/feed/${postId}/like`)
}
export { getFeed, createFeedPost, deleteFeedPost, likePost, unlikePost, isPostLiked }
+39
View File
@@ -0,0 +1,39 @@
import { apiRequest } from "@/lib/api/core-http"
import type { GymPlan, GymPlanInput } from "@/lib/models/gym-plan"
function getGymPlans(includeInactive = true) {
return apiRequest<GymPlan[]>("client/plans", { query: { includeInactive } })
}
function getGymPlan(id: string) {
return apiRequest<GymPlan>(`client/plans/${id}`)
}
function createGymPlan(input: GymPlanInput) {
return apiRequest<GymPlan>("client/plans", { method: "POST", body: input })
}
function updateGymPlan(id: string, input: GymPlanInput & { isActive?: boolean }) {
return apiRequest<GymPlan>(`client/plans/${id}`, { method: "PUT", body: input })
}
/** Activate / deactivate a plan without resending its configuration. */
function setGymPlanStatus(id: string, isActive: boolean) {
return apiRequest<GymPlan>(`client/plans/${id}/status`, {
method: "PATCH",
body: { isActive },
})
}
function deleteGymPlan(id: string, hard = false) {
return apiRequest<unknown>(`client/plans/${id}`, { method: "DELETE", query: { hard } })
}
export {
getGymPlans,
getGymPlan,
createGymPlan,
updateGymPlan,
setGymPlanStatus,
deleteGymPlan,
}
+43
View File
@@ -0,0 +1,43 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
CreateGymRoleInput,
GymRole,
SetRolePermissionsInput,
UpdateGymRoleInput,
} from "@/lib/models/gym-role"
function getGymRoles() {
return apiRequest<GymRole[]>("client/roles")
}
function createGymRole(input: CreateGymRoleInput) {
return apiRequest<GymRole>("client/roles", { method: "POST", body: input })
}
function updateGymRole(gymRoleId: string, input: UpdateGymRoleInput) {
return apiRequest<GymRole>(`client/roles/${gymRoleId}`, { method: "PUT", body: input })
}
function deleteGymRole(gymRoleId: string) {
return apiRequest<unknown>(`client/roles/${gymRoleId}`, { method: "DELETE" })
}
function setRolePermissions(gymRoleId: string, input: SetRolePermissionsInput) {
return apiRequest<GymRole>(`client/roles/${gymRoleId}/permissions`, { method: "PUT", body: input })
}
function assignStaffRole(staffId: string, gymRoleId: string | null) {
return apiRequest<unknown>(`client/staff/${staffId}/role`, {
method: "PUT",
body: { gymRoleId },
})
}
export {
getGymRoles,
createGymRole,
updateGymRole,
deleteGymRole,
setRolePermissions,
assignStaffRole,
}
+89
View File
@@ -0,0 +1,89 @@
import { apiRequest, downloadFile } from "@/lib/api/core-http"
import type { MemberInvoice } from "@/lib/models/invoice"
// Trimmed so a trailing slash on the env var can't produce a double-slash below.
const CORE_API_URL = (process.env.NEXT_PUBLIC_CORE_API_URL ?? "https://hexafit.hexdive.com").replace(/\/+$/, "")
/** scope="latest" returns one row per customer (their latest approved invoice) instead of
* every invoice they've ever had -- used by the regular invoices tab. */
function getMemberInvoices(scope?: "latest") {
return apiRequest<MemberInvoice[]>("client/member-invoices", { query: { scope } })
}
function getMemberInvoice(id: string) {
return apiRequest<MemberInvoice>(`client/member-invoices/${id}`)
}
/** Full invoice history for one customer (previous cycles) -- "view previous invoices". */
function getCustomerInvoiceHistory(customerId: string) {
return apiRequest<MemberInvoice[]>(`client/member-invoices/customer/${customerId}`)
}
/** The unapproved (IsActive=false) invoices the daily renewal sweep created, awaiting review. */
function getUnapprovedInvoices() {
return apiRequest<MemberInvoice[]>("client/renewal-approvals")
}
function approveRenewal(invoiceId: string) {
return apiRequest<{ success: boolean; message: string; renewalDate?: string }>(
`client/renewal-approvals/${invoiceId}/approve`,
{ method: "POST" }
)
}
function changeRenewalPlan(invoiceId: string, newCustomerPlanId: string) {
return apiRequest<{ success: boolean; message: string; subscriptionId: string }>(
`client/renewal-approvals/${invoiceId}/change-plan`,
{ method: "POST", body: { newCustomerPlanId } }
)
}
function rejectRenewal(invoiceId: string) {
return apiRequest<{ success: boolean; message: string }>(
`client/renewal-approvals/${invoiceId}/reject`,
{ method: "POST" }
)
}
/** Downloads the branded receipt PDF for an invoice via the browser. */
function downloadReceipt(id: string, receiptNo?: string) {
return downloadFile(`client/member-invoices/${id}/receipt.pdf`, `${receiptNo ?? `receipt-${id}`}.pdf`)
}
/** Re-sends the receipt to the member via email + SMS. */
function sendReceipt(id: string) {
return apiRequest<{ success: boolean; message: string }>(
`client/member-invoices/${id}/send-receipt`,
{ method: "POST" }
)
}
/** "Charge now" — attempt to auto-deduct from the member's consented saved card. */
function attemptAutoCharge(id: string) {
return apiRequest<{ success: boolean; status: string; message: string }>(
`client/member-invoices/${id}/auto-charge`,
{ method: "POST" }
)
}
/** Emails/texts the member a link to pay this invoice online themselves. */
function sendPaymentLink(id: string) {
return apiRequest<{ success: boolean; message: string }>(
`client/member-invoices/${id}/payment-link`,
{ method: "POST" }
)
}
export {
getMemberInvoices,
getMemberInvoice,
getCustomerInvoiceHistory,
downloadReceipt,
sendReceipt,
attemptAutoCharge,
sendPaymentLink,
getUnapprovedInvoices,
approveRenewal,
changeRenewalPlan,
rejectRenewal,
}
+28
View File
@@ -0,0 +1,28 @@
import { apiRequest } from "@/lib/api/core-http"
import type { AddLedgerEntryInput, LedgerEntry, ProfitLoss } from "@/lib/models/ledger"
function toDateParam(d: Date) {
return d.toISOString().slice(0, 10)
}
function getProfitLoss(from: Date, to: Date) {
return apiRequest<ProfitLoss>("client/reports/profit-loss", {
query: { from: toDateParam(from), to: toDateParam(to) },
})
}
function getLedgerEntries(from: Date, to: Date) {
return apiRequest<LedgerEntry[]>("client/ledger-entries", {
query: { from: toDateParam(from), to: toDateParam(to) },
})
}
function addLedgerEntry(input: AddLedgerEntryInput) {
return apiRequest<LedgerEntry>("client/ledger-entries", { method: "POST", body: input })
}
function deleteLedgerEntry(id: string) {
return apiRequest<{ deleted: boolean }>(`client/ledger-entries/${id}`, { method: "DELETE" })
}
export { getProfitLoss, getLedgerEntries, addLedgerEntry, deleteLedgerEntry }
+24
View File
@@ -0,0 +1,24 @@
import { apiRequest } from "@/lib/api/core-http"
import type { LockPolicy } from "@/lib/models/lock-policy"
import type { AccessCommandStatus } from "@/lib/models/customer"
function getLockPolicy() {
return apiRequest<LockPolicy>("client/settings/lock-policy")
}
function updateLockPolicy(lockAfterDaysOverdue: number | null) {
return apiRequest<LockPolicy>("client/settings/lock-policy", {
method: "PUT",
body: { lockAfterDaysOverdue },
})
}
function lockMember(customerId: string) {
return apiRequest<AccessCommandStatus>(`client/customers/${customerId}/lock`, { method: "POST" })
}
function unlockMember(customerId: string) {
return apiRequest<AccessCommandStatus>(`client/customers/${customerId}/unlock`, { method: "POST" })
}
export { getLockPolicy, updateLockPolicy, lockMember, unlockMember }
+155
View File
@@ -0,0 +1,155 @@
import { apiRequest } from "@/lib/api/core-http"
import type { CreatePlanInput, PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
interface MealPlanDto {
mealPlanId: string
name: string
description?: string | null
isTemplate: boolean
mealCategoryId: string
mealCategoryName?: string | null
clientId?: string | null
clientName?: string | null
isActive: boolean
createDate: string
assignedCustomers?: Array<{
customerMealId: string
customerId: string
customerName?: string | null
isActive: boolean
assignedDate: string
}>
}
interface MealCategoryDto {
mealCategoryId: string
// The backend category DTO exposes the label as `name` (not `mealCategoryName`).
name: string
description?: string | null
}
function normalizePlan(dto: MealPlanDto): TrainingPlan {
return {
planId: dto.mealPlanId,
name: dto.name,
description: dto.description,
isTemplate: dto.isTemplate,
categoryId: dto.mealCategoryId,
categoryName: dto.mealCategoryName,
clientId: dto.clientId,
clientName: dto.clientName,
isActive: dto.isActive,
createDate: dto.createDate,
assignedCustomers: (dto.assignedCustomers ?? []).map((c) => ({
assignmentId: c.customerMealId,
customerId: c.customerId,
customerName: c.customerName,
isActive: c.isActive,
assignedDate: c.assignedDate,
})),
}
}
async function getMealPlans(): Promise<TrainingPlan[]> {
const dtos = await apiRequest<MealPlanDto[]>("client/meal-plans")
return (dtos ?? []).map(normalizePlan)
}
async function getMealCategories(): Promise<PlanCategory[]> {
const dtos = await apiRequest<MealCategoryDto[]>("client/meal-plans/categories")
return (dtos ?? []).map((c) => ({
categoryId: c.mealCategoryId,
categoryName: c.name,
description: c.description,
}))
}
function createMealCategory(name: string, description?: string) {
return apiRequest<unknown>("client/meal-plans/categories", {
method: "POST",
body: { name, description },
})
}
function createMealPlan(input: CreatePlanInput & { mealItems?: unknown[] }) {
return apiRequest<unknown>("client/meal-plans", {
method: "POST",
body: {
name: input.name,
description: input.description,
mealCategoryId: input.categoryId,
isTemplate: input.isTemplate,
customerIds: input.customerIds,
mealItems: input.mealItems,
},
})
}
function updateMealPlan(
id: string,
input: Partial<CreatePlanInput> & { mealItems?: unknown[] }
) {
return apiRequest<unknown>(`client/meal-plans/${id}`, {
method: "PUT",
body: {
name: input.name,
description: input.description,
mealCategoryId: input.categoryId,
isTemplate: input.isTemplate,
mealItems: input.mealItems,
},
})
}
interface MealItemDto {
mealId: string
name: string
description?: string | null
}
async function getMealItems(planId: string): Promise<PlanItemView[]> {
const dtos = await apiRequest<MealItemDto[]>(`client/meal-plans/${planId}/items`)
return (dtos ?? []).map((i) => ({
id: i.mealId,
title: i.name,
detail: i.description ?? "",
exercises: [],
}))
}
function createMealItem(planId: string, title: string, detail: string) {
return apiRequest<unknown>(`client/meal-plans/${planId}/items`, {
method: "POST",
body: { name: title, description: detail },
})
}
function deleteMealPlan(id: string) {
return apiRequest<unknown>(`client/meal-plans/${id}`, { method: "DELETE" })
}
function assignMealPlan(id: string, customerId: string, notes?: string) {
return apiRequest<unknown>(`client/meal-plans/${id}/assignments`, {
method: "POST",
body: { customerId, notes },
})
}
function unassignMealPlan(customerMealId: string) {
return apiRequest<unknown>(`client/meal-plans/assignments/${customerMealId}`, {
method: "DELETE",
})
}
export {
getMealPlans,
getMealCategories,
createMealCategory,
createMealPlan,
updateMealPlan,
deleteMealPlan,
assignMealPlan,
unassignMealPlan,
getMealItems,
createMealItem,
}
+9
View File
@@ -0,0 +1,9 @@
import { apiRequest } from "@/lib/api/core-http"
import type { NavResponse } from "@/lib/models/nav"
/** The permission-filtered sidebar nav for the current caller (owner sees everything). */
function getNav() {
return apiRequest<NavResponse>("client/nav")
}
export { getNav }
+17
View File
@@ -0,0 +1,17 @@
import { apiRequest } from "@/lib/api/core-http"
import type { PayByLinkInvoice, PayByLinkResult } from "@/lib/models/pay"
/** Public — resolves a payment-link token to the invoice it's for. */
function getInvoiceByPaymentToken(token: string) {
return apiRequest<PayByLinkInvoice>(`client/pay/${token}`)
}
/** Public — completes a one-time self-serve payment from the pay-by-link page. */
function payInvoiceByToken(token: string, cardToken: string) {
return apiRequest<PayByLinkResult>(`client/pay/${token}/charge`, {
method: "POST",
body: { cardToken },
})
}
export { getInvoiceByPaymentToken, payInvoiceByToken }
+30
View File
@@ -0,0 +1,30 @@
import { apiRequest } from "@/lib/api/core-http"
import type { Place, PlaceInput } from "@/lib/models/place"
function getPlaces(includeInactive = true) {
return apiRequest<Place[]>("client/places", { query: { includeInactive } })
}
function createPlace(input: PlaceInput) {
return apiRequest<Place>("client/places", { method: "POST", body: input })
}
function updatePlace(id: string, input: Partial<PlaceInput>) {
return apiRequest<Place>(`client/places/${id}`, { method: "PUT", body: input })
}
function setPlaceStatus(id: string, isActive: boolean) {
return apiRequest<Place>(`client/places/${id}/status`, {
method: "PATCH",
body: { isActive },
})
}
function assignPlaceDevice(id: string, deviceId: string | null) {
return apiRequest<Place>(`client/places/${id}/device`, {
method: "PUT",
body: { deviceId },
})
}
export { getPlaces, createPlace, updatePlace, setPlaceStatus, assignPlaceDevice }
+98
View File
@@ -0,0 +1,98 @@
import { apiRequest } from "@/lib/api/core-http"
import type { ClientProfile, GymEquipment } from "@/lib/models/profile"
function getMyProfile() {
return apiRequest<ClientProfile>("client/me")
}
export interface GymFacilities {
hasAc: boolean
equipment: GymEquipment[]
}
/** Facilities is a JSON blob stored in a free-text column; parse defensively. */
function parseFacilities(facilities?: string | null): GymFacilities {
if (!facilities) return { hasAc: false, equipment: [] }
try {
const parsed = JSON.parse(facilities) as Partial<GymFacilities>
return {
hasAc: Boolean(parsed.hasAc),
equipment: Array.isArray(parsed.equipment)
? parsed.equipment
.filter((e): e is GymEquipment => !!e && typeof e.name === "string")
.map((e) => ({ name: e.name, count: Number(e.count) || 0 }))
: [],
}
} catch {
return { hasAc: false, equipment: [] }
}
}
export interface UpdateGymProfileInput {
businessName?: string
address?: string
city?: string
description?: string
hasAc?: boolean
equipment?: GymEquipment[]
}
function updateGymProfile(input: UpdateGymProfileInput) {
return apiRequest<{
success: boolean
message: string
facilities?: GymFacilities
}>("client/gym-profile", {
method: "PUT",
body: input,
})
}
function uploadGymLogo(file: File) {
const fd = new FormData()
fd.append("logo", file)
return apiRequest<{ businessLogoId: string }>("client/gym-profile/logo", {
method: "POST",
formData: fd,
})
}
/** Returns the logo as a base64 data URL (or null if none) for use as an <img> src. */
function getGymLogo() {
return apiRequest<{ logo: string | null }>("client/gym-profile/logo")
}
/** Sends an OTP to a NEW email/phone before it replaces the current one. */
async function sendContactOtp(
type: "email" | "phone",
identifier: string
): Promise<{ referenceNumber: string }> {
const data = await apiRequest<{ referenceNumber: string }>("client/me/contact/otp", {
method: "POST",
body: { type, identifier },
})
return { referenceNumber: data.referenceNumber }
}
/** Verifies the OTP and commits the new email/phone to the auth DB + Client record. */
function verifyContactOtp(input: {
type: "email" | "phone"
identifier: string
referenceNumber: string
otpCode: string
}) {
return apiRequest<{ success: boolean; message: string }>("client/me/contact/verify", {
method: "POST",
body: input,
})
}
export {
getMyProfile,
parseFacilities,
updateGymProfile,
uploadGymLogo,
getGymLogo,
sendContactOtp,
verifyContactOtp,
}
+14
View File
@@ -0,0 +1,14 @@
import { clearToken } from "@/lib/auth/token-service"
import { resetNavItemsCache } from "@/hooks/use-nav-items"
/**
* Logs out. The JWT is stateless and auth-service exposes no server-side revoke endpoint
* (see APIController.cs) — there's nothing to invalidate remotely, so this just clears the
* locally stored token. Callers redirect to /login afterwards.
*/
async function logout() {
resetNavItemsCache()
clearToken()
}
export { logout }
+84
View File
@@ -0,0 +1,84 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
RecordStaffPaymentInput,
StaffInput,
StaffMember,
StaffPayRate,
StaffPayment,
UpsertStaffPayRateInput,
} from "@/lib/models/staff"
function getStaff() {
return apiRequest<StaffMember[]>("client/staff")
}
function getStaffMember(staffId: string) {
return apiRequest<StaffMember>(`client/staff/${staffId}`)
}
function createStaff(input: StaffInput) {
return apiRequest<unknown>("client/staff", { method: "POST", body: input })
}
function updateStaff(staffId: string, input: Omit<StaffInput, "hireDate">) {
return apiRequest<unknown>(`client/staff/${staffId}`, { method: "PUT", body: input })
}
function deleteStaff(staffId: string, softDelete = true) {
return apiRequest<unknown>(`client/staff/${staffId}`, {
method: "DELETE",
query: { softDelete },
})
}
function getStaffPayRate(staffId: string) {
return apiRequest<StaffPayRate | null>(`client/staff/${staffId}/pay-rate`)
}
function upsertStaffPayRate(staffId: string, input: UpsertStaffPayRateInput) {
return apiRequest<StaffPayRate>(`client/staff/${staffId}/pay-rate`, { method: "PUT", body: input })
}
function getStaffPayments(staffId: string) {
return apiRequest<StaffPayment[]>(`client/staff/${staffId}/payments`)
}
function recordStaffPayment(staffId: string, input: RecordStaffPaymentInput) {
return apiRequest<StaffPayment>(`client/staff/${staffId}/payments`, { method: "POST", body: input })
}
function linkStaffFingerprint(staffId: string, unlinkedEnrollmentId: string) {
return apiRequest<{ accessProfileId: number; accessNumber: string; name: string | null }>(
`client/staff/${staffId}/link-fingerprint`,
{ method: "POST", body: { unlinkedEnrollmentId } }
)
}
function lockStaff(staffId: string) {
return apiRequest<import("@/lib/models/customer").AccessCommandStatus>(
`client/staff/${staffId}/lock`,
{ method: "POST" }
)
}
function unlockStaff(staffId: string) {
return apiRequest<import("@/lib/models/customer").AccessCommandStatus>(
`client/staff/${staffId}/unlock`,
{ method: "POST" }
)
}
export {
getStaff,
getStaffMember,
createStaff,
updateStaff,
deleteStaff,
getStaffPayRate,
upsertStaffPayRate,
getStaffPayments,
recordStaffPayment,
linkStaffFingerprint,
lockStaff,
unlockStaff,
}
+35
View File
@@ -0,0 +1,35 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
ClientStatSummary,
MonthlyRevenueStat,
RecentAttendance,
RecentPayment,
} from "@/lib/models/stats"
/** Date-only, matching the convention already used by src/lib/api/ledger.ts. */
function toDateParam(d: Date) {
return d.toISOString().slice(0, 10)
}
/** Omitting from/to falls back to the backend's default (this-month-vs-last-month) window. */
function getStatSummary(from?: Date, to?: Date) {
return apiRequest<ClientStatSummary>("client/stats/summary", {
query: { from: from ? toDateParam(from) : undefined, to: to ? toDateParam(to) : undefined },
})
}
function getMonthlyRevenue(from?: Date, to?: Date) {
return apiRequest<MonthlyRevenueStat[]>("client/stats/monthly-revenue", {
query: { from: from ? toDateParam(from) : undefined, to: to ? toDateParam(to) : undefined },
})
}
function getRecentPayments() {
return apiRequest<RecentPayment[]>("client/stats/recent-payments")
}
function getRecentAttendance() {
return apiRequest<RecentAttendance[]>("client/stats/recent-attendance")
}
export { getStatSummary, getMonthlyRevenue, getRecentPayments, getRecentAttendance }
+66
View File
@@ -0,0 +1,66 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
AddExtraFeeInput,
MemberCard,
RecordPaymentInput,
SubscriptionExtraFee,
SubscriptionPaymentsResult,
SubscriptionSummary,
} from "@/lib/models/subscription"
function getSubscription(id: string) {
return apiRequest<SubscriptionSummary>(`client/subscriptions/${id}`)
}
function getSubscriptionPayments(id: string) {
return apiRequest<SubscriptionPaymentsResult>(`client/subscriptions/${id}/payments`)
}
function recordSubscriptionPayment(id: string, input: RecordPaymentInput) {
// Omit an empty note so the backend's omit-if-null payload stays clean.
const body: Record<string, unknown> = { amount: input.amount, paymentMethod: input.paymentMethod }
if (input.note && input.note.trim()) body.note = input.note.trim()
if (input.paymentMethod === "Bank" && input.bankRefNo && input.bankRefNo.trim())
body.bankRefNo = input.bankRefNo.trim()
return apiRequest<unknown>(`client/subscriptions/${id}/payments`, { method: "POST", body })
}
function addSubscriptionMember(id: string, customerId: string) {
return apiRequest<unknown>(`client/subscriptions/${id}/members`, {
method: "POST",
body: { customerId },
})
}
function removeSubscriptionMember(id: string, subscriptionMemberId: string) {
return apiRequest<unknown>(`client/subscriptions/${id}/members/${subscriptionMemberId}`, {
method: "DELETE",
})
}
/** Cards a member has saved with this gym (used to offer the auto-charge toggle). */
function getMemberCards(customerId: string) {
return apiRequest<MemberCard[]>(`client/customers/${customerId}/cards`)
}
function getSubscriptionExtraFees(id: string) {
return apiRequest<SubscriptionExtraFee[]>(`client/subscriptions/${id}/extra-fees`)
}
function addSubscriptionExtraFee(id: string, input: AddExtraFeeInput) {
return apiRequest<{ customerSubItemId: string; customerInvoiceId: string; amount: number; frequency: string }>(
`client/subscriptions/${id}/extra-fees`,
{ method: "POST", body: input }
)
}
export {
getSubscription,
getSubscriptionPayments,
recordSubscriptionPayment,
addSubscriptionMember,
removeSubscriptionMember,
getMemberCards,
getSubscriptionExtraFees,
addSubscriptionExtraFee,
}
+58
View File
@@ -0,0 +1,58 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
Ticket,
TicketCategory,
TicketDetail,
} from "@/lib/models/ticket"
function getTickets() {
return apiRequest<Ticket[]>("client/tickets")
}
function getTicket(id: string) {
return apiRequest<TicketDetail>(`client/tickets/${id}`)
}
function getTicketCategories() {
return apiRequest<TicketCategory[]>("client/tickets/categories")
}
function createTicket(mainReason: string, ticketCategoryId?: string) {
return apiRequest<unknown>("client/tickets", {
method: "POST",
body: { mainReason, ticketCategoryId },
})
}
function sendTicketMessage(id: string, message: string) {
return apiRequest<unknown>(`client/tickets/${id}/messages`, {
method: "POST",
body: { message },
})
}
function submitTicketFeedback(id: string, rating: number, feedback?: string) {
return apiRequest<unknown>(`client/tickets/${id}/feedback`, {
method: "POST",
body: { rating, feedback },
})
}
function closeTicket(id: string) {
return apiRequest<unknown>(`client/tickets/${id}/close`, { method: "POST" })
}
function deleteTicket(id: string) {
return apiRequest<unknown>(`client/tickets/${id}`, { method: "DELETE" })
}
export {
getTickets,
getTicket,
getTicketCategories,
createTicket,
sendTicketMessage,
submitTicketFeedback,
closeTicket,
deleteTicket,
}
+21
View File
@@ -0,0 +1,21 @@
import { apiRequest } from "@/lib/api/core-http"
import type {
IssueTimeBankCardInput,
SwipeTimeBankCardInput,
TimeBankCard,
} from "@/lib/models/time-bank-card"
function issueTimeBankCard(input: IssueTimeBankCardInput) {
return apiRequest<TimeBankCard>("client/time-bank-cards", { method: "POST", body: input })
}
/** Manual staff-terminal swipe — tap-in starts the clock, tap-out deducts elapsed minutes. */
function swipeTimeBankCard(input: SwipeTimeBankCardInput) {
return apiRequest<TimeBankCard>("client/time-bank-cards/redeem", { method: "POST", body: input })
}
function getTimeBankCardBalance(rfidTagId: string) {
return apiRequest<TimeBankCard>(`client/time-bank-cards/${encodeURIComponent(rfidTagId)}`)
}
export { issueTimeBankCard, swipeTimeBankCard, getTimeBankCardBalance }
+181
View File
@@ -0,0 +1,181 @@
import { apiRequest } from "@/lib/api/core-http"
import type { CreatePlanInput, PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
interface WorkoutPlanDto {
workoutPlanId: string
name: string
description?: string | null
isTemplate: boolean
workoutCategoryId: string
workoutCategoryName?: string | null
clientId?: string | null
clientName?: string | null
isActive: boolean
createDate: string
assignedCustomers?: Array<{
customerWorkoutId: string
customerId: string
customerName?: string | null
isActive: boolean
assignedDate: string
}>
}
interface WorkoutCategoryDto {
workoutCategoryId: string
// The backend category DTO exposes the label as `name` (not `workoutCategoryName`).
name: string
description?: string | null
}
function normalizePlan(dto: WorkoutPlanDto): TrainingPlan {
return {
planId: dto.workoutPlanId,
name: dto.name,
description: dto.description,
isTemplate: dto.isTemplate,
categoryId: dto.workoutCategoryId,
categoryName: dto.workoutCategoryName,
clientId: dto.clientId,
clientName: dto.clientName,
isActive: dto.isActive,
createDate: dto.createDate,
assignedCustomers: (dto.assignedCustomers ?? []).map((c) => ({
assignmentId: c.customerWorkoutId,
customerId: c.customerId,
customerName: c.customerName,
isActive: c.isActive,
assignedDate: c.assignedDate,
})),
}
}
async function getWorkoutPlans(): Promise<TrainingPlan[]> {
const dtos = await apiRequest<WorkoutPlanDto[]>("client/workout-plans")
return (dtos ?? []).map(normalizePlan)
}
async function getWorkoutCategories(): Promise<PlanCategory[]> {
const dtos = await apiRequest<WorkoutCategoryDto[]>("client/workout-plans/categories")
return (dtos ?? []).map((c) => ({
categoryId: c.workoutCategoryId,
categoryName: c.name,
description: c.description,
}))
}
function createWorkoutCategory(name: string, description?: string) {
return apiRequest<unknown>("client/workout-plans/categories", {
method: "POST",
body: { name, description },
})
}
/** items: array of { exerciseDay, exercises } passed through to the service. */
function createWorkoutPlan(input: CreatePlanInput & { workoutItems?: unknown[] }) {
return apiRequest<unknown>("client/workout-plans", {
method: "POST",
body: {
name: input.name,
description: input.description,
categoryId: undefined,
workoutCategoryId: input.categoryId,
isTemplate: input.isTemplate,
customerIds: input.customerIds,
workoutItems: input.workoutItems,
},
})
}
function updateWorkoutPlan(
id: string,
input: Partial<CreatePlanInput> & { workoutItems?: unknown[] }
) {
return apiRequest<unknown>(`client/workout-plans/${id}`, {
method: "PUT",
body: {
name: input.name,
description: input.description,
workoutCategoryId: input.categoryId,
isTemplate: input.isTemplate,
workoutItems: input.workoutItems,
},
})
}
interface WorkoutExerciseDto {
workoutExerciseId: string
name: string
muscleGroup?: string | null
sets?: number | null
reps?: string | null
description?: string | null
breathIn?: string | null
breathOut?: string | null
videoUrl?: string | null
}
interface WorkoutItemDto {
workoutItemId: string
exerciseDay: string
description?: string | null
exercises?: string | null
workoutExercises?: WorkoutExerciseDto[] | null
}
async function getWorkoutItems(planId: string): Promise<PlanItemView[]> {
const dtos = await apiRequest<WorkoutItemDto[]>(`client/workout-plans/${planId}/items`)
return (dtos ?? []).map((i) => ({
id: i.workoutItemId,
title: i.exerciseDay,
detail: i.description ?? (typeof i.exercises === "string" ? i.exercises : "") ?? "",
exercises: (i.workoutExercises ?? []).map((e) => ({
id: e.workoutExerciseId,
name: e.name,
muscleGroup: e.muscleGroup,
sets: e.sets,
reps: e.reps,
description: e.description,
breathIn: e.breathIn,
breathOut: e.breathOut,
videoUrl: e.videoUrl,
})),
}))
}
function createWorkoutItem(planId: string, title: string, detail: string) {
return apiRequest<unknown>(`client/workout-plans/${planId}/items`, {
method: "POST",
body: { exerciseDay: title, exercises: detail },
})
}
function deleteWorkoutPlan(id: string) {
return apiRequest<unknown>(`client/workout-plans/${id}`, { method: "DELETE" })
}
function assignWorkoutPlan(id: string, customerId: string, notes?: string) {
return apiRequest<unknown>(`client/workout-plans/${id}/assignments`, {
method: "POST",
body: { customerId, notes },
})
}
function unassignWorkoutPlan(customerWorkoutId: string) {
return apiRequest<unknown>(`client/workout-plans/assignments/${customerWorkoutId}`, {
method: "DELETE",
})
}
export {
getWorkoutPlans,
getWorkoutCategories,
createWorkoutCategory,
createWorkoutPlan,
updateWorkoutPlan,
deleteWorkoutPlan,
assignWorkoutPlan,
unassignWorkoutPlan,
getWorkoutItems,
createWorkoutItem,
}
+30
View File
@@ -0,0 +1,30 @@
// core-api authenticates via a `MobileAuthToken: Bearer <token>` header (see
// core-http.ts), not an httpOnly cookie -- there's no BFF/gateway in this deployment to
// set one. The JWT itself is what login() returns; this just persists it across page
// reloads. An in-memory mirror avoids a localStorage read on every single request.
const TOKEN_KEY = "vijaya_access_token"
let memoryToken: string | null = null
function setToken(token: string) {
memoryToken = token
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token)
}
function clearToken() {
memoryToken = null
if (typeof window !== "undefined") localStorage.removeItem(TOKEN_KEY)
}
function getToken(): string | null {
if (memoryToken) return memoryToken
if (typeof window === "undefined") return null
memoryToken = localStorage.getItem(TOKEN_KEY)
return memoryToken
}
function isLoggedIn() {
return !!getToken()
}
export { setToken, clearToken, getToken, isLoggedIn }
+53
View File
@@ -0,0 +1,53 @@
export type PeriodKey = "today" | "yesterday" | "7d" | "30d" | "3mo" | "6mo"
export const PERIOD_OPTIONS: { value: PeriodKey; label: string }[] = [
{ value: "today", label: "Today" },
{ value: "yesterday", label: "Yesterday" },
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
{ value: "3mo", label: "3mo" },
{ value: "6mo", label: "6mo" },
]
function startOfDay(d: Date) {
const copy = new Date(d)
copy.setHours(0, 0, 0, 0)
return copy
}
/** Concrete {from, to} (both inclusive, local calendar days) for the selected period tab. */
function getPeriodRange(period: PeriodKey): { from: Date; to: Date } {
const today = startOfDay(new Date())
switch (period) {
case "today":
return { from: today, to: today }
case "yesterday": {
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
return { from: yesterday, to: yesterday }
}
case "7d": {
const from = new Date(today)
from.setDate(from.getDate() - 6)
return { from, to: today }
}
case "30d": {
const from = new Date(today)
from.setDate(from.getDate() - 29)
return { from, to: today }
}
case "3mo": {
const from = new Date(today)
from.setMonth(from.getMonth() - 3)
return { from, to: today }
}
case "6mo": {
const from = new Date(today)
from.setMonth(from.getMonth() - 6)
return { from, to: today }
}
}
}
export { getPeriodRange }
+341
View File
@@ -0,0 +1,341 @@
import type {
AttendanceCount,
AttendanceRecord,
TodayAttendance,
} from "@/lib/models/attendance"
import type { FollowedPlan, MedicalDetails, MemberProfile } from "@/lib/models/customer"
import type { GymPlan } from "@/lib/models/gym-plan"
import type { ProfitLoss } from "@/lib/models/ledger"
import type { PlanCategory, PlanItemView, TrainingPlan } from "@/lib/models/plan"
import type { ClientProfile } from "@/lib/models/profile"
import type { ClientStatSummary, MonthlyRevenueStat, RecentPayment } from "@/lib/models/stats"
import type { SubscriptionPayment, SubscriptionSummary } from "@/lib/models/subscription"
import type { Ticket, TicketCategory, TicketMessage } from "@/lib/models/ticket"
/** Shared mock datasets that back the sandbox/demo pages (no API calls). */
/** Membership plans behind the Members stage's Assign/Change/Onboarding dialogs. */
export const DEMO_GYM_PLANS: GymPlan[] = [
{ customerPlanId: "demo-plan-basic", fkClientId: "demo-gym-pulse-fitness", planName: "Basic Monthly", planDescription: "Gym floor access only", planPrice: 6000, hasOnboardingFee: false, onboardingFeeAmount: 0, durationInDays: 30, planScope: "Individual", memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, installmentCount: 1, isActive: true, createdAt: "2026-01-05T09:00:00+05:30", placeIds: [], placeNames: [] },
{ customerPlanId: "demo-plan-premium", fkClientId: "demo-gym-pulse-fitness", planName: "Premium Monthly", planDescription: "Gym floor + group classes", planPrice: 9500, hasOnboardingFee: true, onboardingFeeAmount: 1000, durationInDays: 30, planScope: "Individual", memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, installmentCount: 1, isActive: true, createdAt: "2026-01-10T09:00:00+05:30", placeIds: [], placeNames: [] },
{ customerPlanId: "demo-plan-couple", fkClientId: "demo-gym-pulse-fitness", planName: "Couple Quarterly", planDescription: "Gym + group classes for two", planPrice: 24000, hasOnboardingFee: false, onboardingFeeAmount: 0, durationInDays: 90, planScope: "Group", memberLimit: 2, durationMode: "Rolling", allowSplitPayments: true, installmentCount: 3, isActive: true, createdAt: "2026-01-15T09:00:00+05:30", placeIds: [], placeNames: [] },
{ customerPlanId: "demo-plan-family", fkClientId: "demo-gym-pulse-fitness", planName: "Family Season Pass", planDescription: "Whole-family access", planPrice: 40000, hasOnboardingFee: false, onboardingFeeAmount: 0, durationInDays: 0, planScope: "Group", memberLimit: 5, durationMode: "FixedRange", validFrom: "2026-08-01", validTo: "2026-12-31", allowSplitPayments: false, installmentCount: 1, isActive: true, createdAt: "2026-01-20T09:00:00+05:30", placeIds: [], placeNames: [] },
]
/** Every demo customer's profile — the source of truth the Members stage's rows are derived from. */
export const DEMO_MEMBER_PROFILES: MemberProfile[] = [
{ cusId: "d1", cusName: "Kasun Perera", gender: "Male", boD: "1995-03-12", primaryPH: "077 123 4567", email: "kasun@example.com", nic: "199512345678", isActive: true, createdAt: "2026-06-02T09:00:00+05:30", isLocked: false, user: { userId: "u-d1", userName: "kasun.perera", email: "kasun@example.com", phoneNumber: "077 123 4567" } },
{ cusId: "d2", cusName: "Nadeesha Silva", gender: "Female", boD: "1996-07-22", primaryPH: "071 987 6543", email: "nadeesha@example.com", nic: "966542310V", isActive: true, createdAt: "2026-05-18T09:00:00+05:30", isLocked: false, user: { userId: "u-d2", userName: "nadeesha.silva", email: "nadeesha@example.com", phoneNumber: "071 987 6543" } },
{ cusId: "d2b", cusName: "Chamara Silva", gender: "Male", boD: "1994-11-02", primaryPH: "071 555 2211", email: "chamara@example.com", nic: "942271234V", isActive: true, createdAt: "2026-05-18T09:00:00+05:30", isLocked: false, user: { userId: "u-d2b", userName: "chamara.silva", email: "chamara@example.com", phoneNumber: "071 555 2211" } },
{ cusId: "d3", cusName: "Ashan Fernando", gender: "Male", boD: "1988-01-30", primaryPH: "076 555 1212", email: "ashan@example.com", nic: "199801240036", isActive: true, createdAt: "2026-07-01T09:00:00+05:30", isLocked: false, user: { userId: "u-d3", userName: "ashan.fernando", email: "ashan@example.com", phoneNumber: "076 555 1212" } },
{ cusId: "d3b", cusName: "Sanduni Fernando", gender: "Female", boD: "1990-04-18", primaryPH: "076 555 1213", email: "sanduni@example.com", nic: "199032500012", isActive: true, createdAt: "2026-07-01T09:00:00+05:30", isLocked: false, user: { userId: "u-d3b", userName: "sanduni.fernando", email: "sanduni@example.com", phoneNumber: "076 555 1213" } },
{ cusId: "d3c", cusName: "Dilan Fernando", gender: "Male", boD: "2014-09-09", primaryPH: "076 555 1214", email: "dilan.fernando@example.com", nic: null, isActive: true, createdAt: "2026-07-01T09:00:00+05:30", isLocked: false, user: { userId: "u-d3c", userName: "dilan.fernando", email: "dilan.fernando@example.com", phoneNumber: "076 555 1214" } },
{ cusId: "d4", cusName: "Ishara Jayawardena", gender: "Female", boD: "1997-02-14", primaryPH: "070 444 8899", email: "ishara@example.com", nic: "925432112V", isActive: true, createdAt: "2026-06-25T09:00:00+05:30", isLocked: false, user: { userId: "u-d4", userName: "ishara.jaya", email: "ishara@example.com", phoneNumber: "070 444 8899" } },
{ cusId: "d5", cusName: "Dinusha Bandara", gender: "Male", boD: "1999-05-05", primaryPH: "072 333 7788", email: "dinusha@example.com", nic: "200012345671", isActive: true, createdAt: "2026-08-15T09:00:00+05:30", isLocked: false, user: { userId: "u-d5", userName: "dinusha.bandara", email: "dinusha@example.com", phoneNumber: "072 333 7788" } },
{ cusId: "d6", cusName: "Malith Fernando", gender: "Male", boD: "1992-10-19", primaryPH: "075 222 6633", email: "malith@example.com", nic: "926691234V", isActive: true, createdAt: "2026-04-05T09:00:00+05:30", isLocked: true, lockReason: "Overdue balance", user: { userId: "u-d6", userName: "malith.fernando", email: "malith@example.com", phoneNumber: "075 222 6633" } },
]
/** One `SubscriptionSummary` per active membership; `d5` is intentionally left without one (planScope "None"). */
export const DEMO_SUBSCRIPTIONS: SubscriptionSummary[] = [
{
subscriptionId: "sub-d1", customerPlanId: "demo-plan-basic", planName: "Basic Monthly", planScope: "Individual",
memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, planPrice: 6000,
onboardDate: "2026-06-02", expiryDate: "2026-08-19", isActive: true,
customerInvoiceId: "inv-d1", invoiceAmount: 6000, paidAmount: 6000, balance: 0, paymentStatus: "Paid",
members: [{ subscriptionMemberId: "sm-d1", customerId: "d1", cusName: "Kasun Perera", email: "kasun@example.com", primaryPH: "077 123 4567", isPrimary: true, joinedAt: "2026-06-02", isActive: true }],
installments: [{ subscriptionInstallmentId: "inst-d1-1", installmentNo: 1, dueDate: "2026-06-02", amount: 6000, paidAmount: 6000, status: "Paid" }],
autoChargeEnabled: false, autoChargeCard: null,
},
{
subscriptionId: "sub-d2", customerPlanId: "demo-plan-couple", planName: "Couple Quarterly", planScope: "Group",
memberLimit: 2, durationMode: "Rolling", allowSplitPayments: true, planPrice: 24000,
onboardDate: "2026-05-18", expiryDate: "2026-08-18", isActive: true,
customerInvoiceId: "inv-d2", invoiceAmount: 24000, paidAmount: 16000, balance: 8000, paymentStatus: "Partial",
members: [
{ subscriptionMemberId: "sm-d2", customerId: "d2", cusName: "Nadeesha Silva", email: "nadeesha@example.com", primaryPH: "071 987 6543", isPrimary: true, joinedAt: "2026-05-18", isActive: true },
{ subscriptionMemberId: "sm-d2b", customerId: "d2b", cusName: "Chamara Silva", email: "chamara@example.com", primaryPH: "071 555 2211", isPrimary: false, joinedAt: "2026-05-18", isActive: true },
],
installments: [
{ subscriptionInstallmentId: "inst-d2-1", installmentNo: 1, dueDate: "2026-05-18", amount: 8000, paidAmount: 8000, status: "Paid" },
{ subscriptionInstallmentId: "inst-d2-2", installmentNo: 2, dueDate: "2026-06-18", amount: 8000, paidAmount: 8000, status: "Paid" },
{ subscriptionInstallmentId: "inst-d2-3", installmentNo: 3, dueDate: "2026-07-18", amount: 8000, paidAmount: 0, status: "Pending" },
],
autoChargeEnabled: true, autoChargeCard: { cardId: "card-d2", cardLast4: "4242", cardType: "Visa" },
},
{
subscriptionId: "sub-d3", customerPlanId: "demo-plan-family", planName: "Family Season Pass", planScope: "Group",
memberLimit: 5, durationMode: "FixedRange", allowSplitPayments: false, planPrice: 40000,
onboardDate: "2026-07-01", expiryDate: "2026-12-31", isActive: true,
customerInvoiceId: "inv-d3", invoiceAmount: 40000, paidAmount: 0, balance: 40000, paymentStatus: "Pending",
members: [
{ subscriptionMemberId: "sm-d3", customerId: "d3", cusName: "Ashan Fernando", email: "ashan@example.com", primaryPH: "076 555 1212", isPrimary: true, joinedAt: "2026-07-01", isActive: true },
{ subscriptionMemberId: "sm-d3b", customerId: "d3b", cusName: "Sanduni Fernando", email: "sanduni@example.com", primaryPH: "076 555 1213", isPrimary: false, joinedAt: "2026-07-01", isActive: true },
{ subscriptionMemberId: "sm-d3c", customerId: "d3c", cusName: "Dilan Fernando", email: "dilan.fernando@example.com", primaryPH: "076 555 1214", isPrimary: false, joinedAt: "2026-07-01", isActive: true },
],
installments: [{ subscriptionInstallmentId: "inst-d3-1", installmentNo: 1, dueDate: "2026-07-01", amount: 40000, paidAmount: 0, status: "Pending" }],
autoChargeEnabled: false, autoChargeCard: null,
},
{
subscriptionId: "sub-d4", customerPlanId: "demo-plan-basic", planName: "Basic Monthly", planScope: "Individual",
memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, planPrice: 6000,
onboardDate: "2026-06-25", expiryDate: "2026-08-25", isActive: true,
customerInvoiceId: "inv-d4", invoiceAmount: 6000, paidAmount: 6000, balance: 0, paymentStatus: "Paid",
members: [{ subscriptionMemberId: "sm-d4", customerId: "d4", cusName: "Ishara Jayawardena", email: "ishara@example.com", primaryPH: "070 444 8899", isPrimary: true, joinedAt: "2026-06-25", isActive: true }],
installments: [{ subscriptionInstallmentId: "inst-d4-1", installmentNo: 1, dueDate: "2026-06-25", amount: 6000, paidAmount: 6000, status: "Paid" }],
autoChargeEnabled: false, autoChargeCard: null,
},
{
subscriptionId: "sub-d6", customerPlanId: "demo-plan-premium", planName: "Premium Monthly", planScope: "Individual",
memberLimit: 1, durationMode: "Rolling", allowSplitPayments: false, planPrice: 9500,
onboardDate: "2026-04-05", expiryDate: "2026-07-05", isActive: true,
customerInvoiceId: "inv-d6", invoiceAmount: 9500, paidAmount: 0, balance: 9500, paymentStatus: "Pending",
members: [{ subscriptionMemberId: "sm-d6", customerId: "d6", cusName: "Malith Fernando", email: "malith@example.com", primaryPH: "075 222 6633", isPrimary: true, joinedAt: "2026-04-05", isActive: true }],
installments: [{ subscriptionInstallmentId: "inst-d6-1", installmentNo: 1, dueDate: "2026-04-05", amount: 9500, paidAmount: 0, status: "Pending" }],
autoChargeEnabled: false, autoChargeCard: null,
},
]
/** Seeds "More info" (workout/meal plans + medical) per customer; missing ids fall back to empty. */
export const DEMO_MEMBER_EXTRAS: Record<string, { workoutPlans: FollowedPlan[]; mealPlans: FollowedPlan[]; medical: MedicalDetails | null }> = {
d1: {
workoutPlans: [{ planId: "w1", name: "Beginner Full Body", isFollowing: true, completedDate: null }],
mealPlans: [{ planId: "m2", name: "Cutting 1800kcal", isFollowing: true, completedDate: null }],
medical: { bloodGroup: "O+", disabilities: null },
},
d2: {
workoutPlans: [{ planId: "w2", name: "Fat Loss HIIT", isFollowing: true, completedDate: null }],
mealPlans: [],
medical: { bloodGroup: "A+", disabilities: null },
},
d3: {
workoutPlans: [],
mealPlans: [{ planId: "m1", name: "Lean Bulk 2500kcal", isFollowing: false, completedDate: null }],
medical: { bloodGroup: null, disabilities: "Mild knee injury — avoid heavy squats." },
},
}
/** Per-customer fingerprint/access panel fields (joined onto rows the same way the backend does). */
export const DEMO_MEMBER_ACCESS: Record<string, {
accessNumber: string
accessProvisionStatus: "NotConfigured" | "NoDevice" | "Pending" | "Synced" | "Failed"
fingerprintCount: number
actualAccessState: "Unknown" | "Enabled" | "Disabled" | "Mixed"
desiredAccessState: "Enabled" | "Disabled"
}> = {
d1: { accessNumber: "101", accessProvisionStatus: "Synced", fingerprintCount: 2, actualAccessState: "Enabled", desiredAccessState: "Enabled" },
d2: { accessNumber: "102", accessProvisionStatus: "Synced", fingerprintCount: 1, actualAccessState: "Enabled", desiredAccessState: "Enabled" },
d2b: { accessNumber: "102b", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
d3: { accessNumber: "103", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
d3b: { accessNumber: "103b", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
d3c: { accessNumber: "103c", accessProvisionStatus: "Pending", fingerprintCount: 0, actualAccessState: "Unknown", desiredAccessState: "Enabled" },
d4: { accessNumber: "104", accessProvisionStatus: "Synced", fingerprintCount: 2, actualAccessState: "Enabled", desiredAccessState: "Enabled" },
d6: { accessNumber: "106", accessProvisionStatus: "Synced", fingerprintCount: 1, actualAccessState: "Disabled", desiredAccessState: "Disabled" },
}
/** Payment-history rows behind `MemberSubscriptionCard`'s "Payment history" accordion, keyed by subscriptionId. */
export const DEMO_SUBSCRIPTION_PAYMENTS: Record<string, SubscriptionPayment[]> = {
"sub-d1": [{ attemptId: "pay-d1-1", payDate: "2026-06-02T10:05:00+05:30", payStatus: "Success", payAmount: 6000, payNote: "Cash at counter" }],
"sub-d2": [
{ attemptId: "pay-d2-1", payDate: "2026-05-18T09:40:00+05:30", payStatus: "Success", payAmount: 8000, payNote: "Card" },
{ attemptId: "pay-d2-2", payDate: "2026-06-18T09:50:00+05:30", payStatus: "Success", payAmount: 8000, payNote: "Card" },
],
"sub-d4": [{ attemptId: "pay-d4-1", payDate: "2026-06-25T11:10:00+05:30", payStatus: "Success", payAmount: 6000, payNote: "Cash at counter" }],
}
export const DEMO_REVENUE: MonthlyRevenueStat[] = [
{ year: 2026, month: 2, monthLabel: "Feb", revenue: 118000, newMemberCount: 9 },
{ year: 2026, month: 3, monthLabel: "Mar", revenue: 134000, newMemberCount: 11 },
{ year: 2026, month: 4, monthLabel: "Apr", revenue: 129000, newMemberCount: 8 },
{ year: 2026, month: 5, monthLabel: "May", revenue: 152000, newMemberCount: 13 },
{ year: 2026, month: 6, monthLabel: "Jun", revenue: 168000, newMemberCount: 15 },
{ year: 2026, month: 7, monthLabel: "Jul", revenue: 96000, newMemberCount: 7 },
]
/** Matches the real `ClientStatSummary` shape (GET client/stats/summary) exactly. */
export const DEMO_STAT_SUMMARY: ClientStatSummary = {
totalCustomerCount: 128,
thisMonthCustomerCount: 14,
lastMonthCustomerCount: 12,
customerGrowthPercentage: 12.0,
thisMonthRevenue: 168000,
lastMonthRevenue: 153500,
revenueGrowthPercentage: 9.4,
totalRevenue: 1845000,
overdueCount: 6,
expiringWithin7DaysCount: 9,
todayPaymentsCount: 4,
todayPaymentsTotal: 22000,
showTodayPayments: false,
}
export const DEMO_TODAY_ATTENDANCE: TodayAttendance[] = [
{ customerName: "Dinusha Bandara", eventTime: "2026-07-28T18:42:00+05:30", eventType: "IN" },
{ customerName: "Ishara Jayawardena", eventTime: "2026-07-28T17:56:00+05:30", eventType: "OUT" },
{ customerName: "Ashan Fernando", eventTime: "2026-07-28T17:31:00+05:30", eventType: "IN" },
{ customerName: "Nadeesha Silva", eventTime: "2026-07-28T08:05:00+05:30", eventType: "IN" },
{ customerName: "Kasun Perera", eventTime: "2026-07-28T07:18:00+05:30", eventType: "IN" },
{ customerName: "Malith Fernando", eventTime: "2026-07-28T06:48:00+05:30", eventType: "OUT" },
]
export const DEMO_ATTENDANCE_HISTORY: AttendanceRecord[] = [
{ attendanceId: "a1", fkCustomerId: "d5", deviceUserId: 105, eventTime: "2026-07-28T18:42:00+05:30", eventType: "IN", customer: { cusId: "d5", cusName: "Dinusha Bandara" } },
{ attendanceId: "a2", fkCustomerId: "d4", deviceUserId: 104, eventTime: "2026-07-28T17:56:00+05:30", eventType: "OUT", customer: { cusId: "d4", cusName: "Ishara Jayawardena" } },
{ attendanceId: "a3", fkCustomerId: "d3", deviceUserId: 103, eventTime: "2026-07-28T17:31:00+05:30", eventType: "IN", customer: { cusId: "d3", cusName: "Ashan Fernando" } },
{ attendanceId: "a4", fkCustomerId: "d2", deviceUserId: 102, eventTime: "2026-07-28T08:05:00+05:30", eventType: "IN", customer: { cusId: "d2", cusName: "Nadeesha Silva" } },
{ attendanceId: "a5", fkCustomerId: "d1", deviceUserId: 101, eventTime: "2026-07-28T07:18:00+05:30", eventType: "IN", customer: { cusId: "d1", cusName: "Kasun Perera" } },
{ attendanceId: "a6", fkCustomerId: "d1", deviceUserId: 101, eventTime: "2026-07-27T19:46:00+05:30", eventType: "OUT", customer: { cusId: "d1", cusName: "Kasun Perera" } },
{ attendanceId: "a7", fkCustomerId: "d2", deviceUserId: 102, eventTime: "2026-07-27T18:34:00+05:30", eventType: "OUT", customer: { cusId: "d2", cusName: "Nadeesha Silva" } },
{ attendanceId: "a8", fkCustomerId: "d5", deviceUserId: 105, eventTime: "2026-07-27T06:22:00+05:30", eventType: "IN", customer: { cusId: "d5", cusName: "Dinusha Bandara" } },
]
export const DEMO_ATTENDANCE_COUNTS: AttendanceCount[] = [
{ customerId: "d1", customerName: "Kasun Perera", attendanceCount: 24 },
{ customerId: "d2", customerName: "Nadeesha Silva", attendanceCount: 22 },
{ customerId: "d5", customerName: "Dinusha Bandara", attendanceCount: 20 },
{ customerId: "d4", customerName: "Ishara Jayawardena", attendanceCount: 17 },
{ customerId: "d3", customerName: "Ashan Fernando", attendanceCount: 14 },
]
export const DEMO_RECENT_PAYMENTS: RecentPayment[] = [
{ attemptId: "p1", payDate: "2026-07-28T16:20:00+05:30", payStatus: "Success", payAmount: 6000, customer: { cusId: "d1", cusName: "Kasun Perera" } },
{ attemptId: "p2", payDate: "2026-07-28T12:15:00+05:30", payStatus: "Success", payAmount: 8000, customer: { cusId: "d2", cusName: "Nadeesha Silva" } },
{ attemptId: "p3", payDate: "2026-07-27T18:05:00+05:30", payStatus: "Pending", payAmount: 40000, customer: { cusId: "d3", cusName: "Ashan Fernando" } },
]
export const DEMO_PROFILE: ClientProfile = {
clientId: "demo-gym-pulse-fitness",
clientName: "Pulse Fitness Colombo",
clientPhone: "077 555 0198",
clientEmail: "hello@pulsefitness.lk",
clientBusinesses: [
{
bid: "demo-business",
businessName: "Pulse Fitness Colombo",
address: "42 Havelock Road",
city: "Colombo 05",
description: "A modern strength, cardio and group-training gym.",
facilities: JSON.stringify({
hasAc: true,
equipment: [
{ name: "Treadmill", count: 8 },
{ name: "Dumbbell set", count: 4 },
{ name: "Squat rack", count: 3 },
{ name: "Exercise bike", count: 6 },
],
}),
isActive: true,
},
],
}
export const DEMO_PROFIT_LOSS: ProfitLoss = {
from: "2026-07-01",
to: "2026-07-28",
totalIncome: 168000,
totalExpense: 89500,
netProfit: 78500,
incomeByCategory: [
{ category: "Membership payments", amount: 154000 },
{ category: "Personal training", amount: 14000 },
],
expenseByCategory: [
{ category: "Rent", amount: 50000 },
{ category: "Staff payroll", amount: 28000 },
{ category: "Utilities", amount: 7500 },
{ category: "Equipment maintenance", amount: 4000 },
],
entries: [
{ id: "l1", entryType: "Income", category: "Membership payments", description: "Member invoice collections", amount: 154000, entryDate: "2026-07-28", isManual: false },
{ id: "l2", entryType: "Income", category: "Personal training", description: "PT session income", amount: 14000, entryDate: "2026-07-26", isManual: true },
{ id: "l3", entryType: "Expense", category: "Rent", description: "July premises rent", amount: 50000, entryDate: "2026-07-05", isManual: true },
{ id: "l4", entryType: "Expense", category: "Staff payroll", description: "Monthly payroll", amount: 28000, entryDate: "2026-07-25", isManual: true },
{ id: "l5", entryType: "Expense", category: "Utilities", description: "Electricity and water", amount: 7500, entryDate: "2026-07-22", isManual: true },
{ id: "l6", entryType: "Expense", category: "Equipment maintenance", description: "Treadmill service", amount: 4000, entryDate: "2026-07-18", isManual: true },
],
}
export const DEMO_WORKOUT_CATEGORIES: PlanCategory[] = [
{ categoryId: "wc-1", categoryName: "Strength", description: null },
{ categoryId: "wc-2", categoryName: "Cardio", description: null },
]
export const DEMO_MEAL_CATEGORIES: PlanCategory[] = [
{ categoryId: "mc-1", categoryName: "Muscle gain", description: null },
{ categoryId: "mc-2", categoryName: "Weight loss", description: null },
]
export const DEMO_WORKOUT_TRAINING_PLANS: TrainingPlan[] = [
{
planId: "wp-1", name: "Beginner Full Body", description: "3-day full body routine for new members",
isTemplate: true, categoryId: "wc-1", categoryName: "Strength", isActive: true, createDate: "2026-06-01T09:00:00+05:30",
assignedCustomers: [{ assignmentId: "wa-1", customerId: "d1", customerName: "Kasun Perera", isActive: true, assignedDate: "2026-06-05" }],
},
{
planId: "wp-2", name: "Fat Loss HIIT", description: "High intensity interval training",
isTemplate: false, categoryId: "wc-2", categoryName: "Cardio", isActive: true, createDate: "2026-06-10T09:00:00+05:30",
assignedCustomers: [],
},
]
export const DEMO_MEAL_TRAINING_PLANS: TrainingPlan[] = [
{
planId: "mp-1", name: "Lean Bulk 2500kcal", description: "High protein muscle-gain plan",
isTemplate: true, categoryId: "mc-1", categoryName: "Muscle gain", isActive: true, createDate: "2026-06-02T09:00:00+05:30",
assignedCustomers: [],
},
{
planId: "mp-2", name: "Cutting 1800kcal", description: "Calorie deficit for fat loss",
isTemplate: false, categoryId: "mc-2", categoryName: "Weight loss", isActive: true, createDate: "2026-06-12T09:00:00+05:30",
assignedCustomers: [{ assignmentId: "ma-1", customerId: "d4", customerName: "Ishara Jayawardena", isActive: true, assignedDate: "2026-06-15" }],
},
]
/** Saved plan days/meals behind PlanDetailPanel and the plan editor's "edit" prefill, keyed by planId. */
export const DEMO_PLAN_ITEMS: Record<string, PlanItemView[]> = {
"wp-1": [
{ id: "wi-1", title: "Monday", detail: "Upper-body strength", exercises: [{ id: "ex-1", name: "Bench Press", muscleGroup: "Chest", sets: 4, reps: "8-10", description: null, breathIn: null, breathOut: null, videoUrl: null }] },
{ id: "wi-2", title: "Wednesday", detail: "Lower-body strength", exercises: [{ id: "ex-2", name: "Squat", muscleGroup: "Legs", sets: 4, reps: "8-10", description: null, breathIn: null, breathOut: null, videoUrl: null }] },
{ id: "wi-3", title: "Friday", detail: "Conditioning and core", exercises: [{ id: "ex-3", name: "Plank", muscleGroup: "Core", sets: 3, reps: "45s", description: null, breathIn: null, breathOut: null, videoUrl: null }] },
],
"mp-1": [
{ id: "mi-1", title: "Breakfast", detail: "Oats, eggs, banana", exercises: [] },
{ id: "mi-2", title: "Lunch", detail: "Rice, chicken breast, vegetables", exercises: [] },
{ id: "mi-3", title: "Dinner", detail: "Fish, sweet potato, salad", exercises: [] },
],
}
export const DEMO_TICKET_CATEGORIES: TicketCategory[] = [
{ ticketCategoryId: "tc-1", ticketCategoryName: "Billing" },
{ ticketCategoryId: "tc-2", ticketCategoryName: "Technical" },
{ ticketCategoryId: "tc-3", ticketCategoryName: "General" },
]
export const DEMO_TICKETS: Ticket[] = [
{
ticketId: "ticket-1", mainReason: "Need help setting up member plans", createDate: "2026-08-10T09:15:00+05:30",
userId: "demo-owner", ticketCategoryId: "tc-3", ticketCategoryName: "General", ticketStatus: 2,
},
{
ticketId: "ticket-2", mainReason: "Payment gateway not receiving webhooks", createDate: "2026-08-05T14:30:00+05:30",
userId: "demo-owner", ticketCategoryId: "tc-2", ticketCategoryName: "Technical", ticketStatus: 1,
},
{
ticketId: "ticket-3", mainReason: "Question about last month's platform invoice", createDate: "2026-07-20T11:00:00+05:30",
userId: "demo-owner", ticketCategoryId: "tc-1", ticketCategoryName: "Billing", ticketStatus: 3,
},
]
export const DEMO_TICKET_MESSAGES: Record<string, TicketMessage[]> = {
"ticket-1": [
{ messageId: "msg-1a", isSentByAdmin: false, message: "How do I add a couple/family plan?", createDate: "2026-08-10T09:15:00+05:30" },
{ messageId: "msg-1b", isSentByAdmin: true, message: "Head to Memberships → New Plan and set the scope to Group with a member limit.", createDate: "2026-08-10T10:02:00+05:30" },
],
"ticket-2": [
{ messageId: "msg-2a", isSentByAdmin: false, message: "PayHere payments aren't marking invoices as paid automatically.", createDate: "2026-08-05T14:30:00+05:30" },
],
"ticket-3": [
{ messageId: "msg-3a", isSentByAdmin: false, message: "Can you break down last month's usage fee?", createDate: "2026-07-20T11:00:00+05:30" },
{ messageId: "msg-3b", isSentByAdmin: true, message: "It's Rs. 50 per active member on top of the base platform fee — 90 members last month.", createDate: "2026-07-20T13:45:00+05:30" },
{ messageId: "msg-3c", isSentByAdmin: false, message: "Got it, thanks!", createDate: "2026-07-20T14:00:00+05:30" },
],
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Client-side normalisation for face photos headed to a Hikvision face library.
*
* The terminal is strict in ways a raw camera roll photo never satisfies, so the browser
* does the work before upload:
* - JPEG only. Phone photos are usually HEIC, which the terminal rejects outright.
* - Small. The ~3MB figure in the Hikvision docs is the ISAPI transport ceiling; the
* face *library* caps far lower, so an oversized photo uploads happily and then fails
* enrolment -- the worst failure mode, because it looks like it worked.
* - Portrait. Terminals expect a 3:4 face frame; a square crop tends to cut the chin.
*
* Canvas does all of this, so there is no image dependency in this project.
*/
/** Target frame. 3:4 portrait is what the face library expects. */
export const FACE_WIDTH = 480
export const FACE_HEIGHT = 640
/** Face-library budget, well under the ISAPI transport limit. */
export const FACE_TARGET_BYTES = 150 * 1024
export const FACE_MAX_BYTES = 200 * 1024
/** Guard on the *input* file, applied before decoding so a huge file can't exhaust memory. */
export const INPUT_MAX_BYTES = 3 * 1024 * 1024
/** Quality ladder: start high, step down only while over budget. */
const QUALITY_STEPS = [0.85, 0.75, 0.65]
export interface NormalizedFacePhoto {
/** Bare base64 JPEG, no data: prefix — what the API expects. */
base64: string
/** data: URL for previewing in an <img>. */
previewUrl: string
bytes: number
width: number
height: number
}
export class FacePhotoError extends Error {}
/**
* Decodes any browser-readable image, cover-crops it to 3:4, resizes to 480x640 and
* encodes JPEG under the face-library budget.
*/
export async function normalizeFacePhoto(
source: Blob | HTMLVideoElement
): Promise<NormalizedFacePhoto> {
if (source instanceof Blob) {
if (source.size > INPUT_MAX_BYTES) {
throw new FacePhotoError(
`That image is ${Math.round(source.size / 1024 / 1024)} MB. Choose one under 3 MB.`
)
}
if (source.size === 0) {
throw new FacePhotoError("That file is empty.")
}
}
const bitmap = await toBitmap(source)
try {
const canvas = document.createElement("canvas")
canvas.width = FACE_WIDTH
canvas.height = FACE_HEIGHT
const ctx = canvas.getContext("2d")
if (!ctx) throw new FacePhotoError("This browser cannot process images.")
// Cover-crop: fill the 3:4 frame from the centre rather than squashing the face.
const scale = Math.max(FACE_WIDTH / bitmap.width, FACE_HEIGHT / bitmap.height)
const drawWidth = bitmap.width * scale
const drawHeight = bitmap.height * scale
ctx.imageSmoothingQuality = "high"
// A face library needs an opaque background; a transparent PNG would otherwise
// flatten to black and wreck the exposure.
ctx.fillStyle = "#ffffff"
ctx.fillRect(0, 0, FACE_WIDTH, FACE_HEIGHT)
ctx.drawImage(
bitmap,
(FACE_WIDTH - drawWidth) / 2,
(FACE_HEIGHT - drawHeight) / 2,
drawWidth,
drawHeight
)
let blob: Blob | null = null
for (const quality of QUALITY_STEPS) {
blob = await canvasToBlob(canvas, quality)
if (blob && blob.size <= FACE_TARGET_BYTES) break
}
if (!blob) throw new FacePhotoError("Could not process that image. Try another photo.")
if (blob.size > FACE_MAX_BYTES) {
throw new FacePhotoError(
"Could not compress that photo enough for the access terminal. Try a simpler " +
"background or better lighting."
)
}
const dataUrl = await blobToDataUrl(blob)
return {
base64: dataUrl.slice(dataUrl.indexOf(",") + 1),
previewUrl: dataUrl,
bytes: blob.size,
width: FACE_WIDTH,
height: FACE_HEIGHT,
}
} finally {
if ("close" in bitmap && typeof bitmap.close === "function") bitmap.close()
}
}
async function toBitmap(source: Blob | HTMLVideoElement): Promise<ImageBitmap> {
try {
// createImageBitmap decodes HEIC on Safari and every other format the browser knows.
return await createImageBitmap(source as ImageBitmapSource)
} catch {
throw new FacePhotoError(
"That file isn't a readable image. Use a JPEG or PNG photo, or take one with the camera."
)
}
}
function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality))
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new FacePhotoError("Could not read the processed image."))
reader.readAsDataURL(blob)
})
}
+28
View File
@@ -0,0 +1,28 @@
const DISPLAY_LOCALE = "en-LK"
const DISPLAY_TIME_ZONE = "Asia/Colombo"
/** Formatting is explicit so SSR and browser renders produce the same output. */
export function formatCurrency(amount: number) {
return `Rs. ${amount.toLocaleString(DISPLAY_LOCALE)}`
}
export function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString(DISPLAY_LOCALE, {
hour: "numeric",
minute: "2-digit",
timeZone: DISPLAY_TIME_ZONE,
})
}
export function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(DISPLAY_LOCALE, {
month: "short",
day: "numeric",
year: "numeric",
timeZone: DISPLAY_TIME_ZONE,
})
}
export function formatDateTime(iso: string) {
return `${formatDate(iso)}, ${formatTime(iso)}`
}
+45
View File
@@ -0,0 +1,45 @@
/** Best-effort geocoding via OpenStreetMap's free Nominatim API (no key required). */
async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`
)
if (!res.ok) return null
const data = (await res.json()) as { display_name?: string }
return data.display_name ?? null
} catch {
return null
}
}
async function forwardGeocode(address: string): Promise<{ lat: number; lng: number } | null> {
const [first] = await searchAddress(address, 1)
return first ? { lat: first.lat, lng: first.lng } : null
}
export interface AddressSuggestion {
label: string
lat: number
lng: number
}
/** Address-autocomplete suggestions as the user types. */
async function searchAddress(query: string, limit = 5): Promise<AddressSuggestion[]> {
const q = query.trim()
if (q.length < 3) return []
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=${limit}&q=${encodeURIComponent(q)}`
)
if (!res.ok) return []
const data = (await res.json()) as { display_name: string; lat: string; lon: string }[]
return data
.map((d) => ({ label: d.display_name, lat: Number(d.lat), lng: Number(d.lon) }))
.filter((d) => !Number.isNaN(d.lat) && !Number.isNaN(d.lng))
} catch {
return []
}
}
export { reverseGeocode, forwardGeocode, searchAddress }
+25
View File
@@ -0,0 +1,25 @@
/** Mirrors the tenant-scoped response from GET client/access-devices. */
export interface AccessDevice {
deviceId: string
deviceName: string
isActive: boolean
lastSeenAt?: string | null
isOnline: boolean
presenceExpiresAt?: string | null
isClaimed: boolean
createdAt: string
}
export interface AccessAgent {
agentId: string
name: string
isActive: boolean
lastSeenAt?: string | null
isOnline: boolean
createdAt: string
}
export interface ClientAccessDevices {
agents: AccessAgent[]
devices: AccessDevice[]
}
+5
View File
@@ -0,0 +1,5 @@
export interface ApiResponse<T> {
statusCode: number
message: string
data: T
}
+31
View File
@@ -0,0 +1,31 @@
/** Attendance entity rows from GET client/attendance. */
export interface AttendanceRecord {
attendanceId: string
fkCustomerId?: string
fkUserId?: string
fkAccessProfileId?: number
deviceUserId: number
eventTime: string
eventType: string
customer?: { cusId: string; cusName: string } | null
user?: { userId: string; userName?: string | null } | null
accessProfile?: {
accessProfileId: number
displayName: string
personType: string
} | null
}
/** Mirrors TodayAttendanceDTO. */
export interface TodayAttendance {
customerName: string
eventTime: string
eventType?: string | null
}
/** Mirrors AttendanceCountDTO. */
export interface AttendanceCount {
customerId: string
customerName: string
attendanceCount: number
}
+12
View File
@@ -0,0 +1,12 @@
export interface LoginRequest {
identifier: string
password: string
userTypeId?: string
deviceName?: string
rememberMe?: boolean
}
export interface LoginResult {
accessToken: string | null
refreshToken: string | null
}
+32
View File
@@ -0,0 +1,32 @@
/** Mirrors BookingDto. */
export interface Booking {
bookingId: string
facilitySessionId: string
facilityName?: string | null
sessionStartTime?: string | null
sessionEndTime?: string | null
isGuest: boolean
customerId?: string | null
guestName?: string | null
guestPhone?: string | null
/** Only populated right after booking creation — the guest's receipt. */
otpCode?: string | null
otpVerified: boolean
paymentStatus: string
amountCharged: number
status: string
bookedAt: string
checkedInAt?: string | null
/** Public, unauthenticated link to this booking's confirmation page. */
shareUrl?: string | null
}
export interface CreateGuestBookingInput {
facilitySessionId: string
guestName: string
guestPhone: string
}
+171
View File
@@ -0,0 +1,171 @@
import type { GymPlan } from "@/lib/models/gym-plan"
import type { PaymentStatus, SubscriptionSummary } from "@/lib/models/subscription"
/** One weekly weigh-in (CusWeightLossProgress). */
export interface WeightProgressPoint {
weeklyProgressId: string
weight?: number | null
recordedAt: string
}
/** A member's goal/measurements (CustomerTarget) with their weight history. */
export interface CustomerTarget {
customerTargetId: string
currentWeight?: number | null
currentHeight?: number | null
targetWeight?: number | null
goal?: string | null
timePeriod?: string | null
weeklyProgresses?: WeightProgressPoint[] | null
}
/** Customer entity as serialized by the backend (camelCase of the EF model). */
export interface MemberProfile {
cusId: string
cusName?: string | null
gender?: string | null
/** Date of birth, "yyyy-MM-dd". Backend property is BoD. */
boD?: string | null
primaryPH?: string | null
secondaryPH?: string | null
email?: string | null
nic?: string | null
address?: string | null
fkUserId?: string
isActive: boolean
createdAt: string
isLocked?: boolean
lockReason?: string | null
lockedAt?: string | null
customerTargets?: CustomerTarget[] | null
user?: {
userId: string
userName?: string | null
email?: string | null
phoneNumber?: string | null
} | null
}
/** A workout/meal plan the member is assigned to. */
export interface FollowedPlan {
planId: string
name?: string | null
isFollowing: boolean
completedDate?: string | null
}
/** GET client/customers/{id}/full — the "More Info" aggregate. */
export interface MemberFullDetail {
customer: MemberProfile
subscriptions?: SubscriptionSummary[]
workoutPlans: FollowedPlan[]
mealPlans: FollowedPlan[]
}
/** POST client/customers/{id}/medical (password-gated). */
export interface MedicalDetails {
bloodGroup?: string | null
disabilities?: string | null
}
/**
* Row shape of GET client/customers (MemberListRowDto): one row per member.
* Group members each get a row pointing at their shared subscription.
*/
export interface MemberSubscription {
subscriptionId: string
fkCustomerPlanId: string
fkCustomerId: string
onboardDate: string
/** Frozen at creation, never changes again -- use renewalDate for current status/listing. */
expiryDate?: string | null
renewalDate?: string | null
isActive: boolean
createdAt: string
customer?: MemberProfile | null
isPrimary: boolean
/** False when unassigned from the plan but still a member of the gym — re-assignable. */
isPlanAssigned?: boolean
planName?: string | null
/** "None" means this member was created without a plan yet — assign one to make them active. */
planScope?: "Individual" | "Group" | "None"
memberCount?: number
memberLimit?: number
invoiceAmount?: number
paidAmount?: number
balance?: number
paymentStatus?: PaymentStatus
allowSplitPayments?: boolean
fingerprintId?: number | null
fingerprintName?: string | null
accessProfileId?: number | null
accessNumber?: string | null
accessProvisionStatus?: "NotConfigured" | "NoDevice" | "Pending" | "Synced" | "Failed"
actualAccessState?: "Unknown" | "Enabled" | "Disabled" | "Mixed"
desiredAccessState?: "Enabled" | "Disabled"
fingerprintCount?: number
accessLastError?: string | null
isLocked?: boolean
lockReason?: string | null
/** Face-photo enrolment on the gym's access terminals. */
hasFacePhoto?: boolean
facePhotoStatus?: "None" | "Pending" | "Synced" | "Failed"
facePhotoError?: string | null
}
/** GET client/customers/unlinked-fingerprints — a device enrollment not yet linked to a Customer. */
export interface UnlinkedFingerprint {
id: string
deviceUserId: number
deviceEmployeeNo: string
deviceReportedName?: string | null
deviceUserType?: string | null
fingerprintCount: number
deviceId: string
deviceName?: string | null
discoveredAt: string
}
export interface AccessCommandStatus {
accessProfileId: number
accessNumber: string
personType: "Customer" | "Staff"
desiredAccessState: "Enabled" | "Disabled"
provisionStatus: "NoDevice" | "Pending" | "Synced" | "Failed"
actualAccessState: "Unknown" | "Enabled" | "Disabled" | "Mixed"
fingerprintCount: number
lastError?: string | null
lastSyncedAt?: string | null
deviceCount: number
syncedDeviceCount: number
}
/** GET client/customers/{id} response. */
export interface MemberDetail {
customer: MemberProfile
plans: GymPlan[]
subscriptions?: SubscriptionSummary[]
}
export interface MemberInput {
fullname: string
userName: string
email: string
mobileNumber: string
gender: string
/** "yyyy-MM-dd" */
birthday: string
nic?: string
customerPlanId?: string
workoutPlanIds?: string[]
mealPlanIds?: string[]
/** ISO datetime. Only meaningful alongside customerPlanId. */
onboardDate?: string
/** false bills the plan at Rs. 0.00 and skips the automatic payment link. */
chargePayment?: boolean
discountType?: "Percentage" | "Flat"
discountValue?: number
/** Set true only on the resubmit after staff has confirmed linking an existing platform
* customer to this gym (see CreateMemberResult.requiresConfirmation). */
confirmLink?: boolean
}
+39
View File
@@ -0,0 +1,39 @@
export type FacilityType = "Gym" | "Pool" | "Court" | "Class"
/** Mirrors FacilityDto. */
export interface Facility {
facilityId: string
name: string
facilityType: FacilityType
description?: string | null
defaultCapacity: number
isActive: boolean
createdAt: string
}
export interface FacilityInput {
name: string
facilityType: FacilityType
description?: string
defaultCapacity: number
}
/** Mirrors FacilitySessionDto. */
export interface FacilitySession {
facilitySessionId: string
facilityId: string
facilityName?: string | null
startTime: string
endTime: string
capacity: number
bookedCount: number
isCancelled: boolean
}
export interface FacilitySessionInput {
facilityId: string
startTime: string
endTime: string
/** Overrides the facility's default capacity for this specific session. */
capacity?: number
}
+38
View File
@@ -0,0 +1,38 @@
/** Item shape returned by GET client/feed (ShowFeedByClient). */
export interface FeedPost {
feedPostId: string
caption?: string | null
description?: string | null
userId: string
docId?: number | null
isActive: boolean
createdAt: string
likeCount: number
document?: {
fileName?: string | null
fileType?: string | null
documentUrl?: string | null
documentType?: string | null
/** Base64 file content. */
data?: string | null
} | null
}
/** Inner wrapper FeedService puts around the list. */
export interface FeedListResult {
success: boolean
message: string
data: FeedPost[]
}
export interface FeedDocumentInput {
FileName: string
/** Base64 file content (DocumentService reads the `Base64` key). FeedService injects FileType. */
Base64: string
}
export interface CreateFeedInput {
caption?: string
description?: string
document: FeedDocumentInput[]
}
+49
View File
@@ -0,0 +1,49 @@
export type PlanScope = "Individual" | "Group"
export type DurationMode = "Rolling" | "FixedRange"
/** Mirrors CustomerPlanDto (gym membership plans). */
export interface GymPlan {
customerPlanId: string
fkClientId: string
clientName?: string | null
planName: string
planDescription?: string | null
planPrice: number
hasOnboardingFee: boolean
onboardingFeeAmount: number
durationInDays: number
planScope: PlanScope
memberLimit: number
durationMode: DurationMode
validFrom?: string | null
validTo?: string | null
allowSplitPayments: boolean
installmentCount: number
isActive: boolean
createdAt: string
/** Places this plan grants access to. Empty = unrestricted (every active device). */
placeIds: string[]
placeNames: string[]
}
export interface GymPlanInput {
planName: string
planDescription?: string
planPrice: number
hasOnboardingFee?: boolean
onboardingFeeAmount?: number
/** Required for Rolling plans; omit for FixedRange. */
durationInDays?: number
planScope?: PlanScope
/** Required (>= 2) for Group plans. */
memberLimit?: number
durationMode?: DurationMode
/** ISO dates; required for FixedRange plans. */
validFrom?: string
validTo?: string
allowSplitPayments?: boolean
/** Number of mostly-equal installments when split payments are enabled. */
installmentCount?: number
/** Replaces the plan's place set when provided; omit to leave unchanged, [] to clear. */
placeIds?: string[]
}
+20
View File
@@ -0,0 +1,20 @@
/** Mirrors GymRoleDto. */
export interface GymRole {
gymRoleId: string
name: string
isActive: boolean
navKeys: string[]
}
export interface CreateGymRoleInput {
name: string
}
export interface UpdateGymRoleInput {
name?: string
isActive?: boolean
}
export interface SetRolePermissionsInput {
navKeys: string[]
}
+36
View File
@@ -0,0 +1,36 @@
import type { MemberProfile } from "@/lib/models/customer"
import type { GymPlan } from "@/lib/models/gym-plan"
/** CustomerInvoice entity from GET client/member-invoices (member billing). */
export interface MemberInvoice {
customerInvoiceId: string
fkCustomerSubscriptionId: string
fkClientId: string
amount: number
/** The portion of `amount` carried forward from a prior cycle's unpaid balance (renewal
* approval flow), rather than this cycle's own charge. 0/undefined when there's none --
* only populated by the renewal-approvals list and the single-invoice detail endpoint. */
carriedForwardAmount?: number
status: string
/** Approval gate for the renewal-approval workflow: false means this invoice was
* auto-generated by the daily renewal sweep and is awaiting approve/change-plan/reject.
* Independent of `status`. */
isActive: boolean
paidAmount?: number
balance?: number
customerInvoiceDate: string
/** Flattened from CustomerSubscription.TenantCustomerLink -- use this for display, not
* customerSubscription.customer, which the backend never actually populates. */
customerName?: string | null
customerPhone?: string | null
/** TenantCustomerLink id -- used for "view previous invoices" and latest-per-customer grouping. */
fkCustomerId?: string | null
customerSubscription?: {
subscriptionId: string
onboardDate: string
expiryDate?: string | null
renewalDate?: string | null
customer?: MemberProfile | null
customerPlan?: GymPlan | null
} | null
}
+37
View File
@@ -0,0 +1,37 @@
export type LedgerEntryType = "Income" | "Expense"
/** Mirrors LedgerEntryDto — one row in the P&L breakdown (derived or manual). */
export interface LedgerEntry {
id?: string | null
entryType: LedgerEntryType
category: string
description?: string | null
amount: number
entryDate: string
isManual: boolean
}
export interface CategoryTotal {
category: string
amount: number
}
/** GET client/reports/profit-loss. */
export interface ProfitLoss {
from: string
to: string
totalIncome: number
totalExpense: number
netProfit: number
incomeByCategory: CategoryTotal[]
expenseByCategory: CategoryTotal[]
entries: LedgerEntry[]
}
export interface AddLedgerEntryInput {
entryType: LedgerEntryType
category: string
description?: string
amount: number
entryDate?: string
}
+3
View File
@@ -0,0 +1,3 @@
export interface LockPolicy {
lockAfterDaysOverdue: number | null
}
+12
View File
@@ -0,0 +1,12 @@
export interface NavItemApi {
key: string
label: string
shortLabel?: string
href: string
icon: string
}
export interface NavResponse {
isOwner: boolean
items: NavItemApi[]
}
+17
View File
@@ -0,0 +1,17 @@
/** GET client/pay/{token} — public pay-by-link invoice summary. */
export interface PayByLinkInvoice {
memberName?: string | null
gymName?: string | null
planName?: string | null
amount: number
balance: number
currency: string
}
export interface PayByLinkResult {
success: boolean
invoiceId: string
paidAmount: number
balance: number
status: string
}
+16
View File
@@ -0,0 +1,16 @@
/** Mirrors PlaceDto. A physical access zone (e.g. "Pool Area", "Indoor Gym") with one
* fingerprint/face terminal for now. */
export interface Place {
placeId: string
name: string
description?: string | null
isActive: boolean
createdAt: string
deviceId?: string | null
deviceName?: string | null
}
export interface PlaceInput {
name: string
description?: string
}
+69
View File
@@ -0,0 +1,69 @@
/** Shared shapes for workout & meal plans (mirrors WorkoutPlanDto / MealPlanDto). */
export interface AssignedCustomer {
/** customerWorkoutId or customerMealId — the id UnassignWorkout/UnassignMeal expects. */
assignmentId: string
customerId: string
customerName?: string | null
isActive: boolean
assignedDate: string
}
export interface TrainingPlan {
/** workoutPlanId or mealPlanId, normalized to `planId` at the API layer. */
planId: string
name: string
description?: string | null
isTemplate: boolean
categoryId: string
categoryName?: string | null
clientId?: string | null
clientName?: string | null
isActive: boolean
createDate: string
assignedCustomers: AssignedCustomer[]
}
export interface PlanCategory {
categoryId: string
categoryName: string
description?: string | null
}
export interface PlanItemRow {
/** Day label for workouts (e.g. "Monday"); free text for meals ("Breakfast"). */
label: string
detail: string
}
/** One structured exercise within a workout day (mirrors WorkoutExerciseDto). */
export interface WorkoutExerciseView {
id: string
name: string
muscleGroup?: string | null
sets?: number | null
reps?: string | null
description?: string | null
breathIn?: string | null
breathOut?: string | null
videoUrl?: string | null
}
/** A saved plan item, normalized for the detail panel (one card per item). */
export interface PlanItemView {
id: string
/** exerciseDay (workout) or meal name (meal). */
title: string
/** exercises list (workout) or description (meal). */
detail: string
/** Structured exercises (workout only) — empty for meal items. */
exercises: WorkoutExerciseView[]
}
export interface CreatePlanInput {
name: string
description?: string
categoryId: string
isTemplate?: boolean
customerIds?: string[]
}
+27
View File
@@ -0,0 +1,27 @@
/** One equipment type + how many the gym has (e.g. Treadmill × 10). */
export interface GymEquipment {
name: string
count: number
}
/** Gym business record attached to a client (from GET client/me). */
export interface ClientBusiness {
bid: string
businessName?: string | null
address?: string | null
city?: string | null
description?: string | null
businessLogoId?: string | null
/** JSON blob: { hasAc, equipment: [{ name, count }] } — parse with parseFacilities(). */
facilities?: string | null
isActive: boolean
}
/** Mirrors the Client entity returned by GET client/me. */
export interface ClientProfile {
clientId: string
clientName?: string | null
clientPhone?: string | null
clientEmail?: string | null
clientBusinesses?: ClientBusiness[] | null
}
+67
View File
@@ -0,0 +1,67 @@
/** Mirrors StaffDto. */
export interface StaffMember {
staffId: string
userId: string
userName?: string | null
email?: string | null
phoneNumber?: string | null
clientId: string
clientName?: string | null
gymRoleId?: string | null
gymRoleName?: string | null
staffPosition?: string | null
department?: string | null
hireDate: string
isActive: boolean
createdAt: string
fingerprintId?: number | null
fingerprintName?: string | null
accessProfileId?: number | null
accessNumber?: string | null
accessProvisionStatus?: "NotConfigured" | "NoDevice" | "Pending" | "Synced" | "Failed"
actualAccessState?: "Unknown" | "Enabled" | "Disabled" | "Mixed"
desiredAccessState?: "Enabled" | "Disabled"
fingerprintCount?: number
accessLastError?: string | null
}
export interface StaffInput {
userName: string
email: string
phoneNumber: string
staffPosition?: string
department?: string
/** "yyyy-MM-dd" — create only. */
hireDate?: string
/** Create only. Left unset, the new staff member has no dashboard access until a role
* is assigned later via Roles & Permissions. */
gymRoleId?: string
}
export type StaffRateType = "Monthly" | "Hourly"
/** Mirrors StaffPayRateDto. */
export interface StaffPayRate {
id: string
rateType: StaffRateType
amount: number
effectiveFrom: string
}
export interface UpsertStaffPayRateInput {
rateType: StaffRateType
amount: number
}
/** Mirrors StaffPaymentDto. */
export interface StaffPayment {
id: string
amount: number
note?: string | null
paidAt: string
}
export interface RecordStaffPaymentInput {
amount: number
note?: string
}
+55
View File
@@ -0,0 +1,55 @@
/** Mirrors ClientStatSummaryDto (camelCased by the backend). */
export interface ClientStatSummary {
totalCustomerCount: number
thisMonthCustomerCount: number
lastMonthCustomerCount: number
customerGrowthPercentage: number
/** These four are zeroed server-side for non-owner callers — revenue is owner-only. */
thisMonthRevenue: number
lastMonthRevenue: number
revenueGrowthPercentage: number
totalRevenue: number
overdueCount: number
expiringWithin7DaysCount: number
/** Always "today" (UTC date), independent of any reporting-period filter elsewhere on this
* object -- visible to every staff member, not owner-gated like revenue above. */
todayPaymentsCount: number
todayPaymentsTotal: number
/** Muscle Beast-only pilot -- gated server-side, so the UI never needs to know any gym's
* ClientId to decide whether to show the Today's Payments card/filter. */
showTodayPayments: boolean
}
/** Mirrors MonthlyRevenueStat. */
export interface MonthlyRevenueStat {
year: number
month: number
monthLabel: string
/** Zeroed server-side for non-owner callers — revenue is owner-only. */
revenue: number
newMemberCount: number
}
interface CustomerRef {
cusId: string
cusName: string
}
/** PaymentTransaction entity rows from GET client/stats/recent-payments. */
export interface RecentPayment {
attemptId: string
payDate: string | null
payStatus: string
payAmount: number
payNote?: string | null
customer?: CustomerRef | null
}
/** Attendance entity rows from GET client/stats/recent-attendance. */
export interface RecentAttendance {
attendanceId: string
eventTime: string
eventType: string
deviceUserId: number
customer?: CustomerRef | null
}
+116
View File
@@ -0,0 +1,116 @@
export type PaymentStatus = "Pending" | "Partial" | "Paid"
export interface SubscriptionMemberInfo {
subscriptionMemberId: string
customerId: string
cusName?: string | null
email?: string | null
primaryPH?: string | null
isPrimary: boolean
joinedAt: string
isActive: boolean
/** False when unassigned from the plan but still a member of the gym. */
isPlanAssigned?: boolean
}
export interface SubscriptionInstallment {
subscriptionInstallmentId: string
installmentNo: number
dueDate: string
amount: number
paidAmount: number
status: PaymentStatus
}
export interface AutoChargeCard {
cardId: string
cardLast4?: string | null
cardType?: string | null
}
export interface MemberCard {
jobId: string
cardId?: string | null
cardNumber?: string | null
cardType?: string | null
nickName?: string | null
isPrimary: boolean
consentGiven: boolean
}
/** GET client/subscriptions/{id}. */
export interface SubscriptionSummary {
subscriptionId: string
customerPlanId: string
planName?: string | null
planScope: "Individual" | "Group"
memberLimit: number
durationMode: "Rolling" | "FixedRange"
allowSplitPayments: boolean
planPrice: number
onboardDate: string
/** Frozen at creation, never changes again -- see renewalDate for the current cycle end. */
expiryDate?: string | null
renewalDate?: string | null
isActive: boolean
customerInvoiceId?: string | null
invoiceAmount: number
paidAmount: number
balance: number
paymentStatus: PaymentStatus
members: SubscriptionMemberInfo[]
installments: SubscriptionInstallment[]
autoChargeEnabled: boolean
autoChargeCard?: AutoChargeCard | null
}
export interface SubscriptionPayment {
attemptId: string
payDate?: string | null
payStatus: string
payAmount: number
payNote?: string | null
refType?: string | null
paymentMethod?: "Cash" | "Bank" | null
bankRefNo?: string | null
isApproved?: boolean | null
}
/** GET client/subscriptions/{id}/payments. */
export interface SubscriptionPaymentsResult {
invoiceId?: string | null
invoiceAmount: number
paidAmount: number
balance: number
status: PaymentStatus
installments: SubscriptionInstallment[]
payments: SubscriptionPayment[]
}
export interface RecordPaymentInput {
amount: number
note?: string
paymentMethod: "Cash" | "Bank"
/** Reference/slip number — only meaningful (and sent) when paymentMethod is "Bank". */
bankRefNo?: string
}
export type ExtraFeeFrequency = "OneTime" | "Recurring"
/** Mirrors CustomerSubItemDto — an optional extra fee on a subscription (e.g. coaching fee). */
export interface SubscriptionExtraFee {
customerSubItemId: string
subItemName?: string | null
subItemDescription?: string | null
subItemPrice: number
frequency: ExtraFeeFrequency
isActive: boolean
createdAt: string
}
export interface AddExtraFeeInput {
name: string
description?: string
amount: number
frequency: ExtraFeeFrequency
}
+46
View File
@@ -0,0 +1,46 @@
/** TicketStatusEnum: 1=Pending, 2=Open, 3=Closed (serialized as int). */
export type TicketStatus = 1 | 2 | 3
export const TICKET_STATUS_LABEL: Record<TicketStatus, string> = {
1: "Pending",
2: "Open",
3: "Closed",
}
/** Mirrors TicketDto (list rows). */
export interface Ticket {
ticketId: string
mainReason: string
createDate: string
userId: string
userName?: string | null
userEmail?: string | null
adminId?: string | null
adminName?: string | null
ticketCategoryId?: string | null
ticketCategoryName?: string | null
userRating?: number | null
ticketStatus: TicketStatus
}
/** Mirrors TicketMessageDto. */
export interface TicketMessage {
messageId: string
isSentByAdmin: boolean
message: string
attachmentURL?: string | null
createDate: string
}
/** Mirrors TicketDetailDto. */
export interface TicketDetail extends Ticket {
userPhoneNumber?: string | null
userFeedback?: string | null
feedbackAt?: string | null
messages: TicketMessage[]
}
export interface TicketCategory {
ticketCategoryId: string
ticketCategoryName: string
}
+34
View File
@@ -0,0 +1,34 @@
/** Mirrors TimeBankCardDto. */
export interface TimeBankCard {
timeBankCardId: string
rfidTagId: string
customerId?: string | null
totalMinutesPurchased: number
minutesRemaining: number
amountPaid: number
purchasedAt: string
expiresAt?: string | null
isActive: boolean
/** True right after a tap-in swipe (session open, clock running). */
sessionOpen: boolean
sessionStartedAt?: string | null
sessionFacilityName?: string | null
/** Only populated in the response to a tap-out swipe. */
minutesDeductedThisSwipe?: number | null
}
export interface IssueTimeBankCardInput {
rfidTagId: string
totalMinutesPurchased: number
amountPaid: number
/** Ties the card to a member's own account; omit for a walk-in-only card. */
customerId?: string
expiresAt?: string
}
export interface SwipeTimeBankCardInput {
rfidTagId: string
facilityId: string
}
+55
View File
@@ -0,0 +1,55 @@
import type { LucideIcon } from "lucide-react"
import {
Activity,
BarChart3,
CalendarCheck,
CalendarClock,
CreditCard,
LayoutDashboard,
Receipt,
Rss,
Settings,
TicketIcon,
Users,
UsersRound,
Utensils,
} from "lucide-react"
export interface DashboardNavItem {
key: string
label: string
/** Compact label used in the mobile bottom bar. */
shortLabel?: string
href: string
icon: LucideIcon
}
// Real dashboard usage fetches this list from the backend (permission-filtered) via
// useNavItems() — see hooks/use-nav-items.ts. NAV_ITEMS below is now sandbox-demo-only
// (src/app/sandbox/[token]/page.tsx has no backend to call) and the icon-name → LucideIcon
// mapping source for the live hook (see ICON_MAP there).
export const NAV_ITEMS: DashboardNavItem[] = [
{ key: "dashboard", label: "Dashboard", shortLabel: "Home", href: "/dashboard", icon: LayoutDashboard },
{ key: "attendance", label: "Attendance", href: "/dashboard/attendance", icon: CalendarCheck },
{ key: "members", label: "Members", href: "/dashboard/members", icon: Users },
{ key: "memberships", label: "Memberships", href: "/dashboard/memberships", icon: CreditCard },
{ key: "customer-payments", label: "Customer Payments", shortLabel: "Payments", href: "/dashboard/customer-payments", icon: Receipt },
{ key: "bookings", label: "Bookings", href: "/dashboard/bookings", icon: CalendarClock },
{ key: "workout-plans", label: "Workout Plans", href: "/dashboard/workout-plans", icon: Activity },
{ key: "meal-plans", label: "Meal Plans", href: "/dashboard/meal-plans", icon: Utensils },
{ key: "staff", label: "Staff", href: "/dashboard/staff", icon: UsersRound },
{ key: "feed", label: "Feed", href: "/dashboard/feed", icon: Rss },
{ key: "tickets", label: "Tickets", href: "/dashboard/tickets", icon: TicketIcon },
{ key: "reports", label: "Reports", href: "/dashboard/reports", icon: BarChart3 },
{ key: "settings", label: "Gym Profile", href: "/dashboard/settings", icon: Settings },
]
/** Sections pinned to the mobile bottom bar; the rest live behind "More". */
export const MOBILE_PRIMARY_KEYS = ["dashboard", "members", "attendance", "customer-payments"]
export function isNavItemActive(item: DashboardNavItem, pathname: string) {
if (item.href === "/dashboard") return pathname === item.href
// Plain startsWith would also match "/dashboard/memberships" against "/dashboard/members"
// (the latter is a literal string prefix of the former) — require a path-segment boundary.
return pathname === item.href || pathname.startsWith(`${item.href}/`)
}
+127
View File
@@ -0,0 +1,127 @@
"use client"
import {
HubConnection,
HubConnectionBuilder,
HubConnectionState,
LogLevel,
} from "@microsoft/signalr"
type AccessEventType =
| "attendance"
| "deviceInventory"
| "accessState"
| "devicePresence"
interface DevicePresenceUpdate {
deviceId: string
isOnline: boolean
lastSeenAt?: string | null
presenceExpiresAt?: string | null
}
interface DevicePresencePayload {
agentId: string
devices: DevicePresenceUpdate[]
occurredAt: string
}
interface AccessRealtimeEvent {
type: AccessEventType
payload: unknown
}
type AccessEventListener = (event: AccessRealtimeEvent) => void
// Trimmed so a trailing slash on the env var can't produce a double-slash in the hub URL
// below -- some routing layers 404 on that (e.g. "https://host.com//hubs/access").
const CORE_API_URL = (
process.env.NEXT_PUBLIC_CORE_API_URL ?? "https://hexafit.hexdive.com"
).replace(/\/+$/, "")
const listeners = new Set<AccessEventListener>()
let connection: HubConnection | null = null
let startPromise: Promise<void> | null = null
let initialRetryTimer: ReturnType<typeof setTimeout> | null = null
function emit(type: AccessEventType, payload: unknown) {
for (const listener of listeners) listener({ type, payload })
}
function getConnection() {
if (connection) return connection
connection = new HubConnectionBuilder()
.withUrl(`${CORE_API_URL}/hubs/access`, {
withCredentials: true,
})
.withAutomaticReconnect([0, 2_000, 5_000, 10_000, 30_000])
.configureLogging(LogLevel.Warning)
.build()
connection.on("AttendanceUpdated", (payload) => emit("attendance", payload))
connection.on("DeviceInventoryUpdated", (payload) =>
emit("deviceInventory", payload)
)
connection.on("AccessStateUpdated", (payload) => emit("accessState", payload))
connection.on("DevicePresenceUpdated", (payload) =>
emit("devicePresence", payload)
)
connection.onclose(() => {
startPromise = null
scheduleInitialRetry()
})
return connection
}
function scheduleInitialRetry() {
if (listeners.size === 0 || initialRetryTimer) return
initialRetryTimer = setTimeout(() => {
initialRetryTimer = null
void ensureStarted()
}, 5_000)
}
async function ensureStarted() {
const hub = getConnection()
if (
hub.state === HubConnectionState.Connected ||
hub.state === HubConnectionState.Connecting ||
hub.state === HubConnectionState.Reconnecting
) {
return
}
if (startPromise) return startPromise
startPromise = hub
.start()
.catch(() => {
scheduleInitialRetry()
})
.finally(() => {
startPromise = null
})
return startPromise
}
function subscribeAccessEvents(listener: AccessEventListener) {
listeners.add(listener)
void ensureStarted()
return () => {
listeners.delete(listener)
if (listeners.size === 0 && initialRetryTimer) {
clearTimeout(initialRetryTimer)
initialRetryTimer = null
}
}
}
export { subscribeAccessEvents }
export type {
AccessRealtimeEvent,
AccessEventType,
DevicePresencePayload,
DevicePresenceUpdate,
}
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
import { ApiError } from "@/lib/api/core-http"
interface UseFetchResult<T> {
data: T | null
loading: boolean
error: string | null
reload: () => void
mutate: (updater: (data: T | null) => T | null) => void
}
function useFetch<T>(fetcher: () => Promise<T>): UseFetchResult<T> {
const fetcherRef = React.useRef(fetcher)
const [data, setData] = React.useState<T | null>(null)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
const [tick, setTick] = React.useState(0)
React.useEffect(() => {
fetcherRef.current = fetcher
}, [fetcher])
React.useEffect(() => {
let cancelled = false
void Promise.resolve().then(async () => {
if (cancelled) return
setLoading(true)
setError(null)
try {
const result = await fetcherRef.current()
if (!cancelled) setData(result)
} catch (err) {
if (cancelled) return
if (err instanceof ApiError && err.status === 401) {
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`
return
}
const message = err instanceof ApiError ? err.message : "Something went wrong."
setError(message)
toast.error(message)
} finally {
if (!cancelled) setLoading(false)
}
})
return () => { cancelled = true }
}, [tick])
const reload = React.useCallback(() => setTick((t) => t + 1), [])
const mutate = React.useCallback((updater: (data: T | null) => T | null) => {
setData((prev) => updater(prev))
}, [])
return { data, loading, error, reload, mutate }
}
export { useFetch }
+19
View File
@@ -0,0 +1,19 @@
"use client"
import * as React from "react"
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false)
React.useEffect(() => {
const mql = window.matchMedia(query)
const onChange = () => setMatches(mql.matches)
onChange()
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [query])
return matches
}
export { useMediaQuery }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import { toast } from "sonner"
/**
* Runs `fn`, toasting the real error message on failure, then rethrows so the caller can
* still run its own follow-up (e.g. keep a dialog open, revert optimistic state). Does
* nothing extra on success — that (toast.success, closing a dialog, reload()) stays the
* caller's job.
*
* Use this directly for per-row busy state (a table where any row can be in flight
* independently); use `useMutation` below for the common single-busy-flag case.
*/
async function runMutation<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Something went wrong.")
throw err
}
}
interface UseMutationResult {
busy: boolean
run: <T>(fn: () => Promise<T>) => Promise<T>
}
/** `runMutation`, plus tracking one `busy` flag — for a single submit button/dialog. */
function useMutation(): UseMutationResult {
const [busy, setBusy] = React.useState(false)
const run = React.useCallback(async <T>(fn: () => Promise<T>): Promise<T> => {
setBusy(true)
try {
return await runMutation(fn)
} finally {
setBusy(false)
}
}, [])
return { busy, run }
}
export { useMutation, runMutation }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+91
View File
@@ -0,0 +1,91 @@
/** Shared client-side validation + formatting helpers. */
/** Strip everything except digits (for phone input). */
export function normalizePhone(raw: string) {
return raw.replace(/\D/g, "")
}
/** Sri Lankan mobile: 10 digits starting with 0 (e.g. 0771234567). Pass digits only. */
export function isValidSriLankanMobile(digits: string) {
return /^0\d{9}$/.test(digits)
}
/** Sri Lankan NIC: old format 9 digits + V/X, or new format 12 digits. */
export function isValidSriLankanNIC(nic: string) {
const value = nic.trim()
return /^\d{9}[vVxX]$/.test(value) || /^\d{12}$/.test(value)
}
/** Email with a TLD of at least two characters. */
export function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value.trim())
}
export const MAX_NIC_FILE_BYTES = 2 * 1024 * 1024 // 2MB
export function isFileWithinSize(file: File, maxBytes = MAX_NIC_FILE_BYTES) {
return file.size <= maxBytes
}
export function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** Mask a phone number, revealing only the last 2 digits: 0771234567 → ••••••••67 */
export function maskPhone(digits: string) {
const clean = normalizePhone(digits)
if (clean.length <= 2) return clean
return "•".repeat(clean.length - 2) + clean.slice(-2)
}
/** Mask an email, revealing only the first char + domain: ruwan@gmail.com → r••@gmail.com */
export function maskEmail(email: string) {
const trimmed = email.trim()
const at = trimmed.indexOf("@")
if (at <= 0) return trimmed
const local = trimmed.slice(0, at)
const domain = trimmed.slice(at)
const shown = local.slice(0, 1)
return `${shown}${"•".repeat(Math.max(local.length - 1, 2))}${domain}`
}
/** Whole years between a birthday (yyyy-MM-dd or ISO) and today. */
/**
* Clamps a raw discount input as the user types: 0-100 for a percentage, 0-maxFlat
* (uncapped if maxFlat isn't known yet) for a flat amount. Keeps "" as-is so the field
* can be cleared, and leaves non-numeric input untouched rather than fighting the caret.
*/
export function clampDiscountValue(raw: string, type: "Percentage" | "Flat", maxFlat?: number) {
if (raw === "") return raw
const n = Number(raw)
if (Number.isNaN(n)) return raw
const upperBound = type === "Percentage" ? 100 : (maxFlat ?? Infinity)
const clamped = Math.min(Math.max(n, 0), upperBound)
return clamped === n ? raw : String(clamped)
}
export function ageFromBirthday(birthday: string) {
const dob = new Date(birthday)
if (Number.isNaN(dob.getTime())) return null
const now = new Date()
let age = now.getFullYear() - dob.getFullYear()
const m = now.getMonth() - dob.getMonth()
if (m < 0 || (m === 0 && now.getDate() < dob.getDate())) age--
return age
}
/**
* Sort a list newest-first by a date-ish key. Non-mutating.
* Missing/invalid dates sink to the bottom.
*/
export function sortByNewest<T>(rows: T[], key: (row: T) => string | null | undefined) {
return [...rows].sort((a, b) => {
const ta = Date.parse(key(a) ?? "")
const tb = Date.parse(key(b) ?? "")
const va = Number.isNaN(ta) ? -Infinity : ta
const vb = Number.isNaN(tb) ? -Infinity : tb
return vb - va
})
}