Start fixing points
This commit is contained in:
@@ -48,13 +48,13 @@ const BUCKET_CASE = (() => {
|
||||
return `CASE\n ${parts.join('\n ')}\n END`;
|
||||
})();
|
||||
|
||||
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5
|
||||
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1
|
||||
|
||||
const DEFAULTS = {
|
||||
merchantFeePercent: 2.9,
|
||||
fixedCostPerOrder: 1.5,
|
||||
pointsPerDollar: 0,
|
||||
pointsRedemptionRate: 0,
|
||||
pointsRedemptionRate: 0, // Will be calculated from actual data
|
||||
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
||||
};
|
||||
|
||||
@@ -265,8 +265,8 @@ router.post('/simulate', async (req, res) => {
|
||||
${BUCKET_CASE} AS bucket_key
|
||||
FROM _order o
|
||||
${promoJoin}
|
||||
WHERE o.summary_total > 0
|
||||
AND o.summary_subtotal > 0
|
||||
WHERE o.summary_shipping > 0
|
||||
AND o.summary_total > 0
|
||||
AND o.order_status NOT IN (15)
|
||||
AND o.ship_method_selected <> 'holdit'
|
||||
AND o.ship_country = ?
|
||||
@@ -302,7 +302,7 @@ router.post('/simulate', async (req, res) => {
|
||||
GROUP BY order_id
|
||||
) AS c ON c.order_id = f.order_id
|
||||
LEFT JOIN (
|
||||
SELECT order_id, SUM(discount_amount_points) AS points_redeemed
|
||||
SELECT order_id, SUM(discount_amount) AS points_redeemed
|
||||
FROM order_discounts
|
||||
WHERE discount_type = 20 AND discount_active = 1
|
||||
GROUP BY order_id
|
||||
@@ -366,11 +366,54 @@ router.post('/simulate', async (req, res) => {
|
||||
? totals.pointsAwarded / totals.subtotal
|
||||
: 0;
|
||||
|
||||
const redemptionRate = config.points.redemptionRate != null
|
||||
? config.points.redemptionRate
|
||||
: totals.pointsAwarded > 0
|
||||
? Math.min(1, totals.pointsRedeemed / totals.pointsAwarded)
|
||||
: 0;
|
||||
// Calculate redemption rate with extended lookback to account for redemption lag
|
||||
let calculatedRedemptionRate = 0;
|
||||
if (config.points.redemptionRate != null) {
|
||||
calculatedRedemptionRate = config.points.redemptionRate;
|
||||
} else if (totals.pointsAwarded > 0) {
|
||||
// Use a 12-month lookback to capture more realistic redemption patterns
|
||||
const extendedStartDt = startDt.minus({ months: 12 });
|
||||
const extendedRedemptionQuery = `
|
||||
SELECT SUM(od.discount_amount) as extended_redemptions
|
||||
FROM order_discounts od
|
||||
JOIN _order o ON od.order_id = o.order_id
|
||||
WHERE od.discount_type = 20 AND od.discount_active = 1
|
||||
AND o.order_status NOT IN (15)
|
||||
AND o.ship_country = ?
|
||||
AND o.date_placed BETWEEN ? AND ?
|
||||
AND o.order_cid IN (
|
||||
SELECT DISTINCT order_cid
|
||||
FROM _order
|
||||
WHERE date_placed BETWEEN ? AND ?
|
||||
AND order_status NOT IN (15)
|
||||
AND summary_points > 0
|
||||
)
|
||||
`;
|
||||
|
||||
try {
|
||||
const [extendedRows] = await connection.execute(extendedRedemptionQuery, [
|
||||
shipCountry,
|
||||
formatDateForSql(extendedStartDt),
|
||||
formatDateForSql(endDt),
|
||||
formatDateForSql(startDt),
|
||||
formatDateForSql(endDt)
|
||||
]);
|
||||
|
||||
const extendedRedemptions = Number(extendedRows[0]?.extended_redemptions || 0);
|
||||
// Convert dollar redemptions to points using the correct conversion rate (200 points = $1)
|
||||
const extendedRedemptionsInPoints = extendedRedemptions * 200;
|
||||
if (extendedRedemptionsInPoints > 0) {
|
||||
calculatedRedemptionRate = Math.min(1, extendedRedemptionsInPoints / totals.pointsAwarded);
|
||||
} else {
|
||||
throw new Error('Unable to calculate redemption rate: no redemption data found in extended lookback period');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate redemption rate:', error);
|
||||
throw error; // Let it fail instead of using fallback
|
||||
}
|
||||
}
|
||||
|
||||
const redemptionRate = calculatedRedemptionRate;
|
||||
|
||||
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse } from "@/types/discount-simulator";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatCurrency, formatNumber } from "@/utils/productUtils";
|
||||
import { formatNumber } from "@/utils/productUtils";
|
||||
import { PlusIcon, X } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
@@ -44,21 +44,13 @@ interface ConfigPanelProps {
|
||||
pointsPerDollar: number;
|
||||
redemptionRate: number;
|
||||
pointDollarValue: number;
|
||||
onPointsChange: (update: {
|
||||
pointsPerDollar?: number;
|
||||
redemptionRate?: number;
|
||||
pointDollarValue?: number;
|
||||
}) => void;
|
||||
onPointDollarValueChange: (value: number) => void;
|
||||
onConfigInputChange: () => void;
|
||||
onResetConfig: () => void;
|
||||
onRunSimulation: () => void;
|
||||
isRunning: boolean;
|
||||
recommendedPoints?: {
|
||||
pointsPerDollar: number;
|
||||
redemptionRate: number;
|
||||
pointDollarValue: number;
|
||||
};
|
||||
onApplyRecommendedPoints?: () => void;
|
||||
recommendedPointDollarValue?: number;
|
||||
onApplyRecommendedPointDollarValue?: () => void;
|
||||
result?: DiscountSimulationResponse;
|
||||
}
|
||||
|
||||
@@ -116,14 +108,14 @@ export function ConfigPanel({
|
||||
pointsPerDollar,
|
||||
redemptionRate,
|
||||
pointDollarValue,
|
||||
onPointsChange,
|
||||
onPointDollarValueChange,
|
||||
onConfigInputChange,
|
||||
onResetConfig,
|
||||
promoLoading = false,
|
||||
onRunSimulation,
|
||||
isRunning,
|
||||
recommendedPoints,
|
||||
onApplyRecommendedPoints,
|
||||
recommendedPointDollarValue,
|
||||
onApplyRecommendedPointDollarValue,
|
||||
result
|
||||
}: ConfigPanelProps) {
|
||||
const promoOptions = useMemo(() => {
|
||||
@@ -213,10 +205,8 @@ export function ConfigPanel({
|
||||
onShippingTiersChange(tiers);
|
||||
};
|
||||
|
||||
const recommendedDiffers = recommendedPoints
|
||||
? recommendedPoints.pointsPerDollar !== pointsPerDollar ||
|
||||
recommendedPoints.redemptionRate !== redemptionRate ||
|
||||
recommendedPoints.pointDollarValue !== pointDollarValue
|
||||
const recommendedDiffers = typeof recommendedPointDollarValue === 'number'
|
||||
? recommendedPointDollarValue !== pointDollarValue
|
||||
: false;
|
||||
|
||||
const fieldClass = "flex flex-col gap-1.5";
|
||||
@@ -230,6 +220,7 @@ export function ConfigPanel({
|
||||
const compactWideNumberClass = "h-8 px-2 text-sm";
|
||||
const showProductAdjustments = productPromo.type !== "none";
|
||||
const showShippingAdjustments = shippingPromo.type !== "none";
|
||||
const promoSelectValue = selectedPromoId != null ? selectedPromoId.toString() : "__all__";
|
||||
|
||||
const handleFieldBlur = useCallback(() => {
|
||||
setTimeout(onRunSimulation, 0);
|
||||
@@ -252,7 +243,7 @@ export function ConfigPanel({
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Promo code</Label>
|
||||
<Select
|
||||
value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined}
|
||||
value={promoSelectValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__all__') {
|
||||
onPromoChange(undefined);
|
||||
@@ -297,15 +288,6 @@ export function ConfigPanel({
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatPercent(result.totals.productDiscountRate)} avg discount
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{result.totals.pointsPerDollar.toFixed(4)} pts/$
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatPercent(result.totals.redemptionRate)} redeemed
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatCurrency(result.totals.pointDollarValue, 4)} pt value
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -568,34 +550,14 @@ export function ConfigPanel({
|
||||
<section className={sectionClass}>
|
||||
<span className={sectionTitleClass}>Rewards points</span>
|
||||
<div className={fieldRowClass}>
|
||||
<div className={fieldRowHorizontalClass}>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Points per $</Label>
|
||||
<Input
|
||||
className={compactNumberClass}
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={pointsPerDollar}
|
||||
onChange={(event) => {
|
||||
onConfigInputChange();
|
||||
onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) });
|
||||
}}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>Points per $</span>
|
||||
<span className="text-sm font-medium">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
|
||||
</div>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Redemption rate (%)</Label>
|
||||
<Input
|
||||
className={compactNumberClass}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={redemptionRate * 100}
|
||||
onChange={(event) => {
|
||||
onConfigInputChange();
|
||||
onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 });
|
||||
}}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>Redemption rate (%)</span>
|
||||
<span className="text-sm font-medium">{formatPercent(redemptionRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={fieldClass}>
|
||||
@@ -607,15 +569,15 @@ export function ConfigPanel({
|
||||
value={pointDollarValue}
|
||||
onChange={(event) => {
|
||||
onConfigInputChange();
|
||||
onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) });
|
||||
onPointDollarValueChange(parseNumber(event.target.value, pointDollarValue));
|
||||
}}
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
</div>
|
||||
{recommendedPoints && (
|
||||
{typeof recommendedPointDollarValue === 'number' && (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{recommendedDiffers && onApplyRecommendedPoints && (
|
||||
<Button variant="outline" size="sm" onClick={onApplyRecommendedPoints}>
|
||||
{recommendedDiffers && onApplyRecommendedPointDollarValue && (
|
||||
<Button variant="outline" size="sm" onClick={onApplyRecommendedPointDollarValue}>
|
||||
Use recommended
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -57,12 +57,8 @@ export function DiscountSimulator() {
|
||||
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
|
||||
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
|
||||
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
|
||||
const [pointsConfig, setPointsConfig] = useState({
|
||||
pointsPerDollar: 0,
|
||||
redemptionRate: 0,
|
||||
pointDollarValue: DEFAULT_POINT_VALUE,
|
||||
});
|
||||
const [pointsTouched, setPointsTouched] = useState(false);
|
||||
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
||||
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
||||
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
||||
@@ -134,6 +130,12 @@ export function DiscountSimulator() {
|
||||
const createPayload = useCallback((): DiscountSimulationRequest => {
|
||||
const { from, to } = ensureDateRange(dateRange);
|
||||
|
||||
const payloadPointsConfig = {
|
||||
pointsPerDollar: null,
|
||||
redemptionRate: null,
|
||||
pointDollarValue,
|
||||
};
|
||||
|
||||
return {
|
||||
dateRange: {
|
||||
start: startOfDay(from).toISOString(),
|
||||
@@ -153,11 +155,7 @@ export function DiscountSimulator() {
|
||||
}),
|
||||
merchantFeePercent,
|
||||
fixedCostPerOrder,
|
||||
pointsConfig: {
|
||||
pointsPerDollar: pointsConfig.pointsPerDollar,
|
||||
redemptionRate: pointsConfig.redemptionRate,
|
||||
pointDollarValue: pointsConfig.pointDollarValue,
|
||||
},
|
||||
pointsConfig: payloadPointsConfig,
|
||||
};
|
||||
}, [
|
||||
dateRange,
|
||||
@@ -168,7 +166,7 @@ export function DiscountSimulator() {
|
||||
shippingTiers,
|
||||
merchantFeePercent,
|
||||
fixedCostPerOrder,
|
||||
pointsConfig,
|
||||
pointDollarValue,
|
||||
]);
|
||||
|
||||
const simulationMutation = useMutation<
|
||||
@@ -193,25 +191,14 @@ export function DiscountSimulator() {
|
||||
|
||||
setSimulationResult(data);
|
||||
|
||||
if (!pointsTouched) {
|
||||
if (!pointDollarTouched && typeof data.totals.pointDollarValue === 'number') {
|
||||
const incomingValue = data.totals.pointDollarValue;
|
||||
if (pointDollarValue !== incomingValue) {
|
||||
skipAutoRunRef.current = true;
|
||||
setPointsConfig((prev) => {
|
||||
if (
|
||||
prev.pointsPerDollar === data.totals.pointsPerDollar &&
|
||||
prev.redemptionRate === data.totals.redemptionRate &&
|
||||
prev.pointDollarValue === data.totals.pointDollarValue
|
||||
) {
|
||||
setPointDollarValue(incomingValue);
|
||||
} else {
|
||||
skipAutoRunRef.current = false;
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
pointsPerDollar: data.totals.pointsPerDollar,
|
||||
redemptionRate: data.totals.redemptionRate,
|
||||
pointDollarValue: data.totals.pointDollarValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -259,7 +246,12 @@ export function DiscountSimulator() {
|
||||
shippingTiers?: ShippingTierConfig[];
|
||||
merchantFeePercent?: number;
|
||||
fixedCostPerOrder?: number;
|
||||
pointsConfig?: typeof pointsConfig;
|
||||
pointsConfig?: {
|
||||
pointsPerDollar?: number | null;
|
||||
redemptionRate?: number | null;
|
||||
pointDollarValue?: number | null;
|
||||
};
|
||||
pointDollarValue?: number;
|
||||
};
|
||||
|
||||
skipAutoRunRef.current = true;
|
||||
@@ -300,9 +292,14 @@ export function DiscountSimulator() {
|
||||
setFixedCostPerOrder(parsed.fixedCostPerOrder);
|
||||
}
|
||||
|
||||
if (parsed.pointsConfig) {
|
||||
setPointsConfig(parsed.pointsConfig);
|
||||
setPointsTouched(true);
|
||||
if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') {
|
||||
setPointDollarValue(parsed.pointsConfig.pointDollarValue);
|
||||
setPointDollarTouched(true);
|
||||
}
|
||||
|
||||
if (typeof parsed.pointDollarValue === 'number') {
|
||||
setPointDollarValue(parsed.pointDollarValue);
|
||||
setPointDollarTouched(true);
|
||||
}
|
||||
|
||||
setLoadedFromStorage(true);
|
||||
@@ -331,9 +328,9 @@ export function DiscountSimulator() {
|
||||
shippingTiers,
|
||||
merchantFeePercent,
|
||||
fixedCostPerOrder,
|
||||
pointsConfig,
|
||||
pointDollarValue,
|
||||
});
|
||||
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]);
|
||||
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointDollarValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoadedConfig) {
|
||||
@@ -379,26 +376,19 @@ export function DiscountSimulator() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadedFromStorage, runSimulation]);
|
||||
|
||||
const recommendedPoints = simulationResult?.totals
|
||||
? {
|
||||
pointsPerDollar: simulationResult.totals.pointsPerDollar,
|
||||
redemptionRate: simulationResult.totals.redemptionRate,
|
||||
pointDollarValue: simulationResult.totals.pointDollarValue,
|
||||
}
|
||||
: undefined;
|
||||
const currentPointsPerDollar = simulationResult?.totals?.pointsPerDollar ?? 0;
|
||||
const currentRedemptionRate = simulationResult?.totals?.redemptionRate ?? 0;
|
||||
const recommendedPointDollarValue = simulationResult?.totals?.pointDollarValue;
|
||||
|
||||
const handlePointsChange = (update: Partial<typeof pointsConfig>) => {
|
||||
setPointsTouched(true);
|
||||
setPointsConfig((prev) => ({
|
||||
...prev,
|
||||
...update,
|
||||
}));
|
||||
const handlePointDollarValueChange = (value: number) => {
|
||||
setPointDollarTouched(true);
|
||||
setPointDollarValue(value);
|
||||
};
|
||||
|
||||
const handleApplyRecommendedPoints = () => {
|
||||
if (recommendedPoints) {
|
||||
setPointsConfig(recommendedPoints);
|
||||
setPointsTouched(true);
|
||||
const handleApplyRecommendedPointValue = () => {
|
||||
if (typeof recommendedPointDollarValue === 'number') {
|
||||
setPointDollarValue(recommendedPointDollarValue);
|
||||
setPointDollarTouched(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -413,12 +403,8 @@ export function DiscountSimulator() {
|
||||
setShippingTiers([]);
|
||||
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
|
||||
setFixedCostPerOrder(DEFAULT_FIXED_COST);
|
||||
setPointsConfig({
|
||||
pointsPerDollar: 0,
|
||||
redemptionRate: 0,
|
||||
pointDollarValue: DEFAULT_POINT_VALUE,
|
||||
});
|
||||
setPointsTouched(false);
|
||||
setPointDollarValue(DEFAULT_POINT_VALUE);
|
||||
setPointDollarTouched(false);
|
||||
setSimulationResult(undefined);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -463,16 +449,16 @@ export function DiscountSimulator() {
|
||||
onMerchantFeeChange={setMerchantFeePercent}
|
||||
fixedCostPerOrder={fixedCostPerOrder}
|
||||
onFixedCostChange={setFixedCostPerOrder}
|
||||
pointsPerDollar={pointsConfig.pointsPerDollar}
|
||||
redemptionRate={pointsConfig.redemptionRate}
|
||||
pointDollarValue={pointsConfig.pointDollarValue}
|
||||
onPointsChange={handlePointsChange}
|
||||
pointsPerDollar={currentPointsPerDollar}
|
||||
redemptionRate={currentRedemptionRate}
|
||||
pointDollarValue={pointDollarValue}
|
||||
onPointDollarValueChange={handlePointDollarValueChange}
|
||||
onConfigInputChange={handleConfigInputChange}
|
||||
onResetConfig={resetConfig}
|
||||
onRunSimulation={() => runSimulation()}
|
||||
isRunning={isSimulating}
|
||||
recommendedPoints={recommendedPoints}
|
||||
onApplyRecommendedPoints={handleApplyRecommendedPoints}
|
||||
recommendedPointDollarValue={recommendedPointDollarValue}
|
||||
onApplyRecommendedPointDollarValue={handleApplyRecommendedPointValue}
|
||||
result={simulationResult}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +90,8 @@ export interface DiscountSimulationRequest {
|
||||
merchantFeePercent: number;
|
||||
fixedCostPerOrder: number;
|
||||
pointsConfig: {
|
||||
pointsPerDollar: number;
|
||||
redemptionRate: number;
|
||||
pointDollarValue: number;
|
||||
pointsPerDollar: number | null;
|
||||
redemptionRate: number | null;
|
||||
pointDollarValue: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user