Compare commits
4 Commits
a161f4533d
...
1696ecf591
| Author | SHA1 | Date | |
|---|---|---|---|
| 1696ecf591 | |||
| dc774862a7 | |||
| d3e3cba087 | |||
| 4ea3a4aec3 |
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user