Loyalty & Offers
Customer gems balance and catalogue, public active offers, and admin offer management.
Loyalty & Offers
Two related areas: the gems loyalty programme (a customer's own balance, transaction history, and the redeemable-services catalogue) and offers (a public list of active promotions plus manager-only CRUD).
Base URLs: the customer endpoints (GET /api/gems, public
GET /api/offers) are served from https://theroyalglow.in. The admin offer
management endpoints are served from https://admin.theroyalglow.in with no
/api/admin/ segment — the subdomain is the namespace (e.g.
admin.theroyalglow.in/api/offers). Note /api/offers therefore exists in
both apps: a public read on theroyalglow.in and role-gated CRUD on
admin.theroyalglow.in. Money is an integer in paise (₹1,000.00 =
100000). Calendar dates are YYYY-MM-DD on input; date-mode columns
serialise back as UTC-midnight ISO strings (e.g. "2026-06-30T00:00:00.000Z").
The role hierarchy is customer < staff < receptionist < manager < owner < developer.
Gems rules. Customers earn 1 gem per ₹100 invoiced (floor), on service invoices only — never on membership purchases or sessions. Gems expire 365 days after they are earned. Redemption is against specific catalogue services, not a rupee discount, and gems cannot be combined with an offer on the same booking.
GET theroyalglow.in/api/gems
Returns the signed-in customer's own loyalty summary, a paginated transaction
history, and the redeemable-services catalogue. Strictly scoped to
session.user.id — it never exposes another customer's data. A loyalty account
is created on first call, so a brand-new customer sees zeros rather than an
error.
Minimum role: customer (requireSession)
GET /api/gems?page=1&pageSize=20Query parameters
Prop
Type
Response
data.summary is the balance plus lifetime totals (zeros if the account was
just created). data.transactions are newest-first; invoiceNumber is null
for non-invoice transactions (e.g. expired/adjusted). data.redeemable is
the active gems-redeemable services catalogue. meta carries page and
totalPages — there is no separate count query, so totalPages is page + 1
whenever a full page is returned (there may be more), otherwise page.
{
"success": true,
"data": {
"summary": {
"balance": 240,
"totalEarned": 310,
"totalRedeemed": 70
},
"transactions": [
{
"id": "lt_9c1f2a7b3d",
"type": "earned",
"gemsAmount": 13,
"description": "Earned on invoice INV-1-2627-92921",
"expiresAt": "2027-06-01T06:30:00.000Z",
"createdAt": "2026-06-01T06:30:00.000Z",
"invoiceNumber": "INV-1-2627-92921"
},
{
"id": "lt_5a8d0e2c4f",
"type": "redeemed",
"gemsAmount": -70,
"description": "Redeemed against Classic Manicure",
"expiresAt": null,
"createdAt": "2026-05-20T11:05:00.000Z",
"invoiceNumber": null
}
],
"redeemable": [
{
"id": "svc_manicure01",
"name": "Classic Manicure",
"gemsRequired": 70,
"pricePaise": 70000
}
]
},
"meta": {
"page": 1,
"totalPages": 1
}
}loyalty_tx_type is one of earned, redeemed, expired, adjusted.
Errors
GET theroyalglow.in/api/offers
Public list of active offers for the customer offers page. No auth. Returns
only offers whose isActive flag is true and whose calendar date range includes
today, ordered by displayOrder. Each offer carries its linked services.
Minimum role: Public (no session required)
GET /api/offersResponse
Each offer is the full offer row plus services ({ id, name }[]) and the
convenience arrays serviceIds and serviceNames. The discount field that is
populated depends on offerType:
percentage→discountPercentage(1–100)flat→discountAmountPaise(paise)combo_price→comboPricePaise(paise)
{
"success": true,
"data": {
"offers": [
{
"id": "of_3b7e1d9a2c",
"name": "Monsoon Glow 20% Off",
"slug": "monsoon-glow-20-off-a1b2c3",
"description": "20% off select salon services this monsoon.",
"offerType": "percentage",
"discountPercentage": 20,
"discountAmountPaise": null,
"comboPricePaise": null,
"startDate": "2026-06-01T00:00:00.000Z",
"endDate": "2026-06-30T00:00:00.000Z",
"isActive": true,
"terms": "One offer per customer per day. Cannot combine with gems.",
"imageUrl": null,
"displayOrder": 0,
"createdAt": "2026-05-28T09:00:00.000Z",
"updatedAt": "2026-05-28T09:00:00.000Z",
"services": [
{ "id": "svc_haircut001", "name": "Signature Haircut" }
],
"serviceIds": ["svc_haircut001"],
"serviceNames": ["Signature Haircut"]
}
]
}
}Errors
GET admin.theroyalglow.in/api/offers
Lists all offers (admin view), newest first, each with its linked services.
Minimum role: manager (requireRole('manager'))
GET /api/offersResponse
Same per-offer shape as GET /api/offers, but unfiltered (includes inactive and
out-of-range offers), ordered by createdAt descending.
{
"success": true,
"data": {
"offers": [
{
"id": "of_3b7e1d9a2c",
"name": "Monsoon Glow 20% Off",
"slug": "monsoon-glow-20-off-a1b2c3",
"description": "20% off select salon services this monsoon.",
"offerType": "percentage",
"discountPercentage": 20,
"discountAmountPaise": null,
"comboPricePaise": null,
"startDate": "2026-06-01T00:00:00.000Z",
"endDate": "2026-06-30T00:00:00.000Z",
"isActive": true,
"terms": "One offer per customer per day.",
"imageUrl": null,
"displayOrder": 0,
"createdAt": "2026-05-28T09:00:00.000Z",
"updatedAt": "2026-05-28T09:00:00.000Z",
"services": [{ "id": "svc_haircut001", "name": "Signature Haircut" }],
"serviceIds": ["svc_haircut001"],
"serviceNames": ["Signature Haircut"]
}
]
}
}Errors
POST admin.theroyalglow.in/api/offers
Creates an offer and its service links. The slug is derived server-side from the
name (lowercased, hyphenated, with a short nanoid suffix so duplicate names never
collide). The calendar dates are converted to UTC-midnight Dates.
Minimum role: manager (requireRole('manager'))
POST /api/offers
Content-Type: application/jsonRequest body
Validated by createOfferSchema (@rgss/types). The discount field that
matches offerType is required, and endDate must be on or after
startDate (both enforced via superRefine, surfaced as field-level errors).
Prop
Type
Requires discountPercentage.
{
"name": "Monsoon Glow 20% Off",
"offerType": "percentage",
"discountPercentage": 20,
"startDate": "2026-06-01",
"endDate": "2026-06-30",
"serviceIds": ["svc_haircut001"],
"description": "20% off select salon services this monsoon.",
"terms": "One offer per customer per day. Cannot combine with gems."
}Requires discountAmountPaise.
{
"name": "Flat ₹500 Off Styling",
"offerType": "flat",
"discountAmountPaise": 50000,
"startDate": "2026-07-01",
"endDate": "2026-07-15",
"serviceIds": ["svc_haircut001", "svc_blowdry01"]
}Requires comboPricePaise.
{
"name": "Haircut + Beard Combo ₹999",
"offerType": "combo_price",
"comboPricePaise": 99900,
"startDate": "2026-07-01",
"endDate": "2026-07-31",
"serviceIds": ["svc_haircut001", "svc_beardtrim01"]
}Response
Returns 201 Created with the created offer (the raw offer row).
{
"success": true,
"data": {
"offer": {
"id": "of_3b7e1d9a2c",
"name": "Monsoon Glow 20% Off",
"slug": "monsoon-glow-20-off-a1b2c3",
"description": "20% off select salon services this monsoon.",
"offerType": "percentage",
"discountPercentage": 20,
"discountAmountPaise": null,
"comboPricePaise": null,
"startDate": "2026-06-01T00:00:00.000Z",
"endDate": "2026-06-30T00:00:00.000Z",
"isActive": true,
"terms": "One offer per customer per day. Cannot combine with gems.",
"imageUrl": null,
"displayOrder": 0,
"createdAt": "2026-05-28T09:00:00.000Z",
"updatedAt": "2026-05-28T09:00:00.000Z"
}
}
}Errors
GET admin.theroyalglow.in/api/offers/[id]
Returns a single offer with its linked services.
Minimum role: manager (requireRole('manager'))
GET /api/offers/of_3b7e1d9a2cPath parameters
Prop
Type
Response
Same per-offer shape as the list endpoints (offer row plus services,
serviceIds, serviceNames).
{
"success": true,
"data": {
"offer": {
"id": "of_3b7e1d9a2c",
"name": "Monsoon Glow 20% Off",
"slug": "monsoon-glow-20-off-a1b2c3",
"offerType": "percentage",
"discountPercentage": 20,
"discountAmountPaise": null,
"comboPricePaise": null,
"startDate": "2026-06-01T00:00:00.000Z",
"endDate": "2026-06-30T00:00:00.000Z",
"isActive": true,
"services": [{ "id": "svc_haircut001", "name": "Signature Haircut" }],
"serviceIds": ["svc_haircut001"],
"serviceNames": ["Signature Haircut"]
}
}
}Errors
PATCH admin.theroyalglow.in/api/offers/[id]
Updates offer fields or toggles isActive. A bare { "isActive": false } (with
no other fields) deactivates via the dedicated query; any other combination maps
the provided fields (dates converted to Date) and updates. When serviceIds
is supplied, the offer's service set is fully replaced.
Minimum role: manager (requireRole('manager'))
PATCH /api/offers/of_3b7e1d9a2c
Content-Type: application/jsonPath parameters
Prop
Type
Request body
Validated by updateOfferSchema — every field from createOfferSchema is
optional, plus an optional isActive boolean. The conditional discount/date
refinements that apply on create are not re-run here (the base object is made
partial).
Prop
Type
isActive: false alone deactivates the offer.
{
"isActive": false
}{
"name": "Monsoon Glow 25% Off",
"discountPercentage": 25,
"endDate": "2026-07-15"
}Response
Returns the updated offer row.
{
"success": true,
"data": {
"offer": {
"id": "of_3b7e1d9a2c",
"name": "Monsoon Glow 25% Off",
"slug": "monsoon-glow-20-off-a1b2c3",
"offerType": "percentage",
"discountPercentage": 25,
"discountAmountPaise": null,
"comboPricePaise": null,
"startDate": "2026-06-01T00:00:00.000Z",
"endDate": "2026-07-15T00:00:00.000Z",
"isActive": true,
"displayOrder": 0,
"createdAt": "2026-05-28T09:00:00.000Z",
"updatedAt": "2026-06-05T10:00:00.000Z"
}
}
}Errors
Was this page helpful?