Fixes
Fixed an issue where media manager only shows files uploaded in specific sections. Fixed an issue with icon picker names. Removed jumbo rounding and a few other style tweaks.
This commit is contained in:
74 files changed
+1622
-195
No files matched your search
@@ -1,16 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateProgrammeConfig } from "../loyalty.service";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import { resolveRewardThreshold, validateProgrammeConfig } from "../loyalty.service";
|
||||
import type { ProgrammeConfigInput } from "../loyalty.service";
|
||||
|
||||
const base: ProgrammeConfigInput = {
|
||||
isEnabled: true,
|
||||
stampsRequired: 10,
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "50",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||
};
|
||||
|
||||
describe("validateProgrammeConfig", () => {
|
||||
@@ -63,3 +67,21 @@ describe("validateProgrammeConfig", () => {
|
||||
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRewardThreshold", () => {
|
||||
it("AFTER_ALL_STAMPS requires every position filled", () => {
|
||||
expect(resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS })).toBe(10);
|
||||
});
|
||||
|
||||
it("FINAL_SLOT_IS_REWARD is earned one stamp early — the last position is the reward, not a stamp to collect", () => {
|
||||
expect(
|
||||
resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
|
||||
).toBe(9);
|
||||
});
|
||||
|
||||
it("clamps FINAL_SLOT_IS_REWARD at a minimum threshold of 1, even for a single-stamp card", () => {
|
||||
expect(
|
||||
resolveRewardThreshold({ stampsRequired: 1, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import {
|
||||
expireOverdueRewards,
|
||||
redeemReward,
|
||||
@@ -32,12 +33,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
input: {
|
||||
isEnabled: true,
|
||||
stampsRequired: 8,
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "25",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||
},
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
@@ -50,19 +54,22 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
businessId: business.businessId,
|
||||
input: {
|
||||
isEnabled: true,
|
||||
stampsRequired: 12,
|
||||
stampsRequired: 10, // max allowed — see validateProgrammeConfig's 1-10 range (Phase 10)
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "PERCENTAGE",
|
||||
rewardValue: "40",
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||
},
|
||||
});
|
||||
expect(second.success).toBe(true);
|
||||
|
||||
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
|
||||
expect(updated.stampsRequired).toBe(12);
|
||||
expect(updated.stampsRequired).toBe(10);
|
||||
expect(Number(updated.rewardValue)).toBe(40);
|
||||
});
|
||||
|
||||
@@ -77,12 +84,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
||||
input: {
|
||||
isEnabled: true,
|
||||
stampsRequired: 10,
|
||||
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: "FREE_SERVICE",
|
||||
freeServiceId: inactiveService.id,
|
||||
autoApplyReward: true,
|
||||
requiresStaffApproval: false,
|
||||
earnOnCancelled: false,
|
||||
earnOnNoShow: false,
|
||||
cardFont: LoyaltyCardFont.SANS,
|
||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RewardType } from "@/generated/prisma/enums";
|
||||
import { RewardPositionMode, RewardType } from "@/generated/prisma/enums";
|
||||
import {
|
||||
addStamp,
|
||||
awardStampForCompletedAppointment,
|
||||
correctStamp,
|
||||
getLoyaltyHistory,
|
||||
getLoyaltySummary,
|
||||
redeemReward,
|
||||
} from "../loyalty.service";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
@@ -24,12 +25,13 @@ import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
describe("LoyaltyService (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
async function enableLoyalty(overrides: { stampsRequired?: number } = {}) {
|
||||
async function enableLoyalty(overrides: { stampsRequired?: number; rewardPosition?: RewardPositionMode } = {}) {
|
||||
return prisma.loyaltyProgramme.create({
|
||||
data: {
|
||||
businessId: business.businessId,
|
||||
isEnabled: true,
|
||||
stampsRequired: overrides.stampsRequired ?? 5,
|
||||
rewardPosition: overrides.rewardPosition ?? RewardPositionMode.AFTER_ALL_STAMPS,
|
||||
rewardType: RewardType.PERCENTAGE,
|
||||
rewardValue: 50,
|
||||
rewardLabel: "50% Off",
|
||||
@@ -144,6 +146,67 @@ describe("LoyaltyService (integration)", () => {
|
||||
expect(account.currentStamps).toBe(5);
|
||||
});
|
||||
|
||||
describe("reward position modes", () => {
|
||||
it("FINAL_SLOT_IS_REWARD earns the reward one stamp early via a manual add — the same threshold AFTER_ALL_STAMPS would need one more stamp to cross", async () => {
|
||||
const programme = await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
const shortOfAfterAllStamps = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
|
||||
expect(shortOfAfterAllStamps.rewardCreated).toBe(true); // 2 stamps == threshold (stampsRequired - 1)
|
||||
|
||||
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||
});
|
||||
expect(account.currentStamps).toBe(2);
|
||||
});
|
||||
|
||||
it("FINAL_SLOT_IS_REWARD uses the same threshold for the automatic completed-appointment path as for a manual add", async () => {
|
||||
await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
|
||||
// One manual stamp, then one automatic one from a completed appointment — together they
|
||||
// cross the threshold (2), and both paths must agree that's enough to earn the reward.
|
||||
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
|
||||
const appointment = await completedAppointment(staffId, customerId);
|
||||
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
|
||||
|
||||
expect(result.rewardCreated).toBe(true);
|
||||
});
|
||||
|
||||
it("redemption deducts exactly the earning threshold, not the raw stampsRequired total, in FINAL_SLOT_IS_REWARD mode", async () => {
|
||||
const programme = await enableLoyalty({ stampsRequired: 4, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
|
||||
const { staffId } = await createTestStaff(business);
|
||||
const { customerId } = await createTestCustomer(business);
|
||||
const appointment = await completedAppointment(staffId, customerId);
|
||||
|
||||
// 3 stamps == threshold (stampsRequired - 1) — earns the reward without ever reaching 4.
|
||||
const added = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
|
||||
expect(added.rewardCreated).toBe(true);
|
||||
|
||||
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||
});
|
||||
const reward = await prisma.loyaltyReward.findFirstOrThrow({
|
||||
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||
});
|
||||
|
||||
const redemption = await redeemReward(prisma, {
|
||||
rewardId: reward.id,
|
||||
appointmentId: appointment.id,
|
||||
staffId,
|
||||
businessId: business.businessId,
|
||||
});
|
||||
expect(redemption.success).toBe(true);
|
||||
|
||||
const afterRedemption = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
|
||||
// Deducting the raw stampsRequired (4) from a balance of 3 would have gone negative —
|
||||
// clampedDeduction alone would mask the bug. Deducting the threshold (3) leaves exactly 0.
|
||||
expect(afterRedemption.currentStamps).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
|
||||
// No enableLoyalty() call — business has no loyalty programme at all.
|
||||
const { staffId } = await createTestStaff(business);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
|
||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||
import { fail, ok } from "@/lib/validation-result";
|
||||
import type { ValidationResult } from "@/lib/validation-result";
|
||||
import {
|
||||
@@ -7,6 +8,9 @@ import {
|
||||
notifyLoyaltyRewardRedeemed,
|
||||
notifyLoyaltyStampAdded,
|
||||
} from "./loyalty-notifications";
|
||||
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
|
||||
|
||||
export { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
|
||||
|
||||
/**
|
||||
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
|
||||
@@ -45,11 +49,19 @@ export interface LoyaltySummary {
|
||||
accountId: string | null;
|
||||
currentStamps: number;
|
||||
stampsRequired: number;
|
||||
rewardPosition: RewardPositionMode;
|
||||
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
|
||||
rewardLabel: string | null;
|
||||
stampIcon: string | null;
|
||||
cardBackgroundColor: string | null;
|
||||
cardBackgroundImageUrl: string | null;
|
||||
termsLine: string | null;
|
||||
cardTopRightText: string | null;
|
||||
cardFont: LoyaltyCardFont;
|
||||
cardTopRightTextSize: LoyaltyCardTextSize;
|
||||
cardTopRightTextColor: string | null;
|
||||
cardBorderColor: string | null;
|
||||
cardMemberNumberColor: string | null;
|
||||
}
|
||||
|
||||
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
|
||||
@@ -79,6 +91,7 @@ export async function getLoyaltySummary(
|
||||
accountId: account?.id ?? null,
|
||||
currentStamps: account?.currentStamps ?? 0,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
rewardPosition: programme.rewardPosition,
|
||||
availableReward: account?.rewards[0]
|
||||
? {
|
||||
id: account.rewards[0].id,
|
||||
@@ -91,6 +104,13 @@ export async function getLoyaltySummary(
|
||||
stampIcon: programme.stampIcon,
|
||||
cardBackgroundColor: programme.cardBackgroundColor,
|
||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
|
||||
termsLine: programme.termsLine,
|
||||
cardTopRightText: programme.cardTopRightText,
|
||||
cardFont: programme.cardFont,
|
||||
cardTopRightTextSize: programme.cardTopRightTextSize,
|
||||
cardTopRightTextColor: programme.cardTopRightTextColor,
|
||||
cardBorderColor: programme.cardBorderColor,
|
||||
cardMemberNumberColor: programme.cardMemberNumberColor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +144,8 @@ export async function getAvailableRewardFlags(
|
||||
|
||||
export interface CustomerLoyaltyBrief {
|
||||
currentStamps: number;
|
||||
stampsRequired: number;
|
||||
/** How many stamps actually earn the reward — see resolveRewardThreshold. */
|
||||
rewardThreshold: number;
|
||||
hasAvailableReward: boolean;
|
||||
}
|
||||
|
||||
@@ -149,12 +170,13 @@ export async function getLoyaltySummariesForCustomers(
|
||||
},
|
||||
});
|
||||
|
||||
const rewardThreshold = resolveRewardThreshold(programme);
|
||||
return new Map(
|
||||
accounts.map((a) => [
|
||||
a.customerId,
|
||||
{
|
||||
currentStamps: a.currentStamps,
|
||||
stampsRequired: programme.stampsRequired,
|
||||
rewardThreshold,
|
||||
hasAvailableReward: a.rewards.length > 0,
|
||||
},
|
||||
]),
|
||||
@@ -404,7 +426,7 @@ async function addStampTransaction(
|
||||
|
||||
let rewardCreated = false;
|
||||
let rewardId: string | undefined;
|
||||
if (account.currentStamps >= params.programme.stampsRequired) {
|
||||
if (account.currentStamps >= resolveRewardThreshold(params.programme)) {
|
||||
const existingAvailable = await tx.loyaltyReward.findFirst({
|
||||
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||
});
|
||||
@@ -506,6 +528,8 @@ export async function getLoyaltyHistory(
|
||||
export interface ProgrammeConfigInput {
|
||||
isEnabled: boolean;
|
||||
stampsRequired: number;
|
||||
/** See resolveRewardThreshold's doc comment for what each mode means. */
|
||||
rewardPosition: RewardPositionMode;
|
||||
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
|
||||
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
|
||||
rewardValue?: string;
|
||||
@@ -522,12 +546,19 @@ export interface ProgrammeConfigInput {
|
||||
cardBackgroundImageUrl?: string;
|
||||
stampIcon?: string;
|
||||
termsLine?: string;
|
||||
/** Free text shown in the card's top-right area — see schema.prisma's cardTopRightText doc comment. */
|
||||
cardTopRightText?: string;
|
||||
cardFont: LoyaltyCardFont;
|
||||
cardTopRightTextSize: LoyaltyCardTextSize;
|
||||
cardTopRightTextColor?: string;
|
||||
cardBorderColor?: string;
|
||||
cardMemberNumberColor?: string;
|
||||
}
|
||||
|
||||
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
|
||||
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
|
||||
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
|
||||
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
|
||||
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1 || input.stampsRequired > MAX_STAMPS_REQUIRED) {
|
||||
return fail({ code: "INVALID_INPUT", message: `Stamps required must be between 1 and ${MAX_STAMPS_REQUIRED}.` });
|
||||
}
|
||||
if (!input.isEnabled) return ok();
|
||||
|
||||
@@ -580,6 +611,7 @@ export async function updateProgrammeConfig(
|
||||
const data = {
|
||||
isEnabled: params.input.isEnabled,
|
||||
stampsRequired: params.input.stampsRequired,
|
||||
rewardPosition: params.input.rewardPosition,
|
||||
rewardType: params.input.rewardType,
|
||||
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
|
||||
rewardLabel: params.input.rewardLabel?.trim() || null,
|
||||
@@ -595,6 +627,12 @@ export async function updateProgrammeConfig(
|
||||
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
|
||||
stampIcon: params.input.stampIcon || null,
|
||||
termsLine: params.input.termsLine?.trim() || null,
|
||||
cardTopRightText: params.input.cardTopRightText?.trim() || null,
|
||||
cardFont: params.input.cardFont,
|
||||
cardTopRightTextSize: params.input.cardTopRightTextSize,
|
||||
cardTopRightTextColor: params.input.cardTopRightTextColor || null,
|
||||
cardBorderColor: params.input.cardBorderColor || null,
|
||||
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
|
||||
};
|
||||
|
||||
await prisma.loyaltyProgramme.upsert({
|
||||
@@ -745,7 +783,11 @@ export async function redeemReward(
|
||||
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
|
||||
const discountAmountString = discountAmount.toFixed(2);
|
||||
|
||||
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
|
||||
// Deduct however many stamps actually earned this reward, not the raw
|
||||
// stampsRequired total — in FINAL_SLOT_IS_REWARD mode those aren't the
|
||||
// same number (see resolveRewardThreshold), and the ledger must only
|
||||
// ever remove what was really collected.
|
||||
const rewardThreshold = resolveRewardThreshold(reward.loyaltyAccount.loyaltyProgramme);
|
||||
|
||||
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.loyaltyReward.updateMany({
|
||||
@@ -760,7 +802,7 @@ export async function redeemReward(
|
||||
if (updated.count === 0) return { alreadyRedeemed: true };
|
||||
|
||||
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
|
||||
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
|
||||
const clampedDeduction = Math.min(rewardThreshold, account.currentStamps);
|
||||
await tx.loyaltyAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { currentStamps: { decrement: clampedDeduction } },
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { LoyaltyProgramme } from "@/generated/prisma/client";
|
||||
import { RewardPositionMode } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Stamps required for a reward is capped at 10 (Phase 10 follow-up brief) —
|
||||
* mirrored by the LoyaltyProgramme_stampsRequired_range CHECK constraint,
|
||||
* validateProgrammeConfig, and the owner form's own client-side validation.
|
||||
* Lives here (not loyalty.service.ts) for the same reason
|
||||
* resolveRewardThreshold does — see that function's doc comment.
|
||||
*/
|
||||
export const MAX_STAMPS_REQUIRED = 10;
|
||||
|
||||
/**
|
||||
* The single place "how many stamps actually earn the reward" is decided —
|
||||
* every caller that needs to know (auto/manual stamp awarding, redemption,
|
||||
* progress text, the customer card, the owner's live preview) reads through
|
||||
* this rather than each re-deriving its own answer, so they can't disagree.
|
||||
* Deliberately its own tiny, side-effect-free module (not defined inline in
|
||||
* loyalty.service.ts) so client components — the customer card, the owner's
|
||||
* live preview — can import this one pure function without pulling in the
|
||||
* rest of that file's Prisma-calling service code.
|
||||
*
|
||||
* AFTER_ALL_STAMPS: every one of `stampsRequired` positions is a normal
|
||||
* stamp; the reward unlocks once all of them are filled — threshold ==
|
||||
* stampsRequired.
|
||||
*
|
||||
* FINAL_SLOT_IS_REWARD: the last of the `stampsRequired` positions on the
|
||||
* card *is* the reward, not a stamp to collect — so it's earned one stamp
|
||||
* early, at stampsRequired - 1. (Clamped at 1 so a stampsRequired of 1 can
|
||||
* never produce a threshold of 0.)
|
||||
*/
|
||||
export function resolveRewardThreshold(
|
||||
programme: Pick<LoyaltyProgramme, "stampsRequired" | "rewardPosition">,
|
||||
): number {
|
||||
return programme.rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD
|
||||
? Math.max(1, programme.stampsRequired - 1)
|
||||
: programme.stampsRequired;
|
||||
}
|
||||
@@ -218,6 +218,7 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
|
||||
const validation = validateProgrammeConfig({
|
||||
isEnabled: loyaltyProgramme.isEnabled,
|
||||
stampsRequired: loyaltyProgramme.stampsRequired,
|
||||
rewardPosition: loyaltyProgramme.rewardPosition,
|
||||
rewardType: loyaltyProgramme.rewardType,
|
||||
rewardValue: loyaltyProgramme.rewardValue?.toString(),
|
||||
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
|
||||
@@ -225,6 +226,8 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
|
||||
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
|
||||
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
||||
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
||||
cardFont: loyaltyProgramme.cardFont,
|
||||
cardTopRightTextSize: loyaltyProgramme.cardTopRightTextSize,
|
||||
});
|
||||
if (!validation.valid) {
|
||||
warnings.push({
|
||||
|
||||
Reference in new issue
Block a user