4 Commits

Author SHA1 Message Date
1696ecf591 Redemption rate part 3 + update cogs options 2025-09-26 00:11:09 -04:00
dc774862a7 Fix redemption rate part 2 2025-09-25 22:41:44 -04:00
d3e3cba087 Start fixing points 2025-09-25 21:27:28 -04:00
4ea3a4aec3 Fix promo codes 2025-09-25 14:51:34 -04:00
4 changed files with 244 additions and 180 deletions

View File

@@ -48,13 +48,13 @@ const BUCKET_CASE = (() => {
return `CASE\n ${parts.join('\n ')}\n END`; 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 = { const DEFAULTS = {
merchantFeePercent: 2.9, merchantFeePercent: 2.9,
fixedCostPerOrder: 1.5, fixedCostPerOrder: 1.5,
pointsPerDollar: 0, pointsPerDollar: 0,
pointsRedemptionRate: 0, pointsRedemptionRate: 0, // Will be calculated from actual data
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE, pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
}; };
@@ -165,6 +165,7 @@ router.post('/simulate', async (req, res) => {
shippingTiers = [], shippingTiers = [],
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode = 'actual',
pointsConfig = {} pointsConfig = {}
} = req.body || {}; } = req.body || {};
@@ -174,7 +175,25 @@ router.post('/simulate', async (req, res) => {
const endDt = parseDate(dateRange.end, endDefault).endOf('day'); const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const shipCountry = filters.shipCountry || 'US'; const shipCountry = filters.shipCountry || 'US';
const promoIds = Array.isArray(filters.promoIds) ? filters.promoIds.filter(Boolean) : []; const rawPromoFilters = [
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
];
const promoCodes = Array.from(
new Set(
rawPromoFilters
.map((value) => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number') {
return String(value);
}
return '';
})
.filter((value) => value.length > 0)
)
);
const config = { const config = {
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent, merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
@@ -217,23 +236,26 @@ router.post('/simulate', async (req, res) => {
connection = dbConn.connection; connection = dbConn.connection;
release = dbConn.release; release = dbConn.release;
const params = [ const filteredOrdersParams = [
shipCountry, shipCountry,
formatDateForSql(startDt), formatDateForSql(startDt),
formatDateForSql(endDt) formatDateForSql(endDt)
]; ];
const promoJoin = promoIds.length > 0 const promoJoin = promoCodes.length > 0
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10' ? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10'
: ''; : '';
if (promoIds.length > 0) { let promoFilterClause = '';
params.push(promoIds); if (promoCodes.length > 0) {
const placeholders = promoCodes.map(() => '?').join(',');
promoFilterClause = `AND od.discount_code IN (${placeholders})`;
filteredOrdersParams.push(...promoCodes);
} }
params.push(formatDateForSql(startDt), formatDateForSql(endDt));
const filteredOrdersQuery = ` const filteredOrdersQuery = `
SELECT SELECT
o.order_id, o.order_id,
o.order_cid,
o.summary_subtotal, o.summary_subtotal,
o.summary_discount_subtotal, o.summary_discount_subtotal,
o.summary_shipping, o.summary_shipping,
@@ -243,15 +265,21 @@ router.post('/simulate', async (req, res) => {
${BUCKET_CASE} AS bucket_key ${BUCKET_CASE} AS bucket_key
FROM _order o FROM _order o
${promoJoin} ${promoJoin}
WHERE o.summary_total > 0 WHERE o.summary_shipping > 0
AND o.summary_subtotal > 0 AND o.summary_total > 0
AND o.order_status NOT IN (15) AND o.order_status NOT IN (15)
AND o.ship_method_selected <> 'holdit' AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ? AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ? AND o.date_placed BETWEEN ? AND ?
${promoIds.length > 0 ? 'AND od.discount_code IN (?)' : ''} ${promoFilterClause}
`; `;
const bucketParams = [
...filteredOrdersParams,
formatDateForSql(startDt),
formatDateForSql(endDt)
];
const bucketQuery = ` const bucketQuery = `
SELECT SELECT
f.bucket_key, f.bucket_key,
@@ -280,7 +308,7 @@ router.post('/simulate', async (req, res) => {
GROUP BY order_id GROUP BY order_id
) AS c ON c.order_id = f.order_id ) AS c ON c.order_id = f.order_id
LEFT JOIN ( LEFT JOIN (
SELECT order_id, SUM(discount_amount_points) AS points_redeemed SELECT order_id, SUM(discount_amount) AS points_redeemed
FROM order_discounts FROM order_discounts
WHERE discount_type = 20 AND discount_active = 1 WHERE discount_type = 20 AND discount_active = 1
GROUP BY order_id GROUP BY order_id
@@ -288,7 +316,7 @@ router.post('/simulate', async (req, res) => {
GROUP BY f.bucket_key GROUP BY f.bucket_key
`; `;
const [rows] = await connection.execute(bucketQuery, params); const [rows] = await connection.execute(bucketQuery, bucketParams);
const totals = { const totals = {
orders: 0, orders: 0,
@@ -344,14 +372,27 @@ router.post('/simulate', async (req, res) => {
? totals.pointsAwarded / totals.subtotal ? totals.pointsAwarded / totals.subtotal
: 0; : 0;
const redemptionRate = config.points.redemptionRate != null
? config.points.redemptionRate
: totals.pointsAwarded > 0
? Math.min(1, totals.pointsRedeemed / totals.pointsAwarded)
: 0;
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE; const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
// Calculate redemption rate using dollars redeemed from the matched order set
let calculatedRedemptionRate = 0;
if (config.points.redemptionRate != null) {
calculatedRedemptionRate = config.points.redemptionRate;
} else if (totals.pointsAwarded > 0 && pointDollarValue > 0) {
const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue;
if (totalRedeemedPoints > 0) {
calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded);
}
}
const redemptionRate = calculatedRedemptionRate;
// Calculate overall average COGS percentage for 'average' mode
let overallCogsPercentage = 0;
if (cogsCalculationMode === 'average' && totals.subtotal > 0) {
overallCogsPercentage = totals.cogs / totals.subtotal;
}
const bucketResults = []; const bucketResults = [];
let weightedProfitAmount = 0; let weightedProfitAmount = 0;
let weightedProfitPercent = 0; let weightedProfitPercent = 0;
@@ -368,7 +409,16 @@ router.post('/simulate', async (req, res) => {
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range); const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0; const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0;
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0; const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
const productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
// Calculate COGS based on the selected mode
let productCogs;
if (cogsCalculationMode === 'average') {
// Use overall average COGS percentage applied to this bucket's order value
productCogs = orderValue * overallCogsPercentage;
} else {
// Use actual COGS data from this bucket (existing behavior)
productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
}
const productDiscountAmount = orderValue * productDiscountRate; const productDiscountAmount = orderValue * productDiscountRate;
const effectiveRegularPrice = productDiscountRate < 0.99 const effectiveRegularPrice = productDiscountRate < 0.99
? orderValue / (1 - productDiscountRate) ? orderValue / (1 - productDiscountRate)
@@ -468,7 +518,8 @@ router.post('/simulate', async (req, res) => {
redemptionRate, redemptionRate,
pointDollarValue, pointDollarValue,
weightedProfitAmount, weightedProfitAmount,
weightedProfitPercent weightedProfitPercent,
overallCogsPercentage: cogsCalculationMode === 'average' ? overallCogsPercentage : undefined
}, },
buckets: bucketResults buckets: bucketResults
}); });

View File

@@ -8,10 +8,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse } from "@/types/discount-simulator"; import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatNumber } from "@/utils/productUtils"; import { formatNumber } from "@/utils/productUtils";
import { PlusIcon, X } from "lucide-react"; import { PlusIcon, X } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -41,24 +41,18 @@ interface ConfigPanelProps {
onMerchantFeeChange: (value: number) => void; onMerchantFeeChange: (value: number) => void;
fixedCostPerOrder: number; fixedCostPerOrder: number;
onFixedCostChange: (value: number) => void; onFixedCostChange: (value: number) => void;
cogsCalculationMode: CogsCalculationMode;
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
pointsPerDollar: number; pointsPerDollar: number;
redemptionRate: number; redemptionRate: number;
pointDollarValue: number; pointDollarValue: number;
onPointsChange: (update: { onPointDollarValueChange: (value: number) => void;
pointsPerDollar?: number;
redemptionRate?: number;
pointDollarValue?: number;
}) => void;
onConfigInputChange: () => void; onConfigInputChange: () => void;
onResetConfig: () => void; onResetConfig: () => void;
onRunSimulation: () => void; onRunSimulation: () => void;
isRunning: boolean; isRunning: boolean;
recommendedPoints?: { recommendedPointDollarValue?: number;
pointsPerDollar: number; onApplyRecommendedPointDollarValue?: () => void;
redemptionRate: number;
pointDollarValue: number;
};
onApplyRecommendedPoints?: () => void;
result?: DiscountSimulationResponse; result?: DiscountSimulationResponse;
} }
@@ -113,17 +107,19 @@ export function ConfigPanel({
onMerchantFeeChange, onMerchantFeeChange,
fixedCostPerOrder, fixedCostPerOrder,
onFixedCostChange, onFixedCostChange,
cogsCalculationMode,
onCogsCalculationModeChange,
pointsPerDollar, pointsPerDollar,
redemptionRate, redemptionRate,
pointDollarValue, pointDollarValue,
onPointsChange, onPointDollarValueChange,
onConfigInputChange, onConfigInputChange,
onResetConfig, onResetConfig,
promoLoading = false, promoLoading = false,
onRunSimulation, onRunSimulation,
isRunning, isRunning,
recommendedPoints, recommendedPointDollarValue,
onApplyRecommendedPoints, onApplyRecommendedPointDollarValue,
result result
}: ConfigPanelProps) { }: ConfigPanelProps) {
const promoOptions = useMemo(() => { const promoOptions = useMemo(() => {
@@ -213,10 +209,8 @@ export function ConfigPanel({
onShippingTiersChange(tiers); onShippingTiersChange(tiers);
}; };
const recommendedDiffers = recommendedPoints const recommendedDiffers = typeof recommendedPointDollarValue === 'number'
? recommendedPoints.pointsPerDollar !== pointsPerDollar || ? recommendedPointDollarValue !== pointDollarValue
recommendedPoints.redemptionRate !== redemptionRate ||
recommendedPoints.pointDollarValue !== pointDollarValue
: false; : false;
const fieldClass = "flex flex-col gap-1.5"; const fieldClass = "flex flex-col gap-1.5";
@@ -230,6 +224,7 @@ export function ConfigPanel({
const compactWideNumberClass = "h-8 px-2 text-sm"; const compactWideNumberClass = "h-8 px-2 text-sm";
const showProductAdjustments = productPromo.type !== "none"; const showProductAdjustments = productPromo.type !== "none";
const showShippingAdjustments = shippingPromo.type !== "none"; const showShippingAdjustments = shippingPromo.type !== "none";
const promoSelectValue = selectedPromoId != null ? selectedPromoId.toString() : "__all__";
const handleFieldBlur = useCallback(() => { const handleFieldBlur = useCallback(() => {
setTimeout(onRunSimulation, 0); setTimeout(onRunSimulation, 0);
@@ -252,14 +247,14 @@ export function ConfigPanel({
<div className={fieldClass}> <div className={fieldClass}>
<Label className={labelClass}>Promo code</Label> <Label className={labelClass}>Promo code</Label>
<Select <Select
value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined} value={promoSelectValue}
onValueChange={(value) => { onValueChange={(value) => {
if (value === '__all__') { if (value === '__all__') {
onPromoChange(undefined); onPromoChange(undefined);
return; return;
} }
const parsed = Number(value); const parsed = Number(value);
onPromoChange(Number.isNaN(parsed) ? undefined : parsed); onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
}} }}
> >
<SelectTrigger className={`${compactTriggerClass} w-full`}> <SelectTrigger className={`${compactTriggerClass} w-full`}>
@@ -297,15 +292,11 @@ export function ConfigPanel({
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{formatPercent(result.totals.productDiscountRate)} avg discount {formatPercent(result.totals.productDiscountRate)} avg discount
</Badge> </Badge>
<Badge variant="secondary" className="text-xs"> {result.totals.overallCogsPercentage != null && (
{result.totals.pointsPerDollar.toFixed(4)} pts/$ <Badge variant="secondary" className="text-xs">
</Badge> {formatPercent(result.totals.overallCogsPercentage)} avg COGS
<Badge variant="secondary" className="text-xs"> </Badge>
{formatPercent(result.totals.redemptionRate)} redeemed )}
</Badge>
<Badge variant="secondary" className="text-xs">
{formatCurrency(result.totals.pointDollarValue, 4)} pt value
</Badge>
</div> </div>
)} )}
</section> </section>
@@ -533,34 +524,54 @@ export function ConfigPanel({
<section className={sectionClass}> <section className={sectionClass}>
<span className={sectionTitleClass}>Order costs</span> <span className={sectionTitleClass}>Order costs</span>
<div className={fieldRowHorizontalClass}> <div className={fieldRowClass}>
<div className={fieldClass}> <div className={fieldClass}>
<Label className={labelClass}>Merchant fee (%)</Label> <Label className={labelClass}>COGS calculation</Label>
<Input <Select
className={compactNumberClass} value={cogsCalculationMode}
type="number" onValueChange={(value) => {
step="0.01"
value={merchantFeePercent}
onChange={(event) => {
onConfigInputChange(); onConfigInputChange();
onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent)); onCogsCalculationModeChange(value as CogsCalculationMode);
}} }}
onBlur={handleFieldBlur} >
/> <SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="actual">Actual COGS per bucket</SelectItem>
<SelectItem value="average">Average COGS percentage</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className={fieldClass}> <div className={fieldRowHorizontalClass}>
<Label className={labelClass}>Fixed cost/order ($)</Label> <div className={fieldClass}>
<Input <Label className={labelClass}>Merchant fee (%)</Label>
className={compactNumberClass} <Input
type="number" className={compactNumberClass}
step="0.01" type="number"
value={fixedCostPerOrder} step="0.01"
onChange={(event) => { value={merchantFeePercent}
onConfigInputChange(); onChange={(event) => {
onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder)); onConfigInputChange();
}} onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent));
onBlur={handleFieldBlur} }}
/> onBlur={handleFieldBlur}
/>
</div>
<div className={fieldClass}>
<Label className={labelClass}>Fixed cost/order ($)</Label>
<Input
className={compactNumberClass}
type="number"
step="0.01"
value={fixedCostPerOrder}
onChange={(event) => {
onConfigInputChange();
onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder));
}}
onBlur={handleFieldBlur}
/>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -568,34 +579,14 @@ export function ConfigPanel({
<section className={sectionClass}> <section className={sectionClass}>
<span className={sectionTitleClass}>Rewards points</span> <span className={sectionTitleClass}>Rewards points</span>
<div className={fieldRowClass}> <div className={fieldRowClass}>
<div className={fieldRowHorizontalClass}> <div className="grid gap-3 sm:grid-cols-2">
<div className={fieldClass}> <div className="flex flex-col gap-1.5">
<Label className={labelClass}>Points per $</Label> <span className={labelClass}>Points per $</span>
<Input <span className="text-sm font-medium">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
className={compactNumberClass}
type="number"
step="0.0001"
value={pointsPerDollar}
onChange={(event) => {
onConfigInputChange();
onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) });
}}
onBlur={handleFieldBlur}
/>
</div> </div>
<div className={fieldClass}> <div className="flex flex-col gap-1.5">
<Label className={labelClass}>Redemption rate (%)</Label> <span className={labelClass}>Redemption rate (%)</span>
<Input <span className="text-sm font-medium">{formatPercent(redemptionRate)}</span>
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> </div>
</div> </div>
<div className={fieldClass}> <div className={fieldClass}>
@@ -607,15 +598,15 @@ export function ConfigPanel({
value={pointDollarValue} value={pointDollarValue}
onChange={(event) => { onChange={(event) => {
onConfigInputChange(); onConfigInputChange();
onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) }); onPointDollarValueChange(parseNumber(event.target.value, pointDollarValue));
}} }}
onBlur={handleFieldBlur} onBlur={handleFieldBlur}
/> />
</div> </div>
{recommendedPoints && ( {typeof recommendedPointDollarValue === 'number' && (
<div className="flex flex-col gap-1 text-xs text-muted-foreground"> <div className="flex flex-col gap-1 text-xs text-muted-foreground">
{recommendedDiffers && onApplyRecommendedPoints && ( {recommendedDiffers && onApplyRecommendedPointDollarValue && (
<Button variant="outline" size="sm" onClick={onApplyRecommendedPoints}> <Button variant="outline" size="sm" onClick={onApplyRecommendedPointDollarValue}>
Use recommended Use recommended
</Button> </Button>
)} )}

View File

@@ -16,6 +16,7 @@ import {
DiscountPromoType, DiscountPromoType,
ShippingPromoType, ShippingPromoType,
ShippingTierConfig, ShippingTierConfig,
CogsCalculationMode,
} from "@/types/discount-simulator"; } from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -57,12 +58,9 @@ export function DiscountSimulator() {
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]); const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE); const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST); const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
const [pointsConfig, setPointsConfig] = useState({ const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
pointsPerDollar: 0, const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
redemptionRate: 0, const [pointDollarTouched, setPointDollarTouched] = useState(false);
pointDollarValue: DEFAULT_POINT_VALUE,
});
const [pointsTouched, setPointsTouched] = useState(false);
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined); const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false); const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
@@ -124,9 +122,22 @@ export function DiscountSimulator() {
const promosLoading = !promosQuery.data && promosQuery.isLoading; const promosLoading = !promosQuery.data && promosQuery.isLoading;
const selectedPromoCode = useMemo(() => {
if (selectedPromoId == null) {
return undefined;
}
return promosQuery.data?.find((promo) => promo.id === selectedPromoId)?.code || undefined;
}, [promosQuery.data, selectedPromoId]);
const createPayload = useCallback((): DiscountSimulationRequest => { const createPayload = useCallback((): DiscountSimulationRequest => {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
const payloadPointsConfig = {
pointsPerDollar: null,
redemptionRate: null,
pointDollarValue,
};
return { return {
dateRange: { dateRange: {
start: startOfDay(from).toISOString(), start: startOfDay(from).toISOString(),
@@ -135,6 +146,7 @@ export function DiscountSimulator() {
filters: { filters: {
shipCountry: 'US', shipCountry: 'US',
promoIds: selectedPromoId ? [selectedPromoId] : undefined, promoIds: selectedPromoId ? [selectedPromoId] : undefined,
promoCodes: selectedPromoCode ? [selectedPromoCode] : undefined,
}, },
productPromo, productPromo,
shippingPromo, shippingPromo,
@@ -145,13 +157,21 @@ export function DiscountSimulator() {
}), }),
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
pointsConfig: { cogsCalculationMode,
pointsPerDollar: pointsConfig.pointsPerDollar, pointsConfig: payloadPointsConfig,
redemptionRate: pointsConfig.redemptionRate,
pointDollarValue: pointsConfig.pointDollarValue,
},
}; };
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]); }, [
dateRange,
selectedPromoId,
selectedPromoCode,
productPromo,
shippingPromo,
shippingTiers,
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
pointDollarValue,
]);
const simulationMutation = useMutation< const simulationMutation = useMutation<
DiscountSimulationResponse, DiscountSimulationResponse,
@@ -175,25 +195,14 @@ export function DiscountSimulator() {
setSimulationResult(data); setSimulationResult(data);
if (!pointsTouched) { if (!pointDollarTouched && typeof data.totals.pointDollarValue === 'number') {
skipAutoRunRef.current = true; const incomingValue = data.totals.pointDollarValue;
setPointsConfig((prev) => { if (pointDollarValue !== incomingValue) {
if ( skipAutoRunRef.current = true;
prev.pointsPerDollar === data.totals.pointsPerDollar && setPointDollarValue(incomingValue);
prev.redemptionRate === data.totals.redemptionRate && } else {
prev.pointDollarValue === data.totals.pointDollarValue skipAutoRunRef.current = false;
) { }
skipAutoRunRef.current = false;
return prev;
}
return {
...prev,
pointsPerDollar: data.totals.pointsPerDollar,
redemptionRate: data.totals.redemptionRate,
pointDollarValue: data.totals.pointDollarValue,
};
});
} }
}, },
onError: (error) => { onError: (error) => {
@@ -241,7 +250,13 @@ export function DiscountSimulator() {
shippingTiers?: ShippingTierConfig[]; shippingTiers?: ShippingTierConfig[];
merchantFeePercent?: number; merchantFeePercent?: number;
fixedCostPerOrder?: number; fixedCostPerOrder?: number;
pointsConfig?: typeof pointsConfig; cogsCalculationMode?: CogsCalculationMode;
pointsConfig?: {
pointsPerDollar?: number | null;
redemptionRate?: number | null;
pointDollarValue?: number | null;
};
pointDollarValue?: number;
}; };
skipAutoRunRef.current = true; skipAutoRunRef.current = true;
@@ -282,9 +297,18 @@ export function DiscountSimulator() {
setFixedCostPerOrder(parsed.fixedCostPerOrder); setFixedCostPerOrder(parsed.fixedCostPerOrder);
} }
if (parsed.pointsConfig) { if (parsed.cogsCalculationMode === 'actual' || parsed.cogsCalculationMode === 'average') {
setPointsConfig(parsed.pointsConfig); setCogsCalculationMode(parsed.cogsCalculationMode);
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); setLoadedFromStorage(true);
@@ -313,9 +337,10 @@ export function DiscountSimulator() {
shippingTiers, shippingTiers,
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
pointsConfig, cogsCalculationMode,
pointDollarValue,
}); });
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]); }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue]);
useEffect(() => { useEffect(() => {
if (!hasLoadedConfig) { if (!hasLoadedConfig) {
@@ -361,26 +386,19 @@ export function DiscountSimulator() {
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [loadedFromStorage, runSimulation]); }, [loadedFromStorage, runSimulation]);
const recommendedPoints = simulationResult?.totals const currentPointsPerDollar = simulationResult?.totals?.pointsPerDollar ?? 0;
? { const currentRedemptionRate = simulationResult?.totals?.redemptionRate ?? 0;
pointsPerDollar: simulationResult.totals.pointsPerDollar, const recommendedPointDollarValue = simulationResult?.totals?.pointDollarValue;
redemptionRate: simulationResult.totals.redemptionRate,
pointDollarValue: simulationResult.totals.pointDollarValue,
}
: undefined;
const handlePointsChange = (update: Partial<typeof pointsConfig>) => { const handlePointDollarValueChange = (value: number) => {
setPointsTouched(true); setPointDollarTouched(true);
setPointsConfig((prev) => ({ setPointDollarValue(value);
...prev,
...update,
}));
}; };
const handleApplyRecommendedPoints = () => { const handleApplyRecommendedPointValue = () => {
if (recommendedPoints) { if (typeof recommendedPointDollarValue === 'number') {
setPointsConfig(recommendedPoints); setPointDollarValue(recommendedPointDollarValue);
setPointsTouched(true); setPointDollarTouched(true);
} }
}; };
@@ -395,12 +413,9 @@ export function DiscountSimulator() {
setShippingTiers([]); setShippingTiers([]);
setMerchantFeePercent(DEFAULT_MERCHANT_FEE); setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
setFixedCostPerOrder(DEFAULT_FIXED_COST); setFixedCostPerOrder(DEFAULT_FIXED_COST);
setPointsConfig({ setCogsCalculationMode('actual');
pointsPerDollar: 0, setPointDollarValue(DEFAULT_POINT_VALUE);
redemptionRate: 0, setPointDollarTouched(false);
pointDollarValue: DEFAULT_POINT_VALUE,
});
setPointsTouched(false);
setSimulationResult(undefined); setSimulationResult(undefined);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -445,16 +460,18 @@ export function DiscountSimulator() {
onMerchantFeeChange={setMerchantFeePercent} onMerchantFeeChange={setMerchantFeePercent}
fixedCostPerOrder={fixedCostPerOrder} fixedCostPerOrder={fixedCostPerOrder}
onFixedCostChange={setFixedCostPerOrder} onFixedCostChange={setFixedCostPerOrder}
pointsPerDollar={pointsConfig.pointsPerDollar} cogsCalculationMode={cogsCalculationMode}
redemptionRate={pointsConfig.redemptionRate} onCogsCalculationModeChange={setCogsCalculationMode}
pointDollarValue={pointsConfig.pointDollarValue} pointsPerDollar={currentPointsPerDollar}
onPointsChange={handlePointsChange} redemptionRate={currentRedemptionRate}
pointDollarValue={pointDollarValue}
onPointDollarValueChange={handlePointDollarValueChange}
onConfigInputChange={handleConfigInputChange} onConfigInputChange={handleConfigInputChange}
onResetConfig={resetConfig} onResetConfig={resetConfig}
onRunSimulation={() => runSimulation()} onRunSimulation={() => runSimulation()}
isRunning={isSimulating} isRunning={isSimulating}
recommendedPoints={recommendedPoints} recommendedPointDollarValue={recommendedPointDollarValue}
onApplyRecommendedPoints={handleApplyRecommendedPoints} onApplyRecommendedPointDollarValue={handleApplyRecommendedPointValue}
result={simulationResult} result={simulationResult}
/> />
</div> </div>

View File

@@ -54,6 +54,7 @@ export interface DiscountSimulationTotals {
pointDollarValue: number; pointDollarValue: number;
weightedProfitAmount: number; weightedProfitAmount: number;
weightedProfitPercent: number; weightedProfitPercent: number;
overallCogsPercentage?: number;
} }
export interface DiscountSimulationResponse { export interface DiscountSimulationResponse {
@@ -65,6 +66,8 @@ export interface DiscountSimulationResponse {
buckets: DiscountSimulationBucket[]; buckets: DiscountSimulationBucket[];
} }
export type CogsCalculationMode = 'actual' | 'average';
export interface DiscountSimulationRequest { export interface DiscountSimulationRequest {
dateRange: { dateRange: {
start: string; start: string;
@@ -72,7 +75,8 @@ export interface DiscountSimulationRequest {
}; };
filters: { filters: {
shipCountry?: string; shipCountry?: string;
promoIds?: number[]; promoIds?: Array<number | string>;
promoCodes?: string[];
}; };
productPromo: { productPromo: {
type: DiscountPromoType; type: DiscountPromoType;
@@ -88,9 +92,10 @@ export interface DiscountSimulationRequest {
shippingTiers: ShippingTierConfig[]; shippingTiers: ShippingTierConfig[];
merchantFeePercent: number; merchantFeePercent: number;
fixedCostPerOrder: number; fixedCostPerOrder: number;
cogsCalculationMode: CogsCalculationMode;
pointsConfig: { pointsConfig: {
pointsPerDollar: number; pointsPerDollar: number | null;
redemptionRate: number; redemptionRate: number | null;
pointDollarValue: number; pointDollarValue: number | null;
}; };
} }