Skip to content

Commit d177212

Browse files
committed
Implement full weight-based shipping rates logic
1 parent 17ca686 commit d177212

File tree

3 files changed

+96
-104
lines changed

3 files changed

+96
-104
lines changed

app/admin/settings/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ export default async function AdminSettings() {
146146
<input name="standard_label" type="text" defaultValue={shipping.standard_label ?? 'USPS Ground Advantage (3-5 Days)'}
147147
className="w-full bg-[#0B0B0D] border border-white/10 rounded-md px-4 py-3 text-sm text-white focus:border-gold/50 outline-none transition-all" />
148148
<div className="space-y-1.5">
149-
<label className="text-[9px] uppercase tracking-luxury text-luxury-subtext font-medium">Weight Brackets (JSON) Default: [ {`{"max_lb": 0.5, "rate": 4.99}`} ... ]</label>
149+
<label className="text-[9px] uppercase tracking-luxury text-luxury-subtext font-medium">Weight Brackets (JSON) - Orders match FIRST bracket where weight ≤ max_lb</label>
150+
<p className="text-[10px] text-luxury-subtext/60 leading-relaxed mb-2">Example: Cart weighing 0.8 lb matches the 1 lb bracket. Cart weighing 1.5 lb matches the 2 lb bracket. Always include a catch-all bracket with max_lb: 999 for heavy orders.</p>
150151
<textarea name="weight_brackets" defaultValue={JSON.stringify(shipping.weight_brackets || [
151152
{ max_lb: 0.5, rate: 4.99 },
152153
{ max_lb: 1, rate: 6.99 },
@@ -171,7 +172,7 @@ export default async function AdminSettings() {
171172
<input name="express_label" type="text" defaultValue={shipping.express_label ?? 'USPS Priority Mail (1-3 Days)'}
172173
className="w-full bg-[#0B0B0D] border border-white/10 rounded-md px-4 py-3 text-sm text-white focus:border-gold/50 outline-none transition-all" />
173174
<div className="space-y-1.5">
174-
<label className="text-[9px] uppercase tracking-luxury text-luxury-subtext font-medium">Weight Brackets (JSON)</label>
175+
<label className="text-[9px] uppercase tracking-luxury text-luxury-subtext font-medium">Weight Brackets (JSON) - Orders match FIRST bracket where weight ≤ max_lb</label>
175176
<textarea name="express_weight_brackets" defaultValue={JSON.stringify(shipping.express_weight_brackets || [
176177
{ max_lb: 1, rate: 9.99 },
177178
{ max_lb: 3, rate: 14.99 },

app/api/checkout/route.ts

Lines changed: 14 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -87,47 +87,21 @@ export async function POST(req: Request) {
8787
.maybeSingle();
8888

8989
const cfg = shippingConfig?.setting_value || {};
90-
const freeThreshold = parseFloat(cfg.free_shipping_threshold ?? "100");
91-
const isFree = subtotal >= freeThreshold;
90+
const isFree = subtotal >= parseFloat(cfg.free_shipping_threshold ?? "100");
9291

93-
// ─── Build shipping options for Stripe ──────────────────────────────
94-
// Stripe will show ALL options; the customer picks one.
95-
// Domestic-oriented options shown first, International last.
92+
// Calculate all 4 options based on actual weight
9693
const stdRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "standard");
9794
const expRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "express");
98-
99-
// International Standard Evaluation
100-
let intlBrackets = cfg.intl_weight_brackets;
101-
if (!intlBrackets || intlBrackets.length === 0) {
102-
intlBrackets = [
103-
{ max_lb: 1, rate: 19.99 },
104-
{ max_lb: 3, rate: 29.99 },
105-
{ max_lb: 5, rate: 39.99 },
106-
{ max_lb: 999, rate: 59.99 }
107-
];
108-
}
109-
const matchingIntlBracket = intlBrackets.find((b: any) => totalWeightLb <= b.max_lb) || intlBrackets[intlBrackets.length - 1];
110-
const intlStandardCost = matchingIntlBracket ? parseFloat(matchingIntlBracket.rate) : 19.99;
111-
112-
// International Express Evaluation
113-
let intlExpressBrackets = cfg.intl_express_weight_brackets;
114-
if (!intlExpressBrackets || intlExpressBrackets.length === 0) {
115-
intlExpressBrackets = [
116-
{ max_lb: 1, rate: 49.99 },
117-
{ max_lb: 3, rate: 69.99 },
118-
{ max_lb: 999, rate: 89.99 }
119-
]
120-
}
121-
const matchingIntlExpressBracket = intlExpressBrackets.find((b: any) => totalWeightLb <= b.max_lb) || intlExpressBrackets[intlExpressBrackets.length - 1];
122-
const intlExpressCost = matchingIntlExpressBracket ? parseFloat(matchingIntlExpressBracket.rate) : 49.99;
95+
const intlStdRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "intl_standard");
96+
const intlExpRate = calculateShippingRate(totalWeightLb, subtotal, cfg, "intl_express");
12397

12498
const shippingOptions: Stripe.Checkout.SessionCreateParams.ShippingOption[] = [
125-
// Option 1: US Domestic Standard (or FREE)
99+
// Option 1: US Domestic Standard (or FREE if threshold met)
126100
{
127101
shipping_rate_data: {
128102
type: "fixed_amount",
129103
fixed_amount: {
130-
amount: isFree ? 0 : Math.round(stdRate.cost * 100),
104+
amount: Math.round(stdRate.cost * 100),
131105
currency: "usd",
132106
},
133107
display_name: isFree ? "Free Standard Shipping 🎁" : `${stdRate.name} (US)`,
@@ -157,13 +131,13 @@ export async function POST(req: Request) {
157131
shipping_rate_data: {
158132
type: "fixed_amount",
159133
fixed_amount: {
160-
amount: Math.round(intlStandardCost * 100),
134+
amount: Math.round(intlStdRate.cost * 100),
161135
currency: "usd",
162136
},
163-
display_name: "USPS Priority Mail International 🌍",
137+
display_name: `${intlStdRate.name} 🌍`,
164138
delivery_estimate: {
165-
minimum: { unit: "business_day", value: 6 },
166-
maximum: { unit: "business_day", value: 10 },
139+
minimum: { unit: "business_day", value: intlStdRate.minDays },
140+
maximum: { unit: "business_day", value: intlStdRate.maxDays },
167141
},
168142
},
169143
},
@@ -172,13 +146,13 @@ export async function POST(req: Request) {
172146
shipping_rate_data: {
173147
type: "fixed_amount",
174148
fixed_amount: {
175-
amount: Math.round(intlExpressCost * 100),
149+
amount: Math.round(intlExpRate.cost * 100),
176150
currency: "usd",
177151
},
178-
display_name: "USPS Priority Mail Express International 🚀",
152+
display_name: `${intlExpRate.name} 🚀`,
179153
delivery_estimate: {
180-
minimum: { unit: "business_day", value: 3 },
181-
maximum: { unit: "business_day", value: 5 },
154+
minimum: { unit: "business_day", value: intlExpRate.minDays },
155+
maximum: { unit: "business_day", value: intlExpRate.maxDays },
182156
},
183157
},
184158
},

lib/utils/shippo.ts

Lines changed: 79 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -60,73 +60,90 @@ export function calculateTotalWeightLb(items: Array<{
6060
}
6161

6262
export function calculateShippingRate(
63-
totalWeightLb: number,
64-
subtotal: number,
65-
config: any,
66-
option: 'standard' | 'express'
63+
weightLb: number,
64+
subtotal: number,
65+
config: any,
66+
type: 'standard' | 'express' | 'intl_standard' | 'intl_express'
6767
): { cost: number; name: string; minDays: number; maxDays: number } {
68-
const freeThreshold = parseFloat(config.free_shipping_threshold ?? "100");
69-
70-
if (option === 'express') {
71-
const brackets = (config.express_weight_brackets && config.express_weight_brackets.length > 0)
72-
? config.express_weight_brackets
73-
: [
74-
{ max_lb: 1, rate: 9.99 },
75-
{ max_lb: 3, rate: 14.99 },
76-
{ max_lb: 999, rate: 19.99 }
77-
];
78-
79-
// Find the first bracket where weight <= max_lb
80-
const bracket = brackets.find((b: any) => totalWeightLb <= b.max_lb) || brackets[brackets.length - 1];
81-
82-
return {
83-
cost: bracket ? parseFloat(bracket.rate.toString()) : parseFloat(config.express_rate ?? "19.99"),
84-
name: config.express_label || "USPS Priority Mail (1-3 Days)",
85-
minDays: 1,
86-
maxDays: 3
87-
};
88-
}
89-
90-
// Standard Option
91-
if (subtotal >= freeThreshold) {
92-
return {
93-
cost: 0,
94-
name: "Free Standard Shipping",
95-
minDays: 3,
96-
maxDays: 5
97-
};
98-
}
99-
100-
// Bracket evaluation
101-
const brackets = (config.weight_brackets && config.weight_brackets.length > 0)
102-
? config.weight_brackets
103-
: [
104-
{ max_lb: 0.5, rate: 4.99 },
105-
{ max_lb: 1, rate: 6.99 },
106-
{ max_lb: 2, rate: 8.99 },
107-
{ max_lb: 5, rate: 12.99 },
108-
{ max_lb: 999, rate: 15.99 }
109-
];
110-
111-
const bracket = brackets.find((b: any) => totalWeightLb <= b.max_lb) || brackets[brackets.length - 1];
112-
if (bracket) {
113-
return {
114-
cost: parseFloat(bracket.rate.toString()),
115-
name: config.standard_label || "USPS Ground Advantage (3-5 Days)",
116-
minDays: 3,
117-
maxDays: 5
118-
};
119-
}
120-
121-
// Fallback to flat rate
68+
// Free shipping check (US only)
69+
const freeThreshold = parseFloat(config.free_shipping_threshold ?? '100');
70+
if (type === 'standard' && subtotal >= freeThreshold) {
12271
return {
123-
cost: parseFloat(config.standard_rate ?? "7.99"),
124-
name: config.standard_label || "USPS Ground Advantage (3-5 Days)",
125-
minDays: 3,
126-
maxDays: 5
72+
cost: 0,
73+
name: config.standard_label || 'Free Standard Shipping',
74+
minDays: 3,
75+
maxDays: 5,
12776
};
77+
}
78+
79+
// Select bracket config
80+
let brackets: Array<{ max_lb: number; rate: number }> = [];
81+
let name = '';
82+
let minDays = 3;
83+
let maxDays = 5;
84+
85+
switch (type) {
86+
case 'standard':
87+
brackets = config.weight_brackets || DEFAULT_US_STANDARD_BRACKETS;
88+
name = config.standard_label || 'USPS Ground Advantage (3-5 Days)';
89+
minDays = 3;
90+
maxDays = 5;
91+
break;
92+
case 'express':
93+
brackets = config.express_weight_brackets || DEFAULT_US_EXPRESS_BRACKETS;
94+
name = config.express_label || 'USPS Priority Mail (1-3 Days)';
95+
minDays = 1;
96+
maxDays = 3;
97+
break;
98+
case 'intl_standard':
99+
brackets = config.intl_weight_brackets || DEFAULT_INTL_STANDARD_BRACKETS;
100+
name = 'USPS Priority Mail International';
101+
minDays = 6;
102+
maxDays = 10;
103+
break;
104+
case 'intl_express':
105+
brackets = config.intl_express_weight_brackets || DEFAULT_INTL_EXPRESS_BRACKETS;
106+
name = 'USPS Priority Mail Express International';
107+
minDays = 3;
108+
maxDays = 5;
109+
break;
110+
}
111+
112+
// Find matching bracket (first bracket where weight <= max_lb)
113+
const matchingBracket = brackets.find((b) => weightLb <= b.max_lb);
114+
const cost = matchingBracket ? parseFloat(String(matchingBracket.rate)) : parseFloat(String(brackets[brackets.length - 1]?.rate ?? 15.99));
115+
116+
return { cost, name, minDays, maxDays };
128117
}
129118

119+
// Default brackets (fallback if DB config missing)
120+
const DEFAULT_US_STANDARD_BRACKETS = [
121+
{ max_lb: 0.5, rate: 4.99 },
122+
{ max_lb: 1, rate: 6.99 },
123+
{ max_lb: 2, rate: 8.99 },
124+
{ max_lb: 5, rate: 12.99 },
125+
{ max_lb: 999, rate: 15.99 },
126+
];
127+
128+
const DEFAULT_US_EXPRESS_BRACKETS = [
129+
{ max_lb: 1, rate: 9.99 },
130+
{ max_lb: 3, rate: 14.99 },
131+
{ max_lb: 999, rate: 19.99 },
132+
];
133+
134+
const DEFAULT_INTL_STANDARD_BRACKETS = [
135+
{ max_lb: 1, rate: 19.99 },
136+
{ max_lb: 3, rate: 29.99 },
137+
{ max_lb: 5, rate: 39.99 },
138+
{ max_lb: 999, rate: 59.99 },
139+
];
140+
141+
const DEFAULT_INTL_EXPRESS_BRACKETS = [
142+
{ max_lb: 1, rate: 49.99 },
143+
{ max_lb: 3, rate: 69.99 },
144+
{ max_lb: 999, rate: 89.99 },
145+
];
146+
130147
export async function createShippingLabel(order: any) {
131148
const apiKey = process.env.SHIPPO_API_KEY;
132149
if (!apiKey) {

0 commit comments

Comments
 (0)