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,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 } },
|
||||
|
||||
Reference in new issue
Block a user