130 lines
4.8 KiB
TypeScript
130 lines
4.8 KiB
TypeScript
import type {ActivityEvent, TimePoint} from '@/features/dashboard/types/dashboard';
|
|
import type {Reward, RewardStatus} from '@/features/lyts/types/reward';
|
|
import type {RangeKey} from '@/shared/types/api';
|
|
import {createRng} from '@/shared/mock/rng';
|
|
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
|
|
|
|
/**
|
|
* The reward catalogue.
|
|
*
|
|
* `usageRate` is fixed per reward rather than random, because the story this
|
|
* module has to tell is that redemption rates differ WILDLY by offer type —
|
|
* Buy-1-Get-1 is claimed constantly and redeemed rarely, a free coffee is
|
|
* claimed and used almost immediately. A catalogue where everything converts
|
|
* at ~50% would make "most claimed" and "least used" the same reward and the
|
|
* whole module pointless.
|
|
*
|
|
* 1 LYT = ₹1, so costLyt doubles as the rupee liability per redemption.
|
|
*/
|
|
const CATALOGUE: {
|
|
id: string;
|
|
name: string;
|
|
costLyt: number;
|
|
usageRate: number;
|
|
status: RewardStatus;
|
|
/** Days from now; null never expires. */
|
|
expiresInDays: number | null;
|
|
}[] = [
|
|
{id: 'r-coffee', name: 'Free Coffee', costLyt: 120, usageRate: 0.72, status: 'active', expiresInDays: 34},
|
|
{id: 'r-combo20', name: 'Combo 20% off', costLyt: 200, usageRate: 0.34, status: 'expiring', expiresInDays: 2},
|
|
{id: 'r-weekend', name: 'Weekend Bonus', costLyt: 150, usageRate: 0.81, status: 'expiring', expiresInDays: 4},
|
|
{id: 'r-birthday', name: 'Birthday Treat', costLyt: 250, usageRate: 0.55, status: 'active', expiresInDays: null},
|
|
{id: 'r-bogo', name: 'Buy 1 Get 1', costLyt: 400, usageRate: 0.19, status: 'active', expiresInDays: 61},
|
|
{id: 'r-dessert', name: 'Dessert on us', costLyt: 90, usageRate: 0.44, status: 'paused', expiresInDays: 20},
|
|
{id: 'r-firstvisit', name: 'First Visit 10%', costLyt: 100, usageRate: 0.63, status: 'active', expiresInDays: 90},
|
|
{id: 'r-loyalty500', name: 'Loyalty 500', costLyt: 500, usageRate: 0.28, status: 'expired', expiresInDays: -6},
|
|
];
|
|
|
|
export function buildRewards(
|
|
storeId: string,
|
|
range: RangeKey,
|
|
endMs: number,
|
|
): Reward[] {
|
|
const scoped = storeId === 'all' ? 1 : 0.28;
|
|
|
|
return CATALOGUE.map((r) => {
|
|
const rng = createRng('reward', r.id, storeId, range);
|
|
const claimed = Math.round(rng.int(140, 900) * scoped);
|
|
const used = Math.round(claimed * r.usageRate * rng.float(0.92, 1.08));
|
|
|
|
return {
|
|
id: r.id,
|
|
name: r.name,
|
|
costLyt: r.costLyt,
|
|
claimed,
|
|
used: Math.min(used, claimed),
|
|
expiresAt:
|
|
r.expiresInDays === null
|
|
? null
|
|
: new Date(endMs + r.expiresInDays * 86400000).toISOString(),
|
|
status: r.status,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Redemptions over time.
|
|
*
|
|
* Derived from the shared timeseries so redemption volume tracks footfall —
|
|
* an independent generator would produce a redemption curve that contradicts
|
|
* the store's own traffic, which is immediately obvious to anyone who knows
|
|
* their business.
|
|
*/
|
|
export function buildRedemptions(
|
|
storeId: string,
|
|
range: RangeKey,
|
|
endMs: number,
|
|
): TimePoint[] {
|
|
const points = buildTimeseries(range, storeId, endMs);
|
|
const rng = createRng('redemptions', storeId, range);
|
|
|
|
return points.map((p) => {
|
|
// Roughly a third of purchases involve a redemption.
|
|
const redeemed = Math.round(p.purchases * rng.float(0.24, 0.38));
|
|
const issued = Math.round(redeemed * rng.float(1.5, 2.4));
|
|
return {
|
|
t: p.t,
|
|
// Reusing TimePoint's shape: visitors→issued, purchases→redeemed so the
|
|
// existing chart primitives apply unchanged.
|
|
visitors: issued,
|
|
purchases: redeemed,
|
|
revenue: redeemed * 140,
|
|
conversion: issued ? Number(((redeemed / issued) * 100).toFixed(1)) : 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
const LYT_ACTIVITY: {
|
|
kind: ActivityEvent['kind'];
|
|
title: string;
|
|
detail: string;
|
|
}[] = [
|
|
{kind: 'reward_redeemed', title: 'Free Coffee redeemed', detail: '120 LYTs · Indiranagar Flagship'},
|
|
{kind: 'reward_redeemed', title: 'Weekend Bonus redeemed', detail: '150 LYTs · Koramangala'},
|
|
{kind: 'reward_expired', title: 'Loyalty 500 expired', detail: 'Claimed 310 times · used 87'},
|
|
{kind: 'reward_redeemed', title: 'Dessert on us redeemed', detail: '90 LYTs · Anna Nagar'},
|
|
{kind: 'purchase', title: 'Combo 20% applied at checkout', detail: '₹640 basket · Jubilee Hills'},
|
|
];
|
|
|
|
export function buildLytActivity(
|
|
storeId: string,
|
|
endMs: number,
|
|
): ActivityEvent[] {
|
|
const rng = createRng('lyt-activity', storeId);
|
|
// Accumulated, not redrawn per index — same ordering bug as buildActivity.
|
|
let minutesAgo = 0;
|
|
|
|
return Array.from({length: 24}, (_, i) => {
|
|
const tpl = LYT_ACTIVITY[i % LYT_ACTIVITY.length];
|
|
if (i > 0) minutesAgo += rng.int(8, 50);
|
|
return {
|
|
id: `l${i}`,
|
|
at: new Date(endMs - minutesAgo * 60000).toISOString(),
|
|
kind: tpl.kind,
|
|
title: tpl.title,
|
|
detail: tpl.detail,
|
|
storeId,
|
|
};
|
|
});
|
|
}
|