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:
Stefan Lazic committed 2026-08-05 09:25:59 +01:00
1 parent 9e39908ea7
commit d3bbad09c6
74 files changed
+1622 -195

No files matched your search

+49 -7
View File
@@ -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 } },